diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md new file mode 100644 index 00000000..9b5c753d --- /dev/null +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -0,0 +1,70 @@ +--- +name: agent-core-dev +description: Use when developing in packages/agent-core-v2 (the DI × Scope agent engine) — adding or modifying a domain Service, choosing a LifecycleScope, wiring DI dependencies, splitting a domain across scopes, owning or migrating a config section, gating behavior behind an experimental flag, raising coded errors, working on the permission system, writing DI/Scope tests, porting business logic from agent-core (v1) to v2, triaging a main-branch commit against v2, or exposing a v2 domain over server-v2 while keeping the /api/v1 wire contract compatible with released clients. Self-contained guide organized by development stage (orient → design → implement → test → verify) plus align workflows for v1→v2 migration, main-branch commit triage, and server-v2 wire exposure; each file carries the rules, examples, and red lines for its step. +--- + +# agent-core-dev + +> Develop `packages/agent-core-v2` by lifecycle stage. This skill is **self-contained**: every rule, recipe, and red line lives in the stage files below — it does not delegate to `packages/agent-core-v2/docs/`. + +`agent-core-v2` is the new agent engine built on the **DI × Scope** architecture (a port of `packages/agent-core`). Everything resolves through the container: a service declares an **identity**, its **dependencies**, and a **lifetime**; the container decides construction, singleton-per-scope, ordering, and disposal. The stage files restate the rules in imperative form so you can work without reading the source docs. + +## Lifecycle at a glance + +```text +Orient → Design → Implement → Test → Verify + │ │ │ │ │ + │ │ │ │ └─ lint:imports · typecheck · test · dep graph · red lines + │ │ │ └─ test.md + │ │ └─ implement.md (+ errors.md · flags.md · permission.md) + │ └─ design.md + └─ orient.md +``` + +Stages are ordered but not strictly linear: a test failure (stage 4) that reveals a wrong scope sends you back to design (stage 2); a `CyclicDependencyError` sends you to `design.md` §dependency-direction and `implement.md` §cycles. + +## Workflows + +End-to-end procedures that span the stages. Reach for these before reading the stage files individually. + +- [Align (port `agent-core` → `agent-core-v2`)](align.md): split a v1 class into semantic units, fix each unit's domain / scope / Service / dependencies, then migrate the logic and tests. Use when the task is "move feature X from v1 to v2" or "port `IXxxService` to v2". +- [Commit align (triage a `main` commit against v2)](commit-align.md): given one `main` commit hash + a short note, find the v1 logic it changed, check whether v2 already has the corresponding implementation, bucket it (aligned / partial / missing / not-applicable), and recommend a minimal fix. Use in the `pythinker-code-v2`-catching-up-to-`main` phase, for one commit at a time; escalate to [align.md](align.md) if the gap is a whole domain. +- [Server align (expose `agent-core-v2` over `server-v2`)](server-align.md): wire a v2 domain into `packages/kap-server` over `/api/v2` (native) and `/api/v1` (v1-compatible mirror), keep the wire schema byte-compatible with the established v1 contract by sharing the `@pymodel/protocol` schema, and isolate v1-only behavior in a `Legacy` edge adapter instead of distorting the native v2 Service. Use when the task is "expose the new v2 Service on the server", "add a route to the `/api/v1` surface", or "keep server-v2 wire-compatible with released v1 clients". + +## Stages + +- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the no-comment convention. Read before touching business code. +- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding. + - Topic: [Domain boundaries vs Scope](domain-boundaries.md) — keep `session` / `agent` / `turn` from becoming god objects; data-ownership test and their split conclusions. + - Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on. + - Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade. +- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook. + - Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules. + - Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay. + - Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation. + - Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence. + - Topic: [Permission](permission.md) — risk-only chain-of-responsibility kernel, harness constraints and product reviews as domain `onBeforeExecuteTool` veto listeners (`veto` / `allow` / `pass` / cold `waitUntil` factories), shared `toolApproval` round-trip, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. + - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). +- [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. +- [Stage 5 — Verify & submit](verify.md): `lint:imports`, `typecheck`, `test`, and the pre-submit checklist. + +## How to use this skill + +Jump to the stage you are in and read that one file; each is self-contained and ends with its own red lines. Skim the global red lines below before submitting — they catch most mistakes across every stage. The repo's source of truth remains the code in `packages/agent-core-v2/src/`; this skill codifies the same rules so you do not have to re-derive them. + +## Global red lines + +Invariants that hold across every stage. Each is expanded in the stage file noted. + +1. No `new` on a class whose constructor carries `@IService` deps — inject with `@IX` or `accessor.get(IX)`. (implement.md) +2. `@IX` decorates constructor parameters only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services). (service-authoring.md) +3. Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. (implement.md) +4. Parent scope never depends on child scope — short-lived may inject long-lived, never the reverse. (orient.md) +5. No cyclic dependencies — refactor (extract a third Service / use an event / re-scope); activation timing does not break dependency cycles. (design.md, implement.md) +6. `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. (implement.md) +7. Scope follows state identity — no `Map` at `App` to fake per-session state. (design.md) +8. Foundational layers never know upstream ones; business code never depends on the edge layer (`gateway`/`rpc`). (design.md) +9. Throw coded errors; register codes centrally; branch on `code` across the wire, never `instanceof`. (errors.md) +10. Gate unreleased behavior behind a flag contributed via `registerFlagDefinition` and resolved through `IFlagService.enabled(id)`; no ad-hoc env toggles. (flags.md) +11. Tests resolve the SUT by interface; shared stubs live under `test/`, never `src/`. (test.md) +12. Config is the preference registry: only preferences that are persistable, schema'd, and user/operator-facing go in `IConfigService`. Domain-specific config (including env-only operational toggles) goes through `registerConfigSection` + `envOverlay`. Facts → `IBootstrapService`, and host invocation arguments (CLI flags, host identity headers, prompt identity) → `BootstrapInput.args` / `IBootstrapService.args` — never new per-domain runtime-options services; domain runtime state (cron/flags/model) never goes onto `IBootstrapService`; session state → Session scope; constants → code. Business domains never call `IBootstrapService.getEnv()` directly. (config.md) diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md new file mode 100644 index 00000000..e77656dc --- /dev/null +++ b/.agents/skills/agent-core-dev/align.md @@ -0,0 +1,235 @@ +# Subskill — Align (port `agent-core` → `agent-core-v2`) + +Port business logic from `packages/agent-core` (v1) into `packages/agent-core-v2` (v2) by **splitting semantics, then fixing the domain, scope, Service, and dependency relationships**, and finally migrating the logic and tests. + +Use this when the task is "move feature X from v1 to v2", "port `IXxxService` to v2", or "align a v1 domain with the v2 architecture". It complements the stage files: orient / design / implement / test explain the *target* architecture; this file explains how to get there *from v1*. + +## The one-paragraph mental model + +v1 is a **VSCode-style singleton container**: services self-register with `registerSingleton`, resolve as singleton-per-container, and have no explicit lifetime tier — so a single `ISessionService` / `IToolService` tends to accumulate global, per-session, and per-agent state in one class. v2 is a **DI × Scope tree**: every service binds to one of `App` / `Session` / `Agent`, and a domain with state at several lifetimes is split into several Services. Porting is therefore **not** a file copy — it is "find each lifetime of state hiding in the v1 class, give each its own v2 Service at the right scope, then re-wire the dependencies". + +## v1 → v2 at a glance + +| Concern | v1 (`agent-core`) | v2 (`agent-core-v2`) | +|---|---|---| +| Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, ScopeActivation.OnDemand, 'domain')` | +| DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/lifecycle'` | +| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Workspace/Session/Agent) — see orient.md | +| Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility | +| Test import | `from '@pymodel/agent-core/di/test'` | `from '#/_base/di/test'` | +| Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md | +| Scope tests | none | `createScopedTestHost` — see test.md | +| Errors | `from '../../errors'` (central `PythinkerError`, `ErrorCodes`) | `from '#/_base/errors'` + domain co-located `XxxError` — see errors.md | +| Flags | `flags/` (process-global `FlagResolver`) | `flag/` (App-scope `IFlagService`) — see flags.md | +| Permission | `agent/permission/` (hardcoded chain) | `permission*` (registry + composer) — see permission.md | + +## The align workflow + +```text +Read v1 → Semantic split → Map domain → Assign scope → Shape Services + → Direct dependencies → Port logic → Port tests → Verify +``` + +Each step below states the goal and the concrete action, then points to the stage file that goes deeper. Do them in order; a later step often sends you back to an earlier one (a scope that does not fit means the semantic split was wrong). + +### 1. Read v1 + +**Goal:** build an accurate inventory of what the v1 code actually owns. Read the v1 *source*, not v1 docs. + +Actions: + +- Locate the v1 entry: contract (`/.ts`) + impl (`/Service.ts`), plus any helpers under the same folder. +- Inventory three things from the impl: + - **State** — every field / `Map` / cache the class holds. For each, note its *identity* (global? keyed by `sessionId`? by `agentId`?). + - **Behavior** — every public method; group them by which state they touch. + - **Dependencies** — every `@IFoo` constructor injection and every cross-domain relative import (`from '..//...'`). +- Note the v1 registration line (`registerSingleton(...)`) and any `services.set(IX, ...)` overrides at bootstrap (these reveal runtime static args or prebuilt instances the port must preserve). + +Do not start splitting yet — an accurate inventory prevents the common mistake of porting the class shape instead of the semantics. + +### 2. Semantic split + +**Goal:** break one v1 class into independent semantic units, each owning state at exactly one lifetime. This is the heart of the port. + +Method — for each piece of state from the inventory, ask: + +1. **What is it keyed by?** nothing → a global unit; `sessionId` → a per-session unit; `agentId` → a per-agent unit. +2. **When should it die?** with the process / the session / the agent. State that must outlive its neighbors is a different unit. +3. **Which methods touch only this state?** they travel with the unit. + +Worked example — v1 `ISessionService` (one class, ~600 lines) holds: + +- a global index of all sessions → **global** unit → v2 `sessionStore` (`ISessionStore`, App); +- this session's metadata → **per-session** unit → v2 `sessionMetaStore` (`ISessionMetaStore`, Session); +- this session's activity / status → **per-session** unit → v2 `sessionActivity`; +- this session's context projection → **per-session** unit → v2 `sessionContext`; +- child-agent lifecycle driven by a session → **per-session** unit → v2 `agentLifecycle`; create/close/archive/fork of the session itself → **per-workspace** unit → v2 `sessionLifecycle` (Workspace, one per live workspace handler). + +A v1 class that maps cleanly to one v1 decorator often becomes **three to five** v2 Services. That is expected and correct — do not try to keep the v1 class shape. + +Red lines: + +- If two pieces of state have different identities, they belong in different units — do not keep them together "because v1 did". +- Do not split by method count or file aesthetics; split by state identity (design.md §3). +- If a unit has no mutable state (pure behavior), defer its scope decision to step 4 (it is pulled down by its shortest-lived dependency). + +### 3. Map to v2 domain + +**Goal:** assign each semantic unit to a v2 domain — an existing one if it fits, a new one only if none does. + +Actions: + +- Search v2 `src/` for an existing domain that owns the same responsibility. Prefer joining an existing domain over creating a new one. +- If creating a domain, name it after the responsibility (camelCase folder, e.g. `sessionActivity`), not after the v1 file. +- Keep a domain's public surface to one contract file (`.ts`) plus its impl(s). + +Reference mapping (a **starting point**, not gospel — verify against the current v2 `src/`, which is the source of truth): + +| v1 location | v2 domain(s) | +|---|---| +| `services/session/`, `session/` | `session`, `sessionStore`, `sessionMetaStore`, `sessionActivity`, `sessionContext`, `agentLifecycle` | +| `services/tool/`, `tools/`, `agent/tool/` | `toolRegistry`, `toolStore`, `toolExecutor`, `tooldedup`, `userTool` | +| `loop/`, `agent/` (turn loop) | `loop`, `llmRequester`, `llmRequestLog`, `turn` | +| `agent/context/`, `agent/compaction/` | `contextMemory`, `contextProjector`, `contextSize`, `fullCompaction`, `dynamicInjector` | +| `agent/permission/` | `permission`, `permissionMode`, `permissionPolicy`, `permissionRules`, `approval`, `externalHooks` | +| `agent/goal/`, `agent/plan/`, `agent/dynamic_workflow/`, `agent/cron/`, `agent/background/` | `goal`, `plan`, `dynamic_workflow`, `cron`, `background`, `subagentHost` | +| `services/config/`, `agent/config/` | `config` | +| `services/event/`, `base/common/event` | `event`, `eventBus` | +| `services/logger/`, `logging/` | `log` | +| `services/fileStore/` | `filestore`, `blobStore` | +| `services/fs/`, `services/workspace/` | `fs`, `workspace` | +| `services/auth/`, `services/oauth/` | `auth` | +| `services/environment/` | `environment` | +| `services/terminal/` | `terminal` | +| `services/question/`, `services/approval/` | `question`, `approval` | +| `services/prompt/`, `agent/injection/` | `prompt`, `dynamicInjector` | +| `services/mcp/`, `mcp/` | `mcp` | +| `plugin/`, `profile/`, `skill/` | `plugin`, `profile`, `skill` | +| `rpc/`, `services/coreProcess/` | `rpc`, `gateway` | +| `di/` | `_base/di` | +| `errors/`, `errors.ts` | `_base/errors` + co-located domain errors | +| `flags/` | `flag` | +| `telemetry.ts` | `telemetry` | +| `agent/records/` | (records split) — verify in v2 `src/` | + +When the table says "verify", or when v1 and v2 have diverged, **read the v2 `src/` tree and decide from the code** — do not invent a mapping. + +### 4. Assign scope + +For each semantic unit, fix its `LifecycleScope` from the identity you found in step 2. Follow design.md §2 verbatim: + +- global → `App`; per `sessionId` → `Session`; per `agentId` → `Agent`. +- Stateless unit → default to `App`, pulled down only by a shorter-lived dependency. +- Self-check: "when this scope is disposed, should this state disappear with it?" + +This is the decision v1 never had to make — get it right before writing any v2 code, because the scope is fixed at registration and changing it later ripples through every consumer. + +### 5. Shape Services + +Decide the Service shape per unit, following design.md §3: + +- A unit that owns **one instance's** state → a single per-instance Service (`ISessionXxx` / `IAgentXxx`). +- A unit that owns a **global view plus per-instance** state → split into an `App` registry/factory (`XxxStore` / `XxxRegistry` / `XxxCatalog`) **and** a per-instance Service. The `App` half creates or locates the per-instance half. +- Do not pre-split a unit that has state at only one lifetime. + +Most consumers inject the per-instance Service; inject the `App` factory only for genuine cross-instance management. + +### 6. Direct dependencies + +Re-wire the dependencies you inventoried in step 1, now across the new v2 Services. Follow design.md §4–§5: + +- **Calling style** — need a result / I orchestrate → direct call (`@IX` injection); stating a fact → event; ordered participation that may veto → hook. +- **Scope direction** — a Service may inject only its own scope or an ancestor. If an `App` Service needs something from a `Session` Service, the dependency is backwards: re-scope or invert into an event. +- **Domain direction** — foundational layers must not know upstream ones. A cycle means a v1 relative import is now pointing the wrong way; extract a third Service or invert the notification into an event. +- **Durable facts** — state changes that must be recorded / replayed / projected across agents go on the wire (`wireRecord`), not a direct call alone. + +Run `lint:imports` (verify.md) as soon as the dependencies compile — it catches v1 imports and kosong boundary violations early. + +### 7. Port the business logic + +Move the behavior into the shaped v2 Services, applying the mechanical conversions below. Follow implement.md for the recipe. + +**Registration:** + +```ts +// v1 +import { InstantiationType, registerSingleton } from '../../di'; +registerSingleton(IXxxService, XxxService, InstantiationType.Delayed); + +// v2 +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +registerScopedService(LifecycleScope.Session, IXxxService, XxxService, ScopeActivation.OnDemand, 'xxx'); +``` + +**Imports:** + +```ts +// v1 +import { createDecorator, Disposable, IInstantiationService } from '../../di'; +import { PythinkerError, ErrorCodes } from '../../errors'; + +// v2 +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { PythinkerError, type ErrorCode } from '#/_base/errors'; +``` + +**Constructor injection** — unchanged in shape (`@IX` on constructor params, service params after static params). Verify each dependency is resolvable from the new scope (step 6). + +**Errors** — move any shared error into a co-located `XxxError extends PythinkerError` with a registered `code` (errors.md). Do not keep throwing v1's central error codes from a v2 domain. + +**Flags** — replace any `FlagResolver` / env check with `IFlagService.enabled(id)`; contribute new flags from the owning domain's `flag.ts` via `registerFlagDefinition` (flags.md). + +**Events** — v1's `Emitter` / `Event` from `base/common/event` maps to v2's `event` / `eventBus` domains. Read existing v2 usage in neighboring domains and match it; do not import v1's `Emitter`. + +**Runtime static args / prebuilt instances** — if v1 bootstrap did `services.set(IX, new SyncDescriptor(C, [bag]))` or set a prebuilt instance, preserve that behavior at the v2 composition root (the scope that owns the Service). Do not silently drop it. + +Red lines: + +- Do not copy a v1 file and "fix imports". Re-split first (steps 2–6); a straight copy carries v1's implicit-singleton assumptions into v2 and creates the `Map`-at-`App` anti-pattern. +- Do not leave v1 relative imports (`from '../x/...'`) in v2 — use the `#/...` alias. +- Do not preserve a v1 behavior just because it exists; if the split reveals it was a workaround for the missing scope tree, drop it. + +### 8. Port the tests + +Convert v1 tests to the v2 harness, following test.md: + +```ts +// v1 +import { TestInstantiationService } from '@pymodel/agent-core/di/test'; +const svc = ix.createInstance(XxxService, 'static-arg'); + +// v2 +import { createServices } from '#/_base/di/test'; +// in additionalServices: +reg.define(IXxxService, XxxService); +// in the test body: +const svc = ix.get(IXxxService); +``` + +- Resolve the SUT by interface (`ix.get(IX)`), never `new` a `@IService`-carrying impl, and prefer `ix.get(IX)` over `ix.createInstance(Impl)`. +- Move shared stubs into `test//stubs.ts`; import by relative path, never `#/...`. +- If the port introduced scope-layer behavior, add a `createScopedTestHost` test that asserts resolution from the correct scope (with `_clearScopedRegistryForTests()` + explicit re-registration in `beforeEach`). +- Keep v1's behavioral assertions where they still describe observable behavior; delete assertions that only checked v1's internal class shape. + +## Migration checklist + +Before submitting a port: + +- [ ] Every piece of v1 state landed in a v2 Service whose scope matches its identity (no `Map` at `App`). +- [ ] Each v1 dependency now points in the right scope direction; `lint:imports` passes. +- [ ] Registrations use `registerScopedService` with an explicit scope and domain name; no `registerSingleton` remains. +- [ ] Imports use the `#/...` alias; no v1 relative (`../../di`, `../../errors`) imports remain. +- [ ] Errors are co-located coded errors; flags go through `IFlagService`. +- [ ] Tests resolve the SUT by interface; scope behavior is asserted via `createScopedTestHost`; teardown goes through one `DisposableStore`. +- [ ] v1 bootstrap overrides (`services.set(...)`) are preserved at the v2 composition root. + +## Red lines (this subskill) + +- Porting is semantic splitting, not file copying — never preserve a v1 class shape in v2. +- Decide scope from state identity before writing v2 code; the scope is fixed at registration. +- Verify the domain mapping against current v2 `src/`; the table here is a starting point, not authority. +- One Service owns state at exactly one lifetime; split global-view + per-instance into registry + per-instance. +- A dependency cycle introduced by the port means a v1 import is now backwards — refactor it; activation timing cannot break the cycle. diff --git a/.agents/skills/agent-core-dev/close-vs-dispose.md b/.agents/skills/agent-core-dev/close-vs-dispose.md new file mode 100644 index 00000000..05251e31 --- /dev/null +++ b/.agents/skills/agent-core-dev/close-vs-dispose.md @@ -0,0 +1,155 @@ +# Topic — Close vs Dispose + +How to shut down a scoped service in `agent-core-v2`: when `dispose()` is enough, when to add an async `close()`, and where cancellation / abort belongs. Read this before putting business shutdown logic into a `Disposable`. + +## The one-sentence rule + +> **`close()` is async business shutdown; `dispose()` is synchronous resource cleanup.** + +`close()` finishes a domain's work: stop in-flight operations, apply shutdown policy, flush persistence, release async resources. `dispose()` releases object resources: event subscriptions, timers, hook registrations, and child disposables. + +## Why they must stay separate + +`IDisposable.dispose()` is synchronous: + +```ts +export interface IDisposable { + dispose(): void; +} +``` + +The container calls it during scope teardown. Disposal order is deterministic (orient.md): child scopes first, then reverse construction order within a scope. Nothing awaits a Promise returned from `dispose()`. + +Business shutdown is usually async. It may need to: + +- stop in-flight tasks and wait for settlement; +- decide policy (`kill` vs `keepAliveOnExit` vs `markLost`); +- flush write queues and persistence; +- emit final records / events / telemetry; +- close sockets, child processes, or external clients. + +If that logic lives in `dispose()`, it becomes fire-and-forget: the scope keeps tearing down, dependencies may be disposed immediately afterward, and the async continuation can run against a half-dead object graph. + +## What `close()` owns + +Add `close(): Promise` when a service owns async shutdown work: + +```ts +export interface IXxxService { + readonly _serviceBrand: undefined; + close(reason?: string): Promise; +} +``` + +A good `close()`: + +- is idempotent — repeated calls return the same Promise or no-op; +- is called by lifecycle code **before** `scope.dispose()`; +- rejects new work after it starts; +- applies shutdown policy explicitly; +- awaits the work it starts; +- leaves `dispose()` with only synchronous cleanup. + +Sketch: + +```ts +class XxxService extends Disposable implements IXxxService { + declare readonly _serviceBrand: undefined; + private closed = false; + + async close(reason = 'scope closed'): Promise { + if (this.closed) return; + this.closed = true; + + await this.stopInFlightWork(reason); + await this.flushPersistence(); + } + + override dispose(): void { + this.closed = true; + // synchronous cleanup only: clear timers, remove listeners, release handles. + super.dispose(); + } +} +``` + +`flush()` is different from `close()`: `flush()` persists buffered state while the service stays open; `close()` is terminal. + +## What `dispose()` owns + +`dispose()` releases resources owned by the object instance: + +```ts +class WSBroadcastService extends Disposable implements IWSBroadcastService { + declare readonly _serviceBrand: undefined; + + constructor(@IEventService event: IEventService) { + super(); + this._register(event.subscribe(() => { /* … */ })); + } +} +``` + +Use `dispose()` to: + +- `_register(...)` event subscriptions and hook registrations; +- clear timers; +- remove signal listeners; +- dispose child `IDisposable`s; +- detach from synchronous handles. + +`dispose()` must be idempotent and should avoid throwing. If `close()` was already called, `dispose()` should be a no-op for business work and only clean resources. + +## Where abort / cancellation belongs + +Cancellation is not the same thing as graceful shutdown. + +For an operation-scoped object, a cancellation trigger can be disposed: + +```ts +const tokenSource = new CancellationTokenSource(); +store.add(toDisposable(() => tokenSource.cancel())); +``` + +This is fine when the contract is **fire-and-forget cancel**: the operation observes the token and settles asynchronously; disposal does not wait for completion. + +For a manager/service that owns many tasks and their state, do not use `dispose()` as the graceful abort path. Expose `stop()` / `stopAll()` / `close()` and let lifecycle code await the one it needs. + +Background-specific rule: a `background`-style service may use `AbortController` internally to propagate cancellation to process / agent / question tasks, but manager shutdown belongs in `close()` or explicit `stopAll()`. `dispose()` may best-effort abort controllers only as a safety net; it must not be the mechanism that decides terminal status, persistence, or notifications. + +## Decision tree + +```text +What does the service own? + │ + ├─ only event subscriptions / timers / disposable handles? + │ └─ extend Disposable; no close() needed. + │ + ├─ async work, in-flight tasks, persistence buffers, sockets, child processes? + │ └─ add close(): Promise; call it before scope.dispose(). + │ + ├─ a single operation that callers may cancel? + │ └─ expose an AbortSignal / CancellationToken or a fire-and-forget cancel handle. + │ + └─ both async shutdown and disposable resources? + └─ close() for business shutdown; dispose() for resource cleanup. +``` + +## VSCode parallel + +VSCode uses the same split: + +- `src/vs/base/common/lifecycle.ts` — `IDisposable.dispose(): void` for synchronous cleanup. +- `src/vs/base/parts/storage/common/storage.ts` — `close(): Promise` flushes and closes the database. +- `src/vs/base/common/cancellation.ts` — `CancellationTokenSource.dispose(true)` / `cancelOnDispose()` cancels operation-scoped work without awaiting it. + +The lesson is not "never cancel in dispose". It is: **disposal may trigger cancellation for a scoped operation, but service shutdown policy stays in an explicit async close path.** + +## Red lines (this topic) + +- Do not put business shutdown in `dispose()` — `dispose()` is synchronous and is not awaited. +- Do not `await` inside `dispose()`. +- Do not rely on `dispose()` to flush persistence, emit final events, wait for tasks, or send notifications. +- Add `close(): Promise` for async shutdown and call it before `scope.dispose()`. +- Keep `close()` and `dispose()` idempotent; `dispose()` after `close()` must be safe. +- Use disposal as a cancellation trigger only for operation-scoped work, not as a manager/service shutdown policy. diff --git a/.agents/skills/agent-core-dev/commit-align.md b/.agents/skills/agent-core-dev/commit-align.md new file mode 100644 index 00000000..11506bb6 --- /dev/null +++ b/.agents/skills/agent-core-dev/commit-align.md @@ -0,0 +1,78 @@ +# Subskill — Commit align (triage a `main` commit against v2) + +Context: you are on the `pythinker-code-v2` branch, in the phase of catching it up to **new commits that landed on `main`**. Those commits change `packages/agent-core` (v1); the job is to decide, for one commit at a time, whether v2 (`packages/agent-core-v2`) already has the corresponding logic — and if not, what the minimal fix is. + +Use this when the user hands you **one commit hash plus a short description** ("look at `` — it fixed the steering race"). It is the small, per-commit sibling of [align.md](align.md): `align.md` ports a whole v1 domain into v2; this file triages a single `main` commit and says *port / adapt / skip*. If the triage reveals a whole missing domain, stop and switch to [align.md](align.md). + +## The one-paragraph mental model + +A `main` commit edits v1's singleton-container code. The same behavior in v2 lives behind a scoped Service, so a commit lands in one of four buckets: **already-aligned** (v2 has it, possibly by construction), **partial** (v2 has a nearby version whose semantics drift), **missing** (v2 has nothing), or **not-applicable** (the v2 architecture removed the very problem the commit fixes). Your output is a bucket assignment plus evidence, then a fix sized to that bucket — never a blind port of the diff. + +## The workflow + +```text +Read the commit + the user's note → Locate the v1 logic → Map to a v2 domain +→ Check v2 for a corresponding implementation → Bucket it → Recommend a fix → Verify +``` + +### 1. Read the commit and the note + +**Goal:** know exactly what changed in v1 and *why*. The user's one-liner gives the intent; the diff gives the facts. + +Actions: + +- Inspect the change scoped to v1: `git show -- packages/agent-core` (and `--stat` first to see the blast radius). +- From the diff, list: touched files, changed functions/methods, and the observable behavior delta (before → after). +- Reconcile with the user's note: is this a bugfix, a semantic correction, new behavior, or a refactor? The *why* decides whether v2 even needs the change. + +Do not skim the user's sentence and guess — the diff is the spec for what "aligned" means here. + +### 2. Locate the v1 logic + +Pin the change to a v1 place: the contract (`/.ts`) + impl (`/Service.ts`), or the helper/handler the commit touched. Note which state it reads/writes and which other v1 services it calls — this is the same inventory as [align.md](align.md) §1, scoped to the commit's footprint. + +### 3. Map to a v2 domain + +Use the v1 → v2 domain table in [align.md](align.md) §3 as a starting point, then **verify against the current `packages/agent-core-v2/src/` tree** — it is the source of truth. Identify the candidate v2 Service(s) that would own this behavior, and their `LifecycleScope`. + +### 4. Check v2 and assign a bucket + +Search the candidate domain in v2 (Grep the method name, the state field, the error code). For each piece of the commit's behavior delta, decide: + +- **Already-aligned** — v2 produces the same observable result (sometimes for free, because the v2 design never had the bug). Cite the v2 file:line. +- **Partial** — v2 has a near miss: same method, different guard/ordering/error; or the state lives at a different scope. Name the exact drift. +- **Missing** — no v2 Service owns this behavior. Confirm it is a single-Service gap, not a whole-domain gap (latter → [align.md](align.md)). +- **Not-applicable** — the v2 architecture removed the condition the commit fixes (e.g. the scope tree already serializes what v1 patched with a lock). Explain why, so a reviewer trusts the skip. + +Every claim needs a citation (`path:line`) on both sides; "I couldn't find it" is a finding only after you name where you looked. + +### 5. Recommend a fix (sized to the bucket) + +- **Already-aligned** — say so and stop; reference the v2 location. No code change. +- **Partial** — propose the smallest edit that closes the drift: which Service, which method, which guard. Stay inside v2 rules — scope/domain direction, no `Map` at `App` (see [align.md](align.md) §6–§7 red lines). +- **Missing** — sketch the port at commit granularity: target domain + scope, the Service/method to add or extend, the dependency direction, and which [align.md](align.md) §7 conversions apply (registration, `#/…` imports, co-located coded error, `IFlagService` for any gate). If it needs a new scope or a wire change, flag it. +- **Not-applicable** — recommend no v2 change, but call out any test worth adding so the gap stays closed. + +Keep the recommendation to the commit's footprint. If it keeps growing, that is the signal to hand off to [align.md](align.md) for a full domain port. + +### 6. Verify + +Point at the checks that cover the fix, per [verify.md](verify.md): `lint:imports`, `typecheck`, and the relevant `test`. Note the expected outcome rather than asserting you ran it if you did not. + +## Output shape + +When triaging, answer in this order so the user can act on it directly: + +1. **Commit + intent** — one line restating what the commit changed and why (from the note + diff). +2. **v1 location** — file(s) and the behavior delta. +3. **v2 status** — one of the four buckets, with `path:line` evidence on both sides. +4. **Recommendation** — the concrete fix (or the justified skip), scoped to the commit; name the target Service / scope / dependency direction. +5. **Verify** — which checks should pass, and whether to escalate to [align.md](align.md). + +## Red lines (this subskill) + +- Read the diff and the note before judging v2; never infer "aligned" from the description alone. +- Do not copy a v1 diff into v2. Decide the bucket first; a bugfix commit often maps to **not-applicable** because the v2 design already removed the defect. +- Cite `path:line` on both sides. A recommendation without evidence is a guess. +- Stay in the commit's footprint. Growing scope means "switch to [align.md](align.md)", not "keep porting here". +- Do not break v2 invariants to chase v1 parity — scope direction, domain direction, and no `Map` at `App` still hold ([align.md](align.md) red lines). diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md new file mode 100644 index 00000000..f9cb8af6 --- /dev/null +++ b/.agents/skills/agent-core-dev/config.md @@ -0,0 +1,312 @@ +# Topic — Config + +How the `config` domain works and how a domain owns its configuration section. Covers the section-registry model, the App vs Session split, the TOML on-disk format, and the recipe for adding or migrating a config section. + +The `config` domain is a thin registry + loader: it does **not** know the shape of any individual section. Each domain owns the schema (and, where needed, the TOML transform) for the config it consumes, contributes the section (statically at module load via `registerConfigSection`, or at runtime as a `ConfigSectionContribution` collection record), and reads it through `IConfigService`. There is no whole-config object passed around. + +## What belongs in Config + +`IConfigService` is the **preference registry**: it holds values a user or +operator *chooses*, each with a schema and a default, that *can* be persisted to +`config.toml`. It is not a grab-bag for every value a domain needs. Before +registering a section, classify the value along three axes — **decision-maker**, +**preference vs fact**, **mutability / persistence**: + +| Type | Decision-maker | Preference/Fact | Persisted? | Examples | Home | +|---|---|---|---|---|---| +| User preference | user | preference | ✅ config.toml | model, theme, log level | **Config** | +| Operational override | operator/deployer | preference | ❌ env / flag | `PYTHINKER_MODEL_*`, `PYTHINKER_LOG_*` | **Config** (env overlay) | +| Per-run intent | invoker | preference | ❌ ephemeral | CLI `--model`, `--config` | **Config** (Memory layer) | +| Host fact | host | fact | ❌ | platform, CI, proxy, home dir | **Bootstrap** | +| Derived convention | code | fact (derived) | ❌ | `configPath`, `logsDir` | **Bootstrap / code** | +| Session runtime state | session/agent | state | ✅ session meta | active model, plan mode | **Session scope** | +| Tuning constant | developer | preference | ❌ compile-time | retry backoffs, buffer sizes | **code** | + +A value belongs in Config **iff** it satisfies all of: + +1. **Preference** — a choice among valid values, not an observed fact. +2. **Persistable** — it *can* be written to `config.toml`, even when a given + value arrives via env or CLI. +3. **Schema + default** — registerable as a section with validation. +4. **User- or operator-facing** — meaningful to set as a preference. + +If it fails any rule, it is not Config: + +- **Fact** (CI, platform, proxy, `HOME`) → a structured fact on + `IBootstrapService` (the startup snapshot), not Config. +- **Derived convention** (`configPath`, `logsDir`) → `IBootstrapService` / code. +- **Session runtime state** (active model, plan mode) → a Session-scoped + service in the owning domain (e.g. `IProfileService`), not `config`. +- **Tuning constant** (retry config, buffer sizes) → domain code; promote to + Config only when it becomes user-tunable. + +**`IBootstrapService` is domain-agnostic.** It holds only generic facts shared by +all domains — the env bag, resolved paths, and host facts (`platform`, `arch`, +`cwd`, `osHomeDir`, `isCI`, …) — plus the host's process-level invocation +arguments in `args` (explicit `agentFiles` / `skillDirs`, `requestHeaders`, +prompt identity). `args` mirrors VS Code's `NativeParsedArgs` on the +environment service: the host states them once via `BootstrapInput.args` at +the composition root, and downstream services read them from +`IBootstrapService.args` instead of through per-domain runtime-options +services (do not add new `IXxxRuntimeOptions` services or seed functions for +host parameters). What must **never** land on `IBootstrapService` is state +tied to a specific upper domain (no `cron`, no `flags`, no feature-specific +fields): that couples the foundational layer to an upstream one. + +Any value that belongs to a specific domain — including env-only operational +toggles (`PYTHINKER_CRON_*`, `PYTHINKER_CODE_EXPERIMENTAL_*`), model parameters, or feature +flags — goes through **Config registration**: the owning domain registers a +section with a declarative `envBindings` map (and a `stripEnv` when the value must +not be persisted) and reads it via `config.get(...)`. Each config value declares +an optional env binding (`{ field: 'ENV_VAR' }`, with optional `parse`/`default`); +IConfig resolves each field by `env > config.toml > default` automatically. This +keeps every domain's config in one registry and keeps Bootstrap free of upstream +knowledge. + +Operational env overrides and per-run intent live *inside* Config as layers over +the same persistable key: `model` can be set in `config.toml`, via `PYTHINKER_MODEL_*`, +or via CLI `--model`. They are not separate abstractions — see "Reads vs writes" +and "Layered resolution" below. + +Env access is encapsulated: business domains read `config.get(...)` or structured +`IBootstrapService` facts; only the `config` domain reads the raw env bag (from +`IBootstrapService`) to build its overlays. Business domains must not call +`IBootstrapService.getEnv()` directly. + +## Layered resolution + +`IConfigService` resolves a key by precedence across layers, lowest to highest: + +```text +Default registered defaultValue (and code constants promoted to a section) + ↓ +User config.toml (persisted user preferences) + ↓ +Operational env overlay (e.g. PYTHINKER_MODEL_*, PYTHINKER_CODE_EXPERIMENTAL_*) + ↓ +Memory per-run intent (CLI flags); never persisted; highest +``` + +`set(domain, patch, target?)` writes the `User` layer (persisted) by default; +pass `ConfigTarget.Memory` for a per-run override that is never written to disk. +`inspect(domain)` reports the value at each layer. + +## Layout + +- `src/app/config/config.ts` — `IConfigRegistry` / `IConfigService` tokens, `ConfigSection`, `ConfigEffectiveOverlay`, event types. +- `src/app/config/configService.ts` — `ConfigRegistry` + `ConfigService` impl; self-registers at App scope. The registry is also the fold of the `ConfigSectionContribution` collection: it drains the module-level contributions at construction, then refolds incrementally (`added` → `registerSection`, `removed` → `unregisterSection`). +- `src/app/config/configSectionContributions.ts` — the `ConfigSectionContribution` collection token (the runtime channel: a unit contributes with `this.provide(ConfigSectionContribution, …)`) plus the module-level `registerConfigSection` collector (the static channel, import = register). +- `src/app/config/configOverlayContributions.ts` — the module-level `registerConfigOverlay` collector for `ConfigEffectiveOverlay`s (drained at construction like the sections). +- `src/app/config/toml.ts` — generic snake_case ↔ camelCase machinery plus the registry-aware `transformTomlData` / `applySectionToToml` entry points. Per-domain normalization lives in the section owner's `configSection.ts` (registered as `fromToml` / `toToml`); this module stays free of any other domain's semantics. +- `src/kosong/model/thinking.ts` (owner domain, not `config`) — the `resolveThinkingEffort` helper and the authoritative `ThinkingConfig` type (the `thinking` section itself registers from `src/app/kosongConfig/configSection.ts`). +- `src/app/config/configPure.ts` — `isPlainObject`, `deepMerge`, `omitUndefined`, `describeUnknownError`. + +A domain that owns a section keeps the schema in its own `configSection.ts` (e.g. `src/app/flag/flag.ts` for `experimental`, `src/agent/loop/configSection.ts` for `loopControl`). Exception: kosong-owned sections (`providers`, `models`, `thinking`) — kosong is a pure, persistence-free abstraction layer that defines only the types (`src/kosong/{provider,model}`); the section constants, the zod schemas (re-derived from those types and compile-time pinned via `AssertExact, Type>>`, see `_base/utils/typeEquality.ts`), the registrations, env bindings, and TOML transforms all live in the persistence wrapper `src/app/kosongConfig/configSection.ts`. (`modelCatalog` has no kosong-side type at all — its section is fully self-contained in `app/kosongConfig`, types derived from the schema.) A cross-section env overlay (e.g. the `PYTHINKER_MODEL_*` synthesis in `src/app/kosongConfig/envOverlay.ts`) lives in the wrapper too and is registered via module-level `registerConfigOverlay`. The session subagent domain owns two sections in `src/session/subagent/configSection.ts`: `[subagent]` (`timeout_ms` on disk) and `[secondary_model]` (`default_model` plus the `[secondary_model.models]` pool, with a lone legacy v1 `model` key honored as a fallback default below `default_model`); neither carries a cross-section overlay. Cross-field pool validation (default present / in-pool / every key resolvable) runs at session creation in `subagentModelsValidationService.ts`, not in the schema. The two-way sync between config sections and kosong's in-memory registries is owned by `IKosongConfigService` (`src/app/kosongConfig/kosongConfigService.ts`). + +## Scope + +- `IConfigRegistry` / `IConfigService` — **App** scope, process-global. One registry of sections; one loader reading `~/.pythinker-code/config.toml` (path from `IBootstrapService.configPath`). + +All config reads go through `IConfigService` (global config). Per-session runtime state (active model, thinking level, etc.) lives in the owning Session-scoped service (e.g. `IProfileService`), not in `config`. + +## The section-registry model + +A config section is identified by a camelCase domain key (`'providers'`, `'thinking'`, `'loopControl'`). Each section has: + +- `schema?: ConfigSchema` — zod schema used to validate the value (absent ⇒ passthrough). +- `defaultValue?: T` — filled when the file has no value for the domain. +- `merge?: ConfigMerge` — how `set(domain, patch)` combines base + patch (default `deepMerge`). +- `fromToml?: ConfigFromToml` — read-path transform (snake_case file value → in-memory shape). Defaults to a plain key-casing pass; owners register one when the on-disk shape needs custom normalization (record key preservation, nested object conversion, array entries, key renames, reshapes). +- `toToml?: ConfigToToml` — write-path transform (in-memory value → snake_case file value). Defaults to a plain camelCase→snake_case key mapping. + +Two contribution channels: + +- **Static (import = register)** — the owning domain calls `registerConfigSection(domain, schema, options)` at the top level of its `configSection.ts`; `ConfigRegistry` drains the collected contributions when it is constructed. Every in-repo section uses this channel. +- **Runtime (collection record)** — a unit contributes `this.provide(ConfigSectionContribution, { domain, schema, options })` (e.g. a feature assembled through `IFeatureManager`); the `ConfigRegistry` fold registers the section when the record lands and unregisters it when the record is withdrawn (provider disposed). User TOML values survive a withdrawal — they just stop being validated and effective. + +Ownership rules: + +- **One owner per section.** `registerSection` throws if a domain is registered twice — the static channel fails fast when `ConfigRegistry` drains it; a conflicting runtime record is reported through `onUnexpectedError` and the first registration wins (the fold is an event path and never throws). +- **The domain that consumes a config owns its schema.** This is what keeps `config` from depending on its consumers: `config` must not import `externalHooks` / `permissionRules` / `provider` / `kosong` / etc. for a section's schema. If a schema needs a domain's types, the schema lives in that domain. +- **Demand-driven.** Do not register sections for config that no domain reads yet; a section appears (with its schema in the owning domain) only when a consumer appears. + +## Env bindings + +A section can declare how its fields are read from environment variables, so the +value resolves through `config.get(...)` rather than ad-hoc `process.env` reads. +Declare the bindings with `envBindings(schema, { … })` — the field names are +type-checked against the schema (no magic strings), and nested schemas recurse: + +```ts +registerConfigSection('thinking', ThinkingConfigSchema, { + env: envBindings(ThinkingConfigSchema, { + effort: 'PYTHINKER_MODEL_THINKING_EFFORT', + }), +}); + +// nested / record section — outer key is a runtime constant, inner fields are +// checked against the value schema: +registerConfigSection('providers', ProvidersSectionSchema, { + env: envBindings(ProvidersSectionSchema, { + [ENV_MODEL_PROVIDER_KEY]: envBindings(ProviderConfigSchema, { + apiKey: 'PYTHINKER_MODEL_API_KEY', + type: 'PYTHINKER_MODEL_PROVIDER_TYPE', + baseUrl:'PYTHINKER_MODEL_BASE_URL', + }), + }), + stripEnv: stripProvidersEnv, +}); +``` + +Each field is an `EnvBinding` — a string (env var name) or +`{ env, deprecatedEnv?, parse?, default? }`. IConfig resolves every field by +`env > config.toml > default`, sets it on the effective value, and validates the +section. Empty nested entries (no field resolved) are omitted, so a synthetic +entry like `__pythinker_env__` only appears when at least one of its env vars is set. +When `deprecatedEnv` is set and `env` itself is absent or fails `parse`, the +deprecated var still supplies the value and a warning diagnostic is reported — +use it to rename an env var without breaking existing setups. + +`stripEnv(value, raw?, getEnv?)` removes env-derived fields before `set`/`replace` +persists, so env overrides never leak into `config.toml`. `raw` is the section's +env-free camelCase base (already `fromToml`-normalized), and `getEnv` reads the +live env bag. For fields that are **both +user-persistable and env-overridable**, register +`stripEnv: stripEnvBoundFields(sectionEnvBindings)` (from `#/app/config/config`) +— it derives the guard from the same bindings the read path uses: while a +field's env var resolves to a value, writes restore the field's raw-base value +(or drop it) instead of persisting an echoed env value; an env value that +fails the binding's `parse` owns nothing, so writes pass through. Env-only +fields/sections need no env check — strip them unconditionally (e.g. thinking's +`forcedEffort`, cron's whole-section `() => undefined`). + +Business domains read `config.get('section')`; they never read env directly, and +never write their own env-merge logic. + +## Add a config section (recipe) + +1. Define the schema in the owning domain, e.g. `src//configSection.ts`: + ```ts + export const MY_SECTION = 'mySection'; + export const MySectionSchema = z.object({ /* ... */ }); + export type MySection = z.infer; + ``` +2. Register it at the top level of the same module (import = register): + ```ts + // src//configSection.ts + import { registerConfigSection } from '#/app/config/configSectionContributions'; + + registerConfigSection(MY_SECTION, MySectionSchema, { defaultValue: {} }); + ``` + `ConfigRegistry` drains module-level contributions when it is constructed, so the section exists before any consumer resolves `IConfigService` — no owning Service needs to be constructed first. Make sure `src/index.ts` imports the leaf so the top-level call runs. +3. (Runtime variant) a dynamically loaded unit (e.g. one assembled through `IFeatureManager`) contributes the section as a collection record instead: + ```ts + this.provide(ConfigSectionContribution, { domain: MY_SECTION, schema: MySectionSchema, options: { defaultValue: {} } }); + ``` + The `ConfigRegistry` fold registers it incrementally and unregisters it when the unit is retracted (user TOML values survive) — see "Late registration". +4. Read it anywhere via `IConfigService`: + ```ts + constructor(@IConfigService private readonly config: IConfigService) {} + // ... + const value = this.config.get(MY_SECTION); + ``` +5. React to edits by subscribing `IConfigService.onDidChange` and filtering on `e.domain === MY_SECTION` (see `FlagService`). +6. Write it only through `IConfigService.set(domain, patch)` (merge) or `.replace(domain, value)` (wholesale). Never write `config.toml` directly. + +## Reads vs writes + +Data flow is one-way by default — reading config never touches the file: + +```text +config.toml ──load──▶ IConfigService.effective ──get──▶ services read + ▲ │ + └──────── IConfigService.set/replace ◀──── only on explicit writes +``` + +- **Read path** (startup, every service): `config.toml` is loaded into `IConfigService` once; services read via `get()`. This path **never writes the file**. +- **Write path** (rare): `config.toml` is rewritten only when something explicitly calls `IConfigService.set/replace`. The only production writers today are provider CRUD (`ProviderService.set/delete`, e.g. provisioning a provider after OAuth login). + +**Runtime service state is not config.** Mutating a service at runtime does **not** rewrite `config.toml`: + +- `ProfileService.configure(...)` / `update(...)` / `setModel(...)` / `setThinking(...)` only change **in-memory** fields and append to the session **wireRecord** (for replay). They never call `IConfigService.set`. +- Switching model or thinking level mid-session is session runtime state, not a config edit — the user's `config.toml` is left untouched. + +So `configure(...)` never overwrites the local file. Treat `config.toml` as the user's static config; runtime overrides live in memory and the session record. + +## Late registration + +`ConfigService` loads in its constructor (first `get(IConfigService)`). Static sections are drained before that, but a runtime-contributed section (a `ConfigSectionContribution` record) can register at any later moment. To keep validation and defaults correct: + +- `IConfigRegistry` emits `onDidRegisterSection` whenever a section is registered (and `onDidUnregisterSection` when a runtime record is withdrawn). +- `ConfigService` subscribes and, on registration, re-validates the already-loaded raw value for that domain, applies the default if the raw value is absent, re-runs the env overlay, and fires `onDidChange` if the effective value changed. On unregistration it devalidates the domain — `get(domain)` falls back to the raw value. +- Before a section is registered, `get(domain)` returns the raw (transformed, unvalidated) value; consumers that need validated values should read after the section lands, or react to `onDidChange`. + +This means registration order is never a correctness concern — you do not need an eager bootstrap. + +## TOML on-disk format + +`config.toml` stores keys in **snake_case**; in-memory values are **camelCase**. `ConfigService` converts both ways by dispatching to each section's registered transform: + +- **Read**: `transformTomlData(fileData, registry)` maps each top-level key to a domain and applies that domain's `fromToml` hook (or a plain key-casing pass when none is registered). Owner domains register their own normalization — e.g. provider `oauth`/`env`/`customHeaders`, permission `deny/allow/ask` → `rules`, `experimental` keys preserved verbatim. When a section registers after the initial load, `ConfigService` re-applies its `fromToml` against the preserved snake_case raw value (see "Late registration"), so registration order is never a correctness concern. +- **Write**: `applySectionToToml(rawSnake, domain, value, registry)` applies the domain's `toToml` hook (or a plain camelCase→snake_case mapping) into a raw clone of the file, preserving unknown top-level keys and unknown sub-fields (lossless round-trip). + +`ConfigService` keeps four views: + +- `rawSnake` — snake_case clone of the file; the write base, never carries the env overlay. +- `raw` — camelCase, env-free; the read/set/replace base. +- `validated` — validated `raw`, env-free; the base every live env re-application starts from, so a degraded or removed env value falls back to the file instead of a stale overlay. +- `effective` — `validated` plus the env overlay, recomputed on load/set; `get()`/`getAll()` re-apply the overlay on a fresh `validated` copy per read rather than caching it. + +### Renaming config keys and env vars (deprecations) + +Renames are declared once on the section, never hand-rolled in `fromToml`: + +```ts +registerSection(MY_SECTION, MySectionSchema, { + deprecations: [{ key: 'old_key', replacement: 'new_key' }], // snake_case, on-disk + env: envBindings(MySectionSchema, { + newKey: { env: 'PYTHINKER_NEW_KEY', deprecatedEnv: 'PYTHINKER_OLD_KEY', parse }, + }), +}); +``` + +- A deprecated TOML key is **ignored** (its value no longer applies — the schema only knows the new key) and reports a warning `ConfigDiagnostic` while present; the file is never rewritten, so the warning is the migration guide. Diagnostics are recomputed on every load/reload and surface to clients via `IConfigService.diagnostics()` and `onDidChangeDiagnostics` (kap-server republishes them as the global `event.config.warning` WS event). +- A deprecated env var still **resolves** as a fallback (new var first), with the same warning treatment, and `stripEnvBoundFields` treats it as env-owned for writes. +- See `src/agent/loop/configSection.ts` for a worked example (`max_retries_per_step` → `max_attempts_per_step`). + +### `PYTHINKER_MODEL_*` env overlay + +When `PYTHINKER_MODEL_NAME` is set, the `kosongConfig` wrapper's `pythinkerModelEnvOverlay` (`src/app/kosongConfig/envOverlay.ts`) injects a reserved model alias (`__pythinker_env_model__`) into `effective`, points `defaultModel` at it, and merges the request `modelOverrides`; the reserved provider (`__pythinker_env__`) comes from the `providers` section env bindings. The overlay is registered via module-level `registerConfigOverlay` and applied **only to `effective`**, never to `rawSnake`, so it is never persisted. Its `strip` (plus the providers section `stripEnv`) is the final guard so a caller that read `effective` (with the overlay) cannot write the reserved entries or the shell API key back to disk. `config` itself only runs registered overlays — it does not know the `PYTHINKER_MODEL_*` semantics. + +## Owner-owned sections + +`config` holds no monolithic config schema and no whole-config object. Every section is owned by the domain that consumes it: the schema (and any `fromToml` / `toToml` normalization and `stripEnv`) lives in that domain's `configSection.ts`, and the domain contributes it via module-level `registerConfigSection` (or a runtime `ConfigSectionContribution` record). Cross-section env behavior (e.g. `PYTHINKER_MODEL_*`) lives in an owner-registered `ConfigEffectiveOverlay` (module-level `registerConfigOverlay`). To add a section, follow "Add a config section" above in the owning domain — never add schema or normalization to `config` itself. + +## Ownership map (generated) + +The authoritative, always-current list of registered sections — rendered in the on-disk `config.toml` shape, with owner file, scope, defaults, env bindings, and schema fields — is generated from the live registry: + +- `packages/agent-core-v2/docs/config-manifest.toml` (checked in; do not edit by hand). +- Regenerate with `pnpm --filter @pymodel/agent-core-v2 gen:config-manifest` (add `--check` for a freshness check; `test/app/config/configManifest.test.ts` enforces it in CI). + +`config` must not import from any of these owner domains; that is the whole reason the schemas, TOML normalization, and env overlays live with their owners. + +## Scope & dependencies + +- `config` is a low-level capability: domains that own sections import `config` (for `IConfigRegistry` / `IConfigService`), never the reverse — section schemas live in the owning domain. +- Cross-domain type sharing for a config type: prefer importing the type from the owning domain over re-declaring it (e.g. `plugin` imports `McpServerConfig` from the MCP config schema). +- `IConfigRegistry` / `IConfigService` are **App**. Agent scope services may inject App services via ancestor lookup. +- `config` never imports a higher domain and holds no section schemas of its own; if a section needs a type from another domain, that schema lives in that domain. + +## Red lines (this topic) + +- One owner per section: a duplicate static registration throws when `ConfigRegistry` drains it; a conflicting runtime record is logged (`onUnexpectedError`) and the first registration wins. +- `config` never imports the domains that consume it — keep section schemas in the owning domain. +- Config is the **preference registry**: register only values that are preferences, persistable, schema'd, and user/operator-facing. Facts → `IBootstrapService`; session state → Session scope; constants → code. +- Business domains read `config.get(...)` or structured `IBootstrapService` facts; never call `IBootstrapService.getEnv()` directly — only `config` reads the raw env bag to build overlays. +- Keep `IBootstrapService` domain-agnostic: host invocation arguments (CLI flags, host identity headers, prompt identity) go into `BootstrapInput.args` / `IBootstrapService.args` — never into new per-domain runtime-options services; domain runtime state (cron, flags, model params, …) never goes onto `IBootstrapService` at all. Domain-specific config goes through `registerConfigSection` + `envBindings`, read via `config.get(...)`. +- Do not pass a whole config bag via options; read each section through `IConfigService`. There is no `PythinkerConfig` object — config is a registry of owner-owned sections. +- `config.toml` is snake_case on disk, camelCase in memory — never write camelCase keys to disk, and never write to `config.toml` except through `IConfigService.set/replace`. +- Reading config / calling `configure(...)` / switching model at runtime must not rewrite `config.toml`; runtime state lives in memory and the session wireRecord, not the file. +- Never persist env overlays (`__pythinker_env__` / `__pythinker_env_model__` / shell API key / experimental env); overlays live only in `effective` / `Memory`. +- Runtime contribution (a `ConfigSectionContribution` record from a unit at any scope) is fine — the late-registration mechanism keeps validation correct; the static channel needs no eager bootstrap (import = register, drained at `ConfigRegistry` construction). diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md new file mode 100644 index 00000000..974db251 --- /dev/null +++ b/.agents/skills/agent-core-dev/design.md @@ -0,0 +1,289 @@ +# Stage 2 — Design a service + +Decide *where things live and who knows whom* before writing code. Every rule here derives from two questions: + +1. **What is the identity of the state it owns?** → decides the **Scope**. +2. **Who owns the decision, and who needs the result?** → decides the **calling style** and **dependency direction**. + +## 1. What a Service is + +A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetime**. + +- **Behavior** is almost free — the same logic runs anywhere, so it does not by itself decide a scope. +- **State** pins a Service to a scope. State has an **identity** (what it is keyed by) and a **lifetime** (when it is born, when it dies). +- **Dependencies / calling style** answer a different question: who controls whom, and who knows whom. + +## 2. Choosing a scope + +> Scope = the identity + lifetime of the owned state. + +| Scope | State identity (keyed by) | Lifetime | +|---|---|---| +| `App` | none (single global instance) | the process | +| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | +| `Session` | `sessionId` | one session | +| `Agent` | `agentId` | one agent | + +### Decision tree + +**Q1. Does it own mutable state?** + +- No (pure behavior) → jump to Q3. +- Yes → Q2. + +**Q2. What is the identity of that state?** + +- one global instance → **`App`** +- one per workspace (shared by every session of that workspace) → **`Workspace`** +- one per session → **`Session`** +- one per agent → **`Agent`** +- a mix (a global registry *and* per-instance state) → **split it** (see §3). + +**Q3 (stateless). What is the shortest-lived dependency it must inject?** + +A stateless Service is pulled *down* by its shortest-lived dependency: if it injects an `Agent`-scoped Service, it cannot be `App`. Among the scopes that still satisfy every dependency, **default to the longest-lived one** (usually `App`) to maximize reuse. Push it down only when it must inject a shorter-lived Service, or when you want to limit its visibility. + +### The core anti-pattern (a litmus test) + +> **Do not store per-session state in a `Map` inside an `App` Service.** + +This is the tell-tale sign of "should have been `Session`-scoped but was parked at `App`". Consequences: nobody cleans the entry up when the session ends (leak); every consumer threads `sessionId` around (loss of type safety); it cannot inject `Session`/`Agent`-scoped collaborators. + +### One-sentence self-check + +> "When this scope is disposed, should this state disappear with it?" +> +> - Yes → the scope is right. +> - It must outlive the scope → too short; move up one tier. +> - It should be one-per-unit but is shared → too long; move down one tier. + +## Scope is not a domain + +Scope answers **lifetime and visibility**. Domain answers **responsibility and data ownership**. A Service registered at `Session` or `Agent` scope is not automatically part of the `session` or `agent` domain, and an entity Service must not be named `I{Scope}EntityService` just because its data is scoped that way. + +Use the data-ownership test and the `session` / `agent` / `turn` split conclusions in [domain-boundaries.md](domain-boundaries.md) before naming a Service or adding `I{Domain}EntityService`. + +## 3. Multi-Scope splitting + +> One Service owns state at exactly one identity / lifetime. If a domain owns state at several lifetimes, split it along those boundaries — one Service per lifetime. + +The standard split is "global registry / factory" + "per-instance": + +| Tier | Role | Naming tends to | +|---|---|---| +| `App` | global registry / catalog / factory — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | +| `Workspace` / `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | + +Canonical splits in the codebase: + +- **`records`** — `ISessionStore` (`App`) + `ISessionMetaStore` (`Session`) + `IAgentRecords` (`Agent`). +- **`config`** — `IConfigRegistry` / `IConfigService` (`App`). +- **`kosong`** — `IProtocolHandlerRegistry` (`App`) + `IProviderManager` (`Session`). Generation is driven by `ILLMRequester` (`Agent`) in the `llmRequester` domain. +- **`tool`** — `IToolDefinitionRegistry` (`App`) + `IToolService` (`Agent`). + +Split when the domain genuinely has both a global view and per-instance state. Do **not** split when state lives at only one lifetime (e.g. purely `App` like `log`; purely `Agent` like `prompt`). Do not pre-split for symmetry. + +After the split, the `App` Service usually plays the **factory**; most consumers inject the **per-instance** Service. Inject the `App` factory only when you genuinely need cross-instance management. + +## 4. Choosing a calling style + +Three mechanisms answer three different questions: + +| Mechanism | Nature | Coupling | Returns a value? | Consumers | +|---|---|---|---|---| +| **Direct call** | command: A tells B to do | A → B | yes | one (known) | +| **Event** | fact: A announces "X happened" | both depend only on the bus | no | zero / one / many (unknown) | +| **Hook** (`onWill` / `onDid`, `OrderedHookSlot`) | participation: observers step into an operation, in order | both depend only on the bus | can observe / veto | many, but ordered | + +### Decision tree + +**Q1. Does A need a return value from B?** → Yes: **direct call**. Events cannot return a value (request/reply over events is an anti-pattern). + +**Q2. Is B's reaction part of A's responsibility, or B's own concern?** + +- A's responsibility *includes* B's behavior (A orchestrates B) → **direct call**. E.g. `session` drives `agentLifecycle`; `loop` drives `llmRequester` / `toolExecutor`. +- B's reaction is B's own concern, A merely states a fact → **event**. E.g. `flag` reacts to `config.onDidChange`. + +**Q3. How many consumers?** + +- exactly one, known → **direct call**. +- zero / one / many, producer should not know → **event**. + +**Q4. Would a direct A→B call create a cycle or violate scope direction?** → A *consequence check*, not a primary reason. Decide by Q1–Q3 first; do not turn a genuine direct call into an event just to break a cycle. + +**Q5. Is this fact part of the durable record / replay / cross-agent projection?** → Yes: **emit it on the wire** (`wireRecord`). State changes that must be recorded, replayed, or synchronized across agents are projected onto the wire, not handled by a direct call alone (`permission.set_mode`, `goal.create/update/clear`, `plan_mode.enter/exit`). The wire is the *durable record*, not the live notification channel. + +### One-sentence rule + +> "I am telling you to do this, and I may need the result" → **direct call.** +> "I am announcing that something happened; react if you care" → **event.** +> "I am announcing something, and you may step in, in order, possibly to veto" → **hook.** + +### As extension points (open-closed) + +The three mechanisms above are also where a domain accepts new behavior without being edited. When adding a scenario would otherwise require changing this domain's `if/else`, expose the right extension point instead: + +| Need | Extension point | Typical scope | +|---|---|---| +| Register a new implementation / definition | a **registry / catalog** the domain queries | `App` | +| React to a fact the domain announces | an **event** on the bus | the announcing scope | +| Step into an operation in order / veto | a **hook** (`onWill`/`onDid`, `OrderedHookSlot`) | the owning scope | +| Swap a backend (File ↔ DB ↔ S3) | a **Store / Storage token** at the byte layer (see persistence.md) | `App` (composition root) | + +The standard shape of a "registry / catalog the domain queries" row is an L3 contribution point: the target domain owns a `collection` token, contributors call `this.provide(token, record)` from a unit, and a fold service in the target domain injects the `CollectionView` (incremental `onDidChange`; provider death withdraws the record). The four in-repo seams are `ConfigSectionContribution` → `ConfigRegistry`, `AgentToolContribution` → `AgentToolActivationService`, `AgentProfileContribution` → `IAgentProfileRegistry`, and `WireModelContribution` → `WireService` (file-level pointers: `packages/agent-core-v2/AGENTS.md` §Units and contribution points). + +Closed-for-modification means: the domain's own file is not where new scenarios branch. If a new scenario forces an edit here, an extension point is missing or misplaced. + +## 5. Dependency direction + +Two layers are involved: + +- **Scope direction**: short-lived → long-lived, **enforced by the container** (see orient.md). +- **Domain direction**: which domain may depend on which — **a matter of judgment**, not enforced by the container. + +> **A depends on B iff A needs B's data or behavior to do its own job.** + +Add one anti-rot heuristic to keep the graph from collapsing into a clique: + +> **Do not let a more foundational / more-reused Service come to know a more specific / more-upstream one.** + +Once a foundational component knows about an upstream scenario, it can no longer be reused by other scenarios and will almost always create a cycle. + +### The boundaries of this repo + +`agent-core-v2` has no mechanical domain-layer numbering — dependency direction is the judgment rule above, applied per domain. What remains enforceable is a small set of specific boundaries (`lint:imports`, `scripts/check-import-boundaries.mjs`): + +- v2 never imports v1 (`@pymodel/agent-core`). +- The kosong subtree keeps its strict internal order (`contract ← protocol ← provider/model`, purity bans, the `provider/bases` registration boundary). + +Two standing red lines on top of that: + +- The **base substrate** (`_base`, errors, wire types) never depends on any business domain. +- Business logic never depends on the **edge** (`gateway`, `rpc`, the `*Legacy` v1 adapters) — business code should not know REST / WebSocket exist. +- A cycle means knowledge was placed backwards: extract a third, more foundational Service, or invert the "notification" half into an event. + +> Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one. + +> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream. + +## 6. New-Service checklist + +1. **What does it remember, and what is the state's identity?** → pick the scope (§2). +2. **What is the shortest-lived dependency it must inject?** → the scope cannot be longer than that. +3. **Does it own state at both a global and a per-instance lifetime?** → if yes, split Multi-Scope (§3). +4. **For each collaborator: am I commanding it, notifying it, or letting it participate?** → pick the calling style (§4). +5. **Does each dependency arrow make a more foundational thing know a more specific thing?** → if yes, invert it (§5). + +## 7. Render the placement tree + +After the checklist, render the result as a plaintext tree — the deliverable reviewers read. Keep it in the design doc or PR description. + +```text +domain: `` (owning scope: ) +├─ serves (who uses me) tag = HOW they reach me +│ ├─ (inject) @ +│ └─ (accessor) @ +├─ exposes (interfaces I provide, by scope) +│ ├─ App : +│ ├─ Workspace : +│ ├─ Session : +│ └─ Agent : +└─ depends (what I inject) tag = calling style + └─ @ direct/event/hook — +``` + +Conventions: + +- List **only real interfaces**; write `—` for a scope with no exposed interface. Most domains are single-scope — do not invent symmetry. +- On `depends`, tag each arrow with its calling style: `direct`, `event`, or `hook`. +- On `serves`, tag each consumer with its **access mechanism**, grouped `inject` first then `accessor`: + - `inject` — a descendant or peer scope DI-injects me. Resolved by the container; lifetime-safe. + - `accessor` — an ancestor or edge scope borrows me through `IScopeHandle.accessor.get(...)`. Valid only while this scope lives; never cache the result; must run before the child scope is disposed. See the cross-scope borrow diagram below. +- An empty `(inject)` group with a non-empty `(accessor)` group is a signal: the interface is currently an edge / lifecycle command surface — check it is not leaking internals. +- A consumer is upstream of you. If you cannot name one business consumer, the domain may be dead or mis-scoped. + +### Cross-scope borrow diagram + +When a domain has `accessor` consumers, draw the reverse-direction borrow next to the tree so it is never mistaken for injection: + +```text +App scope + ──holds──► IScopeHandle() + │ + │ accessor.get() + │ └── resolve runs inside the child scope + ▼ + scope () + ← the interface lives here +``` + +Read it as: + +- `──holds──►` = the ancestor owns a handle to the child scope (it stores the key, not the service). DI allows this. +- `accessor.get(...)` = a **runtime borrow**, not a dependency edge. It must cross an `IScopeHandle`, run on demand, never be cached, and finish before the child scope is disposed. + +Worked example — `sessionLifecycle`: + +```text +domain: `sessionLifecycle` (owning scope: Workspace) +├─ serves (who uses me) +│ ├─ (inject) — (none) +│ └─ (accessor) +│ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… +│ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions +├─ exposes (interfaces I provide, by scope) +│ ├─ Workspace : ISessionLifecycleService — owns this workspace's live session scope tree +│ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) +│ └─ Agent : — — (per-agent state lives in agentLifecycle) +└─ depends (what I inject) + ├─ workspaceContext @Workspace seed — handler identity + persistence scope + ├─ bootstrap @App direct — addresses session storage + ├─ hostEnvironment @App direct — gates scope creation on the probe + ├─ sessionIndex @App direct — persisted read model for cold resumes + ├─ storage @App direct — atomic docs + append logs + ├─ workspaceDirs / workspaceSkillCatalog / workspaceMcp / … + │ @Workspace direct — the handler's shared resource services + └─ event @App direct — broadcasts session-level facts (e.g. archived) +``` + +Cross-scope borrow for `sessionLifecycle`: + +```text +App scope + WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler) + │ + │ accessor.get(ISessionLifecycleService) + │ └── resolve runs inside the Workspace scope + ▼ + Workspace scope (workspaceId) + SessionLifecycleService ──holds──► IScopeHandle(sessionId) + │ + │ accessor.get(ISessionMetadata) … + │ └── resolve runs inside the Session scope + ▼ + Session scope (sessionId) + sessionMetadata / agentLifecycle / … ← per-session services live here +``` + +How the three lenses shaped it: + +- **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`. +- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. +- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. + +For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. + +## Red lines (this stage) + +- Scope is not a domain; ownership follows write authority and invariants, not read consumption. +- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`) that re-merge multiple domains. +- No `Map` at `App` to fake per-session state. +- Scope follows state identity; stateless Services are pulled down by their shortest-lived dependency, otherwise default to `App`. +- Do not pre-split a domain that has state at only one lifetime. +- Need a result / I orchestrate → direct call; stating a fact → event; ordered participation / may veto → hook. +- Foundational layers never know upstream ones; business code never depends on the edge layer. +- A cycle means knowledge is placed backwards — refactor, do not route around it. +- Render the placement tree with real interfaces only — never pad an empty scope for symmetry. +- Tag `serves` consumers with `inject` / `accessor`; an empty `inject` group is a signal to check the interface is not leaking internals. +- An `accessor` consumer is a runtime borrow across a scope boundary, not DI injection — never cache the result and finish before the child scope disposes. +- A `serves` list with no business consumer (or only edge consumers) signals a dead or leaking interface. diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md new file mode 100644 index 00000000..cd3eb8ee --- /dev/null +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -0,0 +1,203 @@ +# Topic — Domain boundaries vs Scope + +How to keep `agent-core-v2` from recreating a god object after splitting one. Read this before naming a Service, adding an `I{Domain}EntityService`, or deciding whether data belongs to `session`, `agent`, or `turn`. + +## The one-sentence rule + +> **Scope is a lifetime and visibility boundary; a domain is a responsibility and data-ownership boundary.** + +A Service registered at `LifecycleScope.Session` or `LifecycleScope.Agent` is **not automatically in the `session` or `agent` domain**. Scope says when an instance is born, when it dies, and who can see it. Domain says which business responsibility it owns and which data it is allowed to mutate. + +## Definitions + +| Term | Meaning | +|---|---| +| **Scope** | Lifetime / visibility tier. Current code registers Services at `App`, `Session`, or `Agent`. | +| **Domain** | A cohesive business responsibility with its own model, invariants, and write authority. | +| **Entity** | Data with identity and lifecycle, usually suitable for `get/list/create/update/delete` semantics. | +| **Aggregate** | A consistency boundary: the owner that enforces invariants over a cluster of data. | +| **Read model / projection** | Derived data built for queries; it may be shaped like a domain, but it is not the write authority. | +| **Runtime state** | Ephemeral data that dies with its scope; it should not be forced into an entity store. | + +## The data-ownership test + +Do not ask "does Session / Agent / Turn use this data?". Most data is used by several of them. Ask these instead: + +1. **What is the data's identity?** `sessionId`, `agentId`, `turnId`, `taskId`, `workspaceId`, `providerName`, or something else? +2. **Who is the only writer?** The writer is usually the owner. Readers and projectors are not owners. +3. **Who enforces the invariants?** The domain that decides valid transitions owns the model. +4. **What is the authoritative source?** Atomic document, append-log / event stream, blob, query projection, config, or runtime memory? +5. **Can it be named without `Session` / `Agent` / `Turn`?** If yes, it probably deserves its own domain. + +Examples: + +- `PermissionRules` are Agent-scoped, but `permission` owns rule changes and evaluation. +- `BackgroundTask` is spawned by an Agent, but `background` owns task state and output. +- `ContextMessage` is consumed by the Agent loop, but `contextMemory` / `wireRecord` owns history and replay. +- `SessionMeta` is about a Session, but it is owned by `sessionMetadata`, not by a broad `session` data bag. + +## Persistence models are not all entity CRUD + +Before introducing `I{Domain}EntityService`, classify the persistence model: + +| Persistence model | Use when | Examples | +|---|---|---| +| **Atomic document** | One typed document per key | `SessionMeta`, `config.toml` | +| **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions | +| **Blob / key-value** | Large or content-addressed bytes | media offload, blob store | +| **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections | +| **Registry / catalog** | Global or scoped known items | `workspace`, `toolRegistry` | +| **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles | + +See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer. + +## Naming consequence + +Do not name Services after a scope or a god-object-shaped concept: + +- ❌ `IAgentEntityService` +- ❌ `IAgentDataService` +- ❌ `ISessionEntityService` +- ❌ `ITurnEntityService` that bundles context, tools, permissions, and telemetry + +Name Services after the real owning domain: + +- ✅ `ISessionMetadata` +- ✅ `ISessionIndex` +- ✅ `IAgentLifecycleService` +- ✅ `ITurnService` +- ✅ `IBackgroundTaskEntityService` +- ✅ `ICronTaskEntityService` +- ✅ `IPermissionRulesService` + +`Session` and `Agent` are valid scope names. They are usually **not** good data-owner names. + +## Split conclusion — `session` + +`session` is both a Scope and a narrow Domain. Keep the Domain small. + +The `session` domain owns only Session-level identity, metadata, lifecycle commands, and Session-level read views: + +| Concern | Owner | Notes | +|---|---|---| +| `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO | +| `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like | +| Open session scope registry | `sessionLifecycle` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | +| Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | +| Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | +| Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | + +`session` must not reabsorb these: + +| Data | Real owner | +|---|---| +| Agent instances / handles | `agentLifecycle` | +| Turns | `turn` | +| Context messages | `contextMemory` / `wireRecord` | +| Tool state | `toolStore` / `tool` | +| Permission rules / mode | `permission` | +| Profile / model | `profile` | +| Goal / Plan | `goal` / `plan` | +| Background tasks | `background` | +| Cron tasks | `cron` | +| Pending approvals / questions | `interaction` / `approval` / `question` | +| Workspace | `workspace` | +| Provider / config | `provider` / `config` | + +Entity-service conclusion for `session`: + +- ✅ `ISessionMetadata` is already an entity-document Service. +- ✅ `ISessionIndex` is a query/read-model Service. +- ❌ Do not create a broad `ISessionEntityService` that owns agents, turns, records, interactions, logs, workspace, and config. + +## Split conclusion — `agent` + +`agent` is primarily a Scope and composition boundary, not a large data Domain. + +Strictly, the `agent` domain owns only Agent-instance concerns: + +| Concern | Owner | Notes | +|---|---|---| +| Agent instance identity / handle | `agentLifecycle` | Owns live Agent scope handles | +| Agent creation / removal | `agentLifecycle` | Lifecycle, not a data bag | +| Parent / child relationship | `session` / `agentLifecycle` depending on current code | Do not duplicate it into a new Agent data service | +| Active turn reference | `turn` | Turn is its own domain even though it is Agent-scoped | + +Many Agent-scoped Services are **not** in the `agent` domain: + +| Data / capability | Real owner | Persistence model | +|---|---|---| +| Wire records | `wireRecord` | Append-log | +| Context messages | `contextMemory` | Event-sourced through `wireRecord` | +| Profile / model config | `profile` | Config + wire records | +| Tool definitions / registry | `toolRegistry` | Runtime registry | +| Tool mutable state | `toolStore` | Wire records | +| Permission mode / rules | `permissionMode` / `permissionRules` | Wire records + config | +| Goal | `goal` | Wire records | +| Plan | `plan` | Wire records + plan file | +| Skill activation | `skill` | Wire records | +| Background tasks | `background` | Task records / output logs, candidate for entity service | +| Cron tasks | `cron` | Task records, candidate for entity service | + +Entity-service conclusion for `agent`: + +- ✅ Keep `IAgentLifecycleService` for Agent instance lifecycle. +- ✅ If a persisted Agent identity registry is ever needed, name it after that narrow concern, e.g. `IAgentInstanceRegistry`. +- ❌ Do not create `IAgentEntityService` or `IAgentDataService` that bundles profile, records, tools, permission, goal, plan, background, cron, and turn. + +## Split conclusion — `turn` + +`turn` is a Domain, but it is **not** currently a separate `LifecycleScope` in code; `ITurnService` is registered at `Agent` scope. + +`turn` owns one execution round's runtime state and turn-level facts: + +| Concern | Owner | Notes | +|---|---|---| +| Active `Turn` handle | `turn` | `id`, `abortController`, `ready`, `result` | +| Turn id allocation | `turn` | Restored from `turn.prompt` records and `context.append_loop_event` turn ids | +| Turn lifecycle hooks | `turn` | `onLaunched`, `onEnded`, `beforeStep`, `afterStep` | +| `turn.started` / `turn.ended` live events | `turn` | Live event stream | + +`turn` must not own these: + +| Data / capability | Real owner | +|---|---| +| Prompt and context messages | `contextMemory` | +| Append-only record log mechanics | `wireRecord` | +| Step loop | `loop` | +| Tool execution | `toolExecutor` / `tool` | +| Permission decisions | `permission` | +| External hook policy | `externalHooks` | +| Telemetry pipeline | `telemetry` | +| Event transport | `eventSink` | + +Entity-service conclusion for `turn`: + +- ✅ Keep `ITurnService` as a runtime orchestrator. +- ✅ Add a Turn read model / projection only if history queries are needed. +- ❌ Do not create `ITurnEntityService` with `create/update/delete/list` over a turn table as the authoritative model. + +## Migration recipe + +When moving data out of a v1 god object or reviewing a proposed EntityService: + +1. **Name the data without using `Session`, `Agent`, or `Turn`.** If you cannot, the domain is probably unclear. +2. **Find the writer.** The exclusive writer is the likely owner. +3. **Find the invariant.** The Service that rejects invalid transitions owns the model. +4. **Classify the persistence model.** Atomic document, append-log, blob, query projection, registry, or runtime-only. +5. **Pick the Service shape.** + - Entity document / record → `I{Domain}EntityService` or domain-specific CRUD Service. + - Event-sourced → behavior Service + `wireRecord` record types + optional projection. + - Derived query → read-model Service, not a write authority. + - Runtime-only → scoped Service with no entity store. +6. **Choose the Scope by state identity.** Scope follows what the state is keyed by; it does not decide the domain name. +7. **Render the placement tree** from [design.md §7](design.md#7-render-the-placement-tree). + +## Red lines (this topic) + +- Scope is not a domain. `Session` / `Agent` scopes do not make data `session` / `agent` owned. +- Ownership follows write authority and invariants, not read consumption. +- Do not create `I{Scope}EntityService` bundles (`IAgentEntityService`, `ISessionEntityService`, `ITurnEntityService`) that re-merge multiple domains. +- Event-sourced domains keep behavior Services and append-log records; do not replace them with arbitrary CRUD. +- Read models may be shaped like a domain, but they are projections, not write authorities. +- A dependency is not ownership. A Service may inject another domain without owning that domain's data. diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md new file mode 100644 index 00000000..f336e888 --- /dev/null +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -0,0 +1,183 @@ +# Edge exposure — `resource:action` + WS events + +How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream. + +The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it. + +## 1. The edge model + +Four scopes, four URL shapes, one dispatcher: + +```text +GET|POST /api/v2/:sa Core +GET|POST /api/v2/workspace/:workspace_id/:sa Workspace +GET|POST /api/v2/session/:session_id/:sa Session +GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent +``` + +`:sa` is a single path segment of the form `:` (e.g. +`sessions:list`, `session:read`, `profile:getModel`). + +- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`). +- `:action` is the method. `GET` for reads, `POST` for writes. +- Body = the method's single argument (JSON), omitted for no-arg. +- Response = the project envelope `{ code, msg, data, request_id, details? }`. +- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result. + +```ts +// actionMap — the allowlist; hides internal domain names. +const actionMap = { + core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... }, + workspace: { 'skills:list': { service: IWorkspaceSkillCatalog, method: 'list' }, ... }, + session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... }, + agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... }, +}; +``` + +The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`. + +## 2. What may be exposed directly + +A Service method is directly exposable iff **all** hold: + +1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns). +2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`). +3. Errors are `PythinkerError` (coded). +4. It is a command/query, not a factory, stream, byte-store, or sink. + +If any fail → add a wire-safe orchestration method to the owning domain Service (e.g. `IAgentPromptService.submit` settles `{turn_id}` instead of returning the live `PromptHandle`) or compose several domain Services at the edge — kap-server's `routes/prompts.ts` is the reference for edge-side composition. + +## 3. Per-scope `resource:action` map + +Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. + +### Core (`/api/v2/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `sessions` | `listRecent` | ISessionIndex.listRecent | GET | +| `sessions` | `get` | ISessionIndex.get | GET | +| `sessions` | `count` | ISessionIndex.count | GET | +| `workspaces` | `list` | IWorkspaceService.list | GET | +| `workspaces` | `get` | IWorkspaceService.get | GET | +| `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | +| `workspaces` | `update` | IWorkspaceService.update | POST | +| `workspaces` | `delete` | IWorkspaceService.delete | POST | +| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET | +| `config` | `set` / `replace` / `reload` | IConfigService.* | POST | +| `providers` | `list` / `get` | IProviderService.* | GET | +| `providers` | `set` / `delete` | IProviderService.* | POST | +| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST | +| `oauth` | `getFlow` / `status` | IOAuthService.* | GET | +| `auth` | `summarize` | IAuthSummaryService.summarize | GET | +| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST | +| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET | +| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET | +| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET | + +### Session (`/api/v2/session/:sid/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `session` | `read` | ISessionMetadata.read | GET | +| `session` | `update` | ISessionMetadata.update | POST | +| `session` | `setTitle` | ISessionMetadata.setTitle | POST | +| `session` | `setArchived` | ISessionMetadata.setArchived | POST | +| `session` | `status` | ISessionActivity.status | GET | +| `session` | `isIdle` | ISessionActivity.isIdle | GET | +| `session` | `archive` | ISessionLifecycleService.archive | POST | +| `approvals` | `listPending` | IApprovalService.listPending | GET | +| `approvals` | `decide` | IApprovalService.decide | POST | +| `questions` | `listPending` | IQuestionService.listPending | GET | +| `questions` | `answer` | IQuestionService.answer | POST | +| `interactions` | `listPending` | IInteractionService.listPending | GET | +| `interactions` | `respond` | IInteractionService.respond | POST | +| `workspace` | `workDir` / `additionalDirs` / `resolve` | ISessionWorkspaceContext.* | GET | + +### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`) + +| resource | action | Service.method | verb | +|---|---|---|---| +| `goal` | `get` | IGoalService.getGoal | GET | +| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST | +| `plan` | `status` | IPlanService.status | GET | +| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST | +| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET | +| `tasks` | `stop` / `detach` | IBackgroundService.* | POST | +| `usage` | `status` | IUsageService.status | GET | +| `context` | `status` | IAgentTokenCountingService.get | GET | +| `dynamic_workflow` | `isActive` | IDynamicWorkflowService.isActive | GET | +| `dynamic_workflow` | `enter` / `exit` | IDynamicWorkflowService.* | POST | +| `permission` | `getMode` | IPermissionModeService.mode | GET | +| `permission` | `setMode` | IPermissionModeService.setMode | POST | +| `permissionRules` | `list` | IPermissionRulesService.rules | GET | +| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST | +| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET | +| `profile` | `setModel` / `setThinking` | IProfileService.* | POST | +| `messages` | `list` | IContextMemory.get | GET | +| `messages` | `splice` | IContextMemory.splice | POST | +| `toolStore` | `get` / `data` | IToolStoreService.* | GET | +| `toolStore` | `set` | IToolStoreService.set | POST | +| `mcp` | `list` | IMcpService.list | GET | +| `mcp` | `reconnect` | IMcpService.reconnect | POST | +| `tools` | `list` | IToolRegistry.list | GET | + +## 4. Facade-needed (wrap before exposing) + +These fail §2 and must be wrapped in a facade that takes ids and returns data: + +| Service | Why not direct | Facade shape | +|---|---|---| +| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session | +| IAgentPromptService / IAgentTurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` | +| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC | +| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info | +| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) | +| Storage / Store (IFileSystemStorageService / IAppendLogStore / IAtomicDocumentStore / IBlobStore) | bytes / streams | not for RPC | +| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes | +| IExternalHooksService | server-side outbound | not exposed | +| IWireRecord | write-ahead log | internal | + +## 5. WS events + +A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6): + +```text +WS /api/v2/ws +``` + +Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`. +Server → client: `ready`, `result`, `error`, `event`, `ping`. + +`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event` source and forwards each emission as an `event` message, keyed by the client-chosen `id`. + +The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`): + +| Scope | event | Source | +|---|---|---| +| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) | +| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) | + +Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer. + +Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`): + +- request ids + active-request table — `cancel` / `unlisten` disposes them; +- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`; +- schema validation — invalid frames are dropped, not fatal; +- graceful close — dispose listeners, cancel pending, reject in-flight calls; +- no stack traces over the wire; +- non-serializable event payloads are dropped, never fatal. + +Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation. + +## 6. Red lines (edge exposure) + +- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`. +- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade. +- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade. +- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes. +- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`. +- Events stream over WS (`listen`), never RPC (`call`). +- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface. +- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters. diff --git a/.agents/skills/agent-core-dev/errors.md b/.agents/skills/agent-core-dev/errors.md new file mode 100644 index 00000000..11a812cd --- /dev/null +++ b/.agents/skills/agent-core-dev/errors.md @@ -0,0 +1,40 @@ +# Topic — Errors + +Error infrastructure for agent-core-v2: base classes, the per-domain code contract, wire serialization, and the conventions domains follow when raising errors. The package-level reference is `packages/agent-core-v2/docs/errors.md`; this topic summarizes the hot-path rules. + +Base classes and serialization are **centralized** in `_base/errors`; error **codes** are **decentralized** — each domain owns an `errors.ts` that self-registers its codes and metadata, and the `src/errors.ts` facade aggregates them into the unified `ErrorCodes` const. + +## Where things live + +- `src/_base/errors/errors.ts`: base classes — `Error2`, `ExpectedError`, `ErrorNoTelemetry`, `BugIndicatingError`, `NotImplementedError`, plus `isError2` and `unwrapErrorCause`. +- `src/_base/errors/codes.ts`: the `ErrorDomain` contract, the registry (`registerErrorDomain` / `errorInfo` / `isErrorCode`), and `CoreErrors` (`internal`, `not_implemented`). The `ErrorCode` union type is derived by `#/errors` from the aggregated domain definitions. +- `src/_base/errors/serialize.ts`: `ErrorPayload`, `isCodedError`, `toErrorPayload`, `fromErrorPayload`. Wire-facing names (`PythinkerErrorPayload`, `toPythinkerErrorPayload`) mirror the protocol and are kept as-is. +- `src/_base/errors/unexpectedError.ts`: `onUnexpectedError` / `setUnexpectedErrorHandler` (global handler). +- `src//errors.ts`: the domain's `XxxErrors` descriptor (codes + retryable list + per-code info overrides), self-registered on import. +- `src/errors.ts`: the **facade** — imports every domain's `errors.ts`, builds `ErrorCodes`, re-exports the primitives. Throw sites import from here. + +## Conventions (hard rules) + +- **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. Bare `new Error` only for unreachable guards; `BugIndicatingError` for caller bugs; `NotImplementedError('feature')` for stubs. +- **Define codes in the owning domain**, in `/errors.ts` as an `XxxErrors` descriptor (`satisfies ErrorDomain` + `registerErrorDomain`), then wire it into the facade. Never add domain codes to `_base/errors`. +- **One `code` per failure mode.** Codes read `domain.reason`. The valid code strings are derived from the aggregated domain definitions (`ErrorCode` in `#/errors` is computed from the `ErrorCodes` aggregate): **add new codes to the owning domain's `errors.ts`** — registration throws on cross-domain collisions. Renaming/removing a code is a major. +- **Translate foreign errors at the boundary.** Provider/HTTP, fs, MCP errors are re-thrown as the owning domain's coded error. `_base/errors` never imports a business domain. +- **Translation is idempotent and cause-preserving.** Translators (`toHostFsError`, `toStorageIoError`) pass through an already-translated error and always keep the original as `cause`. +- **`details` is structured and JSON-serializable; `message` is a short human sentence.** Paths/errnos/scope/key go into `details`, not the message. +- **Cancellation passes through untranslated** (`UserCancellationError` from `_base/utils/abort`) — apply only at boundaries that can actually see cancellation; do not sprinkle the check everywhere. +- **Classify wrapped errors via `unwrapErrorCause`** — errno/status predicates test the unwrapped cause, not the coded wrapper. +- **Branch on `code`, never `instanceof`, across the wire.** In-process, `instanceof Error2` / `isCodedError` are fine. + +## Reference tiers + +- `os.fs` — `HostFsError` via `toHostFsError` (`os/interface/hostFsErrors.ts`): errno → `os.fs.*`, details `{ path, op, errno?, syscall? }`. +- `os.process` — `HostProcessError`: `spawn_failed` / `kill_failed`, raw error as `cause`. +- `storage` — `StorageError` (`persistence/interface/storage.ts`): `not_found` / `decode_failed` / `corrupted` / `io_failed` (retryable) / `locked` (retryable). ENOENT keeps absence semantics, never an error. A locked query store throws `storage.locked`; consumers catch it explicitly and fall back — no silent no-op degradation. +- `wire` — `WireError` (`wire/errors.ts`): `DuplicateOpError`, `CycleError`, and `wire.unknown_record` (replay skips unknown records, reports via `onUnexpectedError`, returns `{ unknownRecords }`). + +## Red lines (this topic) + +- Throw a coded error with a `code`, not a bare string (except unreachable guards / `BugIndicatingError` / `NotImplementedError`). +- Codes live in the owning domain's `errors.ts` and self-register; new codes land in the owning domain first. +- Translate foreign errors at the owning domain's boundary, idempotently, with `cause` and structured `details`; `_base/errors` never imports a business domain. +- Branch on `code` across the wire, never `instanceof`. diff --git a/.agents/skills/agent-core-dev/flags.md b/.agents/skills/agent-core-dev/flags.md new file mode 100644 index 00000000..2107a82e --- /dev/null +++ b/.agents/skills/agent-core-dev/flags.md @@ -0,0 +1,108 @@ +# Topic — Flags + +Experimental feature-flag gating for agent-core-v2 — an App-scope `IFlagService` resolver plus a writable `IFlagRegistry` catalog that domains contribute their flags to, backed by the `[experimental]` config section. + +Gate not-yet-public features behind `IFlagService.enabled(id)`, per the repository hard rule that unreleased behavior must be flag-gated. v1 was a process-global `FlagResolver` singleton over a central `FLAG_DEFINITIONS` array; v2 is a scoped DI service whose flag definitions are registered **decentrally** by each owning domain — there is no central catalog to edit. + +## Layout + +- `src/app/flag/flagRegistry.ts` — `IFlagRegistry` token + `FlagDefinitionInput` / `FlagId` / `FlagSurface` types + `registerFlagDefinition` / `getContributedFlags` (import-time contribution queue). +- `src/app/flag/flagRegistryService.ts` — `FlagRegistryService` impl; in-memory catalog seeded from import-time contributions; App scope. +- `src/app/flag/flag.ts` — `IFlagService` token + resolver types (`ExperimentalFlagMap`, `ExperimentalFlagConfig`, `ExperimentalFlagSource`, `ExperimentalFeatureState`) + `EXPERIMENTAL_SECTION` (`experimental`) / `ExperimentalConfigSchema` (zod) + the module-level `registerConfigSection(EXPERIMENTAL_SECTION, …)` call that owns the section. +- `src/app/flag/flagService.ts` — `FlagService` impl + `MASTER_ENV` (`PYTHINKER_CODE_EXPERIMENTAL_FLAG`); reads definitions from `IFlagRegistry` and overrides from `IConfigService`; self-registers at App scope. +- `src/app/flag/index.ts` — **removed (no barrel)**; `src/index.ts` imports the `flag` leafs precisely instead (e.g. `import './app/flag/flagService'`). +- `src//flag.ts` — each domain that owns a flag declares it here and calls `registerFlagDefinition` at the module top level (e.g. `src/agent/toolSelect/flag.ts`). The directory already names the domain, so the file is just `flag.ts`. + +## Public surface + +- `IFlagService` (DI token, App scope): `enabled(id)`, `explain(id)`, `snapshot()`, `enabledIds()`, `explainAll()`, `setConfigOverrides(overrides)`, `registry`. +- `IFlagRegistry` (DI token, App scope): `register(definition)`, `get(id)`, `list()` — writable catalog. `register` is the **runtime** path (tests, dynamic registration); `IFlagService.registry` exposes the same instance for hosts/UI to enumerate flags without resolving them. +- `registerFlagDefinition(definition)` — the **import-time** path. Domains call this from their `flag.ts` top level; contributions are queued and drained by `FlagRegistryService` when it is instantiated. +- `FlagService` / `FlagRegistryService`: exported for tests and hosts that construct them directly. + +## Resolution precedence + +Highest wins; env is read live on every call (nothing cached): + +1. Master env `PYTHINKER_CODE_EXPERIMENTAL_FLAG` truthy → every flag on. +2. Per-feature `def.env` (e.g. `PYTHINKER_CODE_EXPERIMENTAL_MY_FEATURE`) → forces on/off. +3. `[experimental]` config section per-flag override. +4. Registry `default`. + +`explain(id)` returns the winning `source` (`master-env` | `env` | `config` | `default`) plus the effective `configValue`. `explain(id)` returns `undefined` (and `enabled(id)` returns `false`) for an id that no domain has registered. + +## Config integration + +- The flag domain owns the `[experimental]` section: `src/app/flag/flag.ts` registers it at module load via `registerConfigSection(EXPERIMENTAL_SECTION, ExperimentalConfigSchema, { fromToml, toToml })` (import = register, drained by `ConfigRegistry` at construction); `FlagService` reads overrides from `IConfigService`. +- It subscribes `IConfigService.onDidChange` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. +- `ConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by the flag domain. +- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead. + +Config shape: + +```toml +[experimental] +my_feature = false +``` + +Keys are intentionally loose (`z.record(z.string(), z.boolean())`), so obsolete flags stay inert config. + +## Add a flag + +Declare the definition in the owning domain's `flag.ts` and call `registerFlagDefinition` at the module top level. There is no central catalog to edit. + +`src//flag.ts`: + +```ts +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const myFeatureFlag: FlagDefinitionInput = { + id: 'my_feature', + title: 'My feature', + description: '...', + env: 'PYTHINKER_CODE_EXPERIMENTAL_MY_FEATURE', + default: false, + surface: 'both', +}; + +registerFlagDefinition(myFeatureFlag); +``` + +Then ensure the package entry `src/index.ts` imports the flag leaf precisely so the top-level call runs at import time — there is no `src//index.ts` barrel: + +```ts +// src/index.ts +import './/flag'; +``` + +`src/index.ts` imports every domain's leaf files precisely (one line per leaf), so the contribution runs during bootstrap, before any scope is created — and therefore before any consumer resolves `IFlagService`. + +- `env` must start with `PYTHINKER_CODE_EXPERIMENTAL_`, be unique, and not equal `PYTHINKER_CODE_EXPERIMENTAL_FLAG`. +- `id` must not be `flag`. A duplicate `id` throws when `FlagRegistryService` drains the contributions. +- `FlagId` is `string`, not a literal union: with no central catalog there is nothing to derive it from, so `enabled()` has no compile-time typo-checking. Cover gated behavior with tests instead. +- `surface`: `core` | `tui` | `both` (documentation/grouping only; not used in resolution). + +## Consume a flag + +Inject `IFlagService` and gate on it. It is resolvable from any scope (App ancestor): + +```ts +constructor(@IFlagService private readonly flags: IFlagService) {} +// ... +if (!this.flags.enabled('my_feature')) return; +``` + +## Layering & scope + +- Domain `flag` imports only `config` downward. +- It cannot live in `_base`: registering/reading the config section requires importing `config`, and `_base` is pure infrastructure that must not know any business domain. +- Scope: `IFlagRegistry` and `IFlagService` are both `App`. Env + config are process-global inputs, so there is no per-session/agent state. Flag definitions are contributed at **import time** (top-level `registerFlagDefinition` calls), so they are queued before any scope is created and drained when `FlagRegistryService` is first instantiated — before `IFlagService` is first resolved. +- Tests build `FlagService` + `FlagRegistryService` directly with a real `ConfigRegistry`/`ConfigService` and an injected env map, then `register` the flags they exercise. + +## Red lines (this topic) + +- Gate unreleased behavior behind a registered flag; no ad-hoc env toggles. +- Contribute each flag from the **owning domain's** `flag.ts` (`src//flag.ts`) via a top-level `registerFlagDefinition` call; there is no central catalog to edit. The directory names the domain, so the file is just `flag.ts`. +- `env` must start with `PYTHINKER_CODE_EXPERIMENTAL_`, be unique, and not equal `PYTHINKER_CODE_EXPERIMENTAL_FLAG`; `id` must not be `flag`. +- `FlagId` is `string` (decentralized registration) — do not reintroduce a central `FLAG_DEFINITIONS` array or a derived literal union. +- `flag` lives at `App` scope — never in `_base`, never per-session. diff --git a/.agents/skills/agent-core-dev/implement.md b/.agents/skills/agent-core-dev/implement.md new file mode 100644 index 00000000..9e436280 --- /dev/null +++ b/.agents/skills/agent-core-dev/implement.md @@ -0,0 +1,295 @@ +# Stage 3 — Implement + +Write the contract leaf, implementation leaf (with its registration), and the package-entry lines that load them. Each section below introduces one DI building block as you need it. Source lives in `src/_base/di/`. + +## Standard recipe for a new `IXxxService` + +1. **Contract leaf** — `src//.ts`: interface (with `_serviceBrand`) + `createDecorator` identity. +2. **Impl leaf** — `src//Service.ts`: class with `@IX` constructor deps; top-level `registerScopedService(scope, IX, Impl, activation, '')`. The fourth argument is activation; the fifth is the domain. +3. **Entry** — `src/index.ts`: load each leaf precisely — `export * from './/';` for the contract and `import './/Service';` for the impl (importing the impl runs the registration). **No `src//index.ts` barrel.** +4. **Tests** — see test.md. + +There is **no central wiring file**: bindings live in each domain's impl file and are collected through import side effects. + +## §1 Interface + identity (a global service, no deps) + +```ts +// greet/greet.ts +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IGreeter { + readonly _serviceBrand: undefined; // type marker: tells DI "this is a service" + hello(): string; +} + +export const IGreeter: ServiceIdentifier = createDecorator('greeter'); +``` + +`createDecorator(name)` produces a `ServiceIdentifier` that is three things at once: a runtime key, a parameter decorator, and a compile-time carrier of the `IGreeter` type. + +> **The identity name is globally unique.** `createDecorator` caches by `name`; two domains using the same string collide and share one identity. + +```ts +// greet/greetService.ts +import { LifecycleScope } from '#/app/scopes'; +import { registerScopedService, ScopeActivation } from '#/_base/di/scope'; +import { IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; // mirrors the interface marker + hello(): string { return 'hi'; } +} + +registerScopedService( + LifecycleScope.App, // lifetime: process-wide + IGreeter, // identity + Greeter, // implementation + ScopeActivation.OnScopeCreated, // construct when the App scope is created + 'greet', // domain name (for diagnostics) +); +``` + +The scope a class binds to is an **intrinsic property of the class**, decided at the registration point, not the call site. + +The impl's top-level `registerScopedService` runs as soon as the module is imported. There is no `greet/index.ts` barrel — instead, add the leafs to the package entry `src/index.ts`, one line per leaf: + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; // this import runs registerScopedService +``` + +Anyone can now `accessor.get(IGreeter)` the single global instance. + +## §2 Constructor injection (your service uses others) + +```ts +export class SessionMetadata extends Disposable implements ISessionMetadata { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @IAtomicDocumentStore private readonly store: IAtomicDocumentStore, + @ILogService private readonly log: ILogService, + ) { + super(); + } +} +``` + +`@ISessionContext` records "parameter 0 needs `ISessionContext`" on the class metadata; the container fills it when constructing. + +Three inviolable constraints: + +1. **Do not `new` a class with `@IService` deps** — `new` bypasses registration, scope, and the singleton cache. Inject with `@IX` or `accessor.get(IX)`. +2. **`@IX` decorates constructor parameters only.** Decorating a field/method throws at runtime. +3. **Parameter order depends on how the object is built** — for `createInstance` non-singletons, static params come first (see §7); for scoped services, `@IX` params are conventionally first and any static params need defaults. See service-authoring.md §constructor-conventions. + +Consumers resolve by interface and never import the impl class: + +```ts +const meta = accessor.get(ISessionMetadata); // type is ISessionMetadata +``` + +> If you need "a config" rather than "a service", model it as a service (e.g. `IConfigService`) and inject it. If you need a per-turn, parameterized, non-singleton object, see §7. + +## §3 Scoped registration (not global) + +Swap the `scope` argument to bind to a different tier. Use `ScopeActivation.OnDemand` when the service should be constructed only on its first `get()`: + +```ts +registerScopedService( + LifecycleScope.Session, + ISessionMetadata, + SessionMetadata, + ScopeActivation.OnDemand, + 'sessionMetadata', +); +``` + +Remember the visibility rule from orient.md: a service may inject services from its own scope or any ancestor; never from a descendant. + +## §4 Releasing resources (`Disposable`) + +For a service that subscribes to events, starts timers, or holds handles: + +```ts +import { Disposable } from '#/_base/di/lifecycle'; + +export class WSBroadcastService extends Disposable implements IWSBroadcastService { + declare readonly _serviceBrand: undefined; + + constructor(@IEventService event: IEventService) { + super(); + this._register(event.subscribe(() => { /* … */ })); // collect child resources + } +} +``` + +- Extend `Disposable`, collect any `IDisposable` with `this._register(d)` (event subscriptions, `toDisposable(fn)`, etc.). +- The container calls `dispose()` automatically when the service is torn down; child resources release in turn. +- Disposal order is deterministic (orient.md): child scopes first; within a scope the Ledger (`src/_base/lifecycle/`) tears entries down in strict reverse registration order, serially — `Disposable` / `DisposableStore` delegate to it. +- Extend `Service` (from `#/_base/di/service`) instead when the unit needs capability calls on `this` (`provide` / `effect` / `on` / `get` / `ref`) — e.g. contributing a record to a `collection` token. `Service` extends `Disposable` (so `_register` is unchanged) and adds the two-phase construction protocol: `provide` / `on` / `effect` calls inside the constructor are buffered and flushed by the kernel after `Reflect.construct`; `get` / `ref` throw inside the constructor — dependencies stay constructor parameters. A manually `new`ed `Service` has no capabilities: every capability call throws. + +## §5 Scope activation + +`ScopeActivation` is the only construction-timing choice for scoped services: + +```ts +export enum ScopeActivation { + OnScopeCreated = 0, + OnDemand = 1, +} +``` + +```ts +// Default: construct the real instance while the App scope is created. +registerScopedService( + LifecycleScope.App, + ILogService, + LogService, + ScopeActivation.OnScopeCreated, + 'log', +); + +// Construct the real instance on the first get(IScopeRegistry). +registerScopedService( + LifecycleScope.App, + IScopeRegistry, + ScopeRegistry, + ScopeActivation.OnDemand, + 'gateway', +); +``` + +`ScopeActivation.OnScopeCreated` is the default fourth argument. Scope creation activates every registration using this mode, after constructing its dependencies. An eager constructor failure no longer fails scope creation: the unit lands in sticky `Failed` — scope creation succeeds, resolving the unit rethrows its error, and an explicit `update()` reloads it (see the bootstrap note below). Use it for ordinary services and for constructor side effects that must exist when the scope becomes ready. + +`ScopeActivation.OnDemand` stores the descriptor without constructing the service. The first `get()` constructs and caches the real instance directly; later `get()` calls return that same instance. Use it only when construction should wait until the service is actually requested. + +Both modes use the same dependency graph and reject cycles with `CyclicDependencyError`. + +The complete registration signature is `registerScopedService(scope, id, ctor, activation = ScopeActivation.OnScopeCreated, domain?)`: activation is the fourth argument and domain is the fifth. + +**Bootstrap shares the dynamic provide path.** Scope creation (`Scope.createApp` / `Scope.createChild` / `createScopedChildHandle` in `src/_base/di/scope.ts`) submits the scope kind's entire `registerScopedService` batch as ONE cascade transaction via `provideAll`: every token registers before the activation wave runs, so **registration order never matters**, and untracked transitive `createInstance` resolutions succeed inside the batch. A seed occupying a token (the `extra` tuple in `ScopeOptions`) overrides the static registration for that token. `activateScopeServices` is gone — there is no separate static activation path. + +## §6 Using a service inside a plain function (`invokeFunction`) + +When you do not want a new class and just need a service once, or when you expose a `ServicesAccessor` to the outside: + +```ts +const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => instantiation.invokeFunction((a) => a.get(id)), +}; +``` + +`invokeFunction(fn)` hands `fn` a `ServicesAccessor` valid **only during that call**. + +> **The accessor is valid only during the invocation.** Calling `accessor.get()` after `invokeFunction` returns throws `"service accessor is only valid during the invocation"`. Do not stash it for async use — inject the service in the constructor (§2) if you need it long-term. + +## §7 Creating a non-singleton object with deps (`createInstance`) + +For a per-turn executor that also has `@IService` deps: + +```ts +class TurnRunner { + constructor( + private readonly input: string, // static param: passed by caller + private readonly turn: number, // static param: passed by caller + @ILogService private readonly log: ILogService, // service param: injected by container + ) {} +} + +const runner = instantiation.createInstance(TurnRunner, 'hello', 1); +``` + +Static params come first (you pass them), service params follow (the container fills them), then `Reflect.construct` builds the instance. This object is **not** placed in any scope's singleton cache — every call is a fresh instance — and it is not tracked as a cascade unit either: `createInstance` products are cascade-exempt leaves that no cascade tears down or rebuilds; their owner disposes them. + +> This is why service params must follow static params **for `createInstance`**: the container sorts by the parameter positions recorded via `@IX`. `_serviceBrand` lets the compiler tell the two kinds apart. Scoped services built by `registerScopedService` follow a different convention (`@IX` params first, optional static params after) — see service-authoring.md §constructor-conventions. + +## §8 Spawning a child scope / child container + +For a service that "starts a new session / agent" and needs a child scope, inject `IInstantiationService` itself (every container binds itself as `IInstantiationService`): + +```ts +export class ScopeRegistry implements IScopeRegistry { + declare readonly _serviceBrand: undefined; + + constructor(@IInstantiationService private readonly instantiation: IInstantiationService) {} + + createSession(opts: CreateSessionOptions): Promise { + const collection = new ServiceCollection(); + for (const entry of getScopedServiceDescriptors(LifecycleScope.Session)) { + collection.set(entry.id, entry.descriptor); // collect Session-tier descriptors + } + const child = this.instantiation.createChild(collection); // spawn child container + const accessor: ServicesAccessor = { + get: (id: ServiceIdentifier): T => child.invokeFunction((a) => a.get(id)), + }; + const handle: IScopeHandle = { id: opts.sessionId, kind: LifecycleScope.Session, accessor }; + this.sessions.set(opts.sessionId, handle); + return Promise.resolve(handle); + } +} +``` + +Key points: + +- `getScopedServiceDescriptors(scope)` returns every descriptor registered at that tier; load them into a `ServiceCollection`. +- `instantiation.createChild(collection)` builds a child container whose parent pointer is the current container — so the child resolves upward to `App` services (the visibility rule). +- Expose the child to the outside by wrapping it in a `ServicesAccessor` via `invokeFunction` (§6). + +> Higher-level code usually calls `Scope.createChild(kind, id)` (it does the "filter descriptors + build child" for you, then submits the whole batch through `provideAll` as one cascade transaction — see §5). Drop to the manual `ServiceCollection` form only when you need explicit control; to change bindings on an already-created container, prefer `provide` / `unprovide` / `update` over rebuilding a collection. Before the static batch lands, the scope-creation point runs the kernel's `ScopeUnits` fold (`_base/di/scopeUnits.ts` — materializes the recipes contributed to `ScopeUnits(kind)` as per-scope units) and then the `ScopeOptions.assemble` hook — the session domain uses the hook to construct its seed-adapter units (`session/sessionSeed/sessionSeedAdapters.ts`) so their provided tokens exist before the session services activate. + +## §9 Cyclic dependencies (forbidden — refactor) + +Business rule: **no cyclic dependencies.** The container rejects them; the correct response is to refactor, not to make it run. + +### The container rejects synchronous cycles + +If A needs B while being created and B needs A while being created, the container throws `CyclicDependencyError` with a `path` like `['A', 'B', 'A']`. Self-cycles (A depends on itself) are also rejected. This is a protection mechanism telling you the two services' responsibilities are mis-drawn. + +### Why cycles are disallowed + +- Scope layering makes normal dependencies a DAG (Agent → Session → Workspace → App, resolving upward); a cycle is almost always a design smell. +- "Making the cycle happen to work" turns construction order into an implicit contract — hard to debug. + +v2's stance: **the dependency graph must be acyclic.** + +### How to refactor (in priority order) + +1. **Extract a third service C.** Move the part A and B both need into C; let A and B both depend on C instead of each other. The most common fix. +2. **Decouple with an event.** If A only needs to know about a change in B, have B emit via `IEventService` and A subscribe, rather than A holding a reference to B. +3. **Re-partition scope.** One of them may belong at a different tier — moving it makes the cycle disappear. + +### Activation does not break cycles + +Both `ScopeActivation.OnScopeCreated` and `ScopeActivation.OnDemand` construct through the same synchronous dependency graph. Changing activation cannot make a cycle valid. On `CyclicDependencyError`, refactor per the above. + +## Interface cheat sheet + +| Interface | Section | Role | +|---|---|---| +| `createDecorator(name)` → `ServiceIdentifier` | §1 | identity (runtime key + compile-time type + param decorator) | +| `@IService` | §2, §7 | declare a dependency on a constructor param | +| `registerScopedService(scope, id, ctor, activation, domain)` | §1, §3, §5 | bind an impl to a lifetime tier and construction time | +| `ServicesAccessor.get(IX)` | §2, §6 | resolve an instance by interface | +| `IInstantiationService.invokeFunction(fn, …)` | §6, §8 | obtain a temporary accessor inside a function | +| `IInstantiationService.createInstance(ctor, …args)` | §7 | build a non-singleton object with deps injected | +| `IInstantiationService.createChild(collection)` | §8 | spawn a child container | +| `getScopedServiceDescriptors(scope)` | §8 | retrieve all descriptors registered at a tier | +| `Disposable` / `DisposableStore` / `IDisposable` | §4 | resource management and disposal | +| `Scope` / `LifecycleScope` | §3, §8 | the lifetime tree | +| `ScopeActivation` | §3, §5 | choose scope-created or first-`get()` construction | +| `Service` (`_base/di/service`) | §4 | unit base class — `this.provide/effect/on/get/ref` capabilities, two-phase construction | +| `collection(name)` / `CollectionView` (`_base/di/collection`) | §4 | contribution-point token + the fold's live view (provider death withdraws the record) | +| `SyncDescriptor` | (tests / low-level) | package a constructor + static args into a pending descriptor | + +> Legacy export (not used in v2, just recognize it): `refineServiceDecorator` is a VS Code leftover DI helper. v2 src/test has zero references; always use `registerScopedService`. + +## Red lines (this stage) + +- No `new` on a class whose constructor carries `@IService` deps — inject or `accessor.get(IX)`. +- `@IX` decorates constructor params only; parameter order depends on construction (static-first for `createInstance`, `@IX`-first for scoped services — see service-authoring.md). +- Both interface and impl carry `_serviceBrand`; the `createDecorator` name is globally unique. +- `ServicesAccessor` is valid only during `invokeFunction` — never stash it for async use. +- No cyclic dependencies — refactor (extract / event / re-scope); activation does not change cycle detection. diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md new file mode 100644 index 00000000..446f4aef --- /dev/null +++ b/.agents/skills/agent-core-dev/orient.md @@ -0,0 +1,77 @@ +# Stage 1 — Orient + +Understand the DI × Scope black box and the file conventions before touching business code. + +## The DI black box + +When writing business code you declare three things; the container handles the rest (when to construct, whether it is the same instance, ordering, disposal): + +- **Who am I** — an identity that is both a runtime key and a compile-time type. +- **Whom do I need** — the dependencies that provide my capabilities. +- **How long do I live** — which lifetime tier I belong to. + +Classes talk only to interfaces and never care how an implementation is constructed. + +## The four `LifecycleScope` tiers + +Lifetimes form a tree, from longest to shortest: + +```text +App process-wide, single global instance + └── Workspace one workspace handler (a materialized workspace root) + └── Session one session + └── Agent one agent +``` + +```ts +// src/app/scopes.ts — the business layer declares the tiers and their order; +// the DI kernel only knows opaque string kinds plus the declared topology. +export enum LifecycleScope { + App = 'app', + Workspace = 'workspace', + Session = 'session', + Agent = 'agent', +} +``` + +- Later in the topology = shorter life = closer to a leaf. +- "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`. +- `kind` must advance along the declared topology in the parent→child direction. + +### Visibility rule + +A child scope sees its ancestors; a parent never sees its children. Resolution walks *up* the tree: + +- ✅ An `Agent` service injects a `Session` or `App` service (found upward). +- ❌ An `App` service injects a `Session` service (the parent does not look down, and the child may not exist yet). + +> **Short-lived may inject long-lived; never the reverse.** The tree structure enforces this — it is not a matter of discipline. + +### Disposal order + +Deterministic: **child scopes die first; within one scope, teardown runs in strict reverse registration order, one entry at a time.** The mechanism is the Ledger (`src/_base/lifecycle/`): ordered effect bookkeeping, dual-track (sync + async disposers), serial reverse-order teardown (never parallel), with the teardown reason (`'scope-close' | 'cascade' | 'unload'`) passed through to every disposer. `Disposable` / `DisposableStore` (`src/_base/di/lifecycle.ts`) delegate to it — "reverse construction order" is a Ledger property, not a container convention. Business code declares which tier it lives in and never disposes by hand. + +## Dynamic DI: units and cascades + +Registration is not the end of the story. Every unit a container tracks — static registrations and runtime `provide`s alike — lives in a small state machine owned by the scope's cascade engine (`src/_base/di/cascadeEngine.ts`, one per scope container, orchestrating tree-wide). Vocabulary you will meet in errors, tests, and the debug surface: + +- **Unit states** — `Pending → Activating → Active`, plus `Unloading` during teardown and a sticky `Failed`. A construction failure parks the unit in `Failed` with no auto-retry: resolving it rethrows its error; an explicit `update()` reloads it. +- **Waiting area** — a unit whose declared dependencies are missing sits `Pending` and auto-activates when they arrive, including cross-scope wake-up when an ancestor gains the token. An `ondemand` unit counts as available: consumers pull it transitively at materialization. +- **Cascade transaction** — every `provide` / `unprovide` / `update` runs as one tree-wide transaction: contagion set from the persistent dependency graph (instance edges, child→parent across scopes) → abort hook → global reverse-topo teardown → apply the change → waiting-area recheck fixpoint → history ring. Static bootstrap shares this path: scope creation submits the kind's whole registration batch as one `provideAll`, so registration order never matters. + +## Import boundaries + +There is no domain-layer numbering — a domain may import any other domain, guided by the dependency-direction judgment in design.md. The only mechanically enforced import boundaries are (`lint:imports`, `scripts/check-import-boundaries.mjs`): + +- v2 never imports v1 (`@pymodel/agent-core` or any subpath). +- The kosong subtree (`src/kosong/{contract,protocol,provider,model}`) keeps its strict internal order (`contract ← protocol ← provider/model`), purity bans (no SDKs in `contract`/`protocol`), and the `provider/bases` registration boundary. + +## Comment convention + +`packages/agent-core-v2/AGENTS.md` bans comments: no file headers, no section banners, no statement-level narration — the code is the source of truth. The only exception is JSDoc attached to exported symbols, which flows into the generated `.d.ts` and the consumers' IDE hover. Tooling directives (`eslint-disable`, `@ts-expect-error`, …) are banned too: fix the underlying lint/type problem instead, and put negative type-safety cases in compiler-asserted fixtures. Scope is carried by the filename: `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). + +## Red lines (this stage) + +- Import via the `#/...` alias (mapped to `src/`); never reach into another domain's internals by relative path. +- Short-lived may inject long-lived; never the reverse. +- No comments — not file headers, not beside statements; exported-symbol JSDoc is the only exception. diff --git a/.agents/skills/agent-core-dev/permission.md b/.agents/skills/agent-core-dev/permission.md new file mode 100644 index 00000000..728fabff --- /dev/null +++ b/.agents/skills/agent-core-dev/permission.md @@ -0,0 +1,213 @@ +# Topic — Permission + +The target design for the agent-core permission system. Read this when touching `permission`, `permissionMode`, `permissionRules`, or when adding a new permission dimension. + +> **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata. +> +> **The chain adjudicates risk only.** A policy node answers "how dangerous is this call, and may the user override that judgment?" — its `ask`/`deny` outcomes are always user-overridable. **Harness constraints are not permissions**: a mechanism that limits the agent for its own correctness (plan-mode write guard, AgentDynamicWorkflow batch exclusivity, btw side-question fork, goal budget rejection) produces a hard deny with no ask channel and no per-call user exemption. Those live in their owning domains as `onBeforeExecuteTool` veto listeners that call `event.veto(...)` (precedent: `goalService.ts`'s budget/stale rejection). Product reviews (plan review, goal-start review) are likewise not permissions: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives the shared `IAgentToolApprovalService` round-trip itself, so the review only starts once no other listener vetoed the call. +> +> **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision". + +## 1. Problem definition + +The permission system answers one question: **for each tool call, in the current agent and current mode — allow / deny / ask the user?** Three traits shape the architecture: + +1. **Decisions carry behavior.** Returning `ask` is not an enum value — it is a workflow with an RPC round-trip, hooks, telemetry, state writes, and a continuation; returning `deny` may be the result of running an external hook. +2. **Heterogeneous policies.** Some check a tool-name set, some count same-batch `AgentDynamicWorkflow` calls, some run a hook, some inspect the plan state machine — no uniform `(sub, obj, act)` shape. +3. **Multi-agent × multi-mode × external extension.** Different agents / modes need different permissions, and outsiders (org admins, plugins) must contribute rules or behavior in a decoupled way. + +## 2. Current state (v1) at a glance + +Code lives in `packages/agent-core/src/agent/permission/`. + +- **Architecture: ordered chain of responsibility, first hit wins.** `PermissionManager` holds `PermissionPolicy[]`; evaluation iterates in order, the first non-`undefined` result wins. +- **`PermissionPolicyResult` is a behavior bundle, not a scalar:** `approve` (with `executionMetadata`), `deny` (with `message`), or `ask` (with `resolveApproval` / `resolveError` continuations). +- **11 dimensions, 19 policies**, hardcoded in `policies/index.ts#createPermissionDecisionPolicies()`. Order is a high-to-low safety cascade: external force → structural deny → state-machine deny → static deny → mode allow → session-memory allow → static ask → static allow → flow allow → sensitive-path ask → default allow → fallback ask. +- **Resource-access declaration:** tools declare accessed resources in `resolveExecution(input)` via `accesses` (`ToolAccesses`, currently `file` and `all`); generic dimensions read `context.execution.accesses`. + +### v1 pain points the target design fixes + +1. The chain is hardcoded — outsiders cannot contribute. +2. `mode` is an `if` inside each policy (`YoloModeApprove` / `AutoModeApprove` self-guard). +3. No per-agent chain entry point (only scattered `agent.type === 'sub'` checks). +4. No external extension point beyond the single `PreToolUse` hook slot. + +## 3. Why not Casbin + +- **`policy_effect` is unusable** — composition here is a fixed, intentionally hardcoded safety cascade; the real complexity lives in each policy's `evaluate` behavior, which a Casbin expression cannot absorb. Externally tunable safety knobs are already exposed via `mode` + allow/deny/ask rules. +- **Flexible priority is unusable** — there is no plugin injection point, no multi-subject/RBAC, and a fixed subject (agent/user), so priority collisions do not arise. Casbin's `(sub, obj, act)`, `g()`, and domains would idle. +- **Fundamental mismatch: decisions are not scalars.** `enforce()` maps a request to an effect; agent-core decisions are behavior bundles (continuations, side effects, synthesized results). Even if Casbin computed `ask`, the surrounding behavior would still need to be rewritten — Casbin would degrade to an enum generator. +- **When Casbin becomes worth it:** when the hard part is matching semantics itself — role inheritance, domain isolation, ABAC expressions, policies loaded from a DB. Not before. + +## 4. Design-pattern placement + +Permission orchestration is a layered combination, not a single pattern: + +| Layer | Pattern | Role | +|---|---|---| +| Runtime decision | **Chain of Responsibility** | multiple candidates in order; first hit wins, rest short-circuit | +| Single handler | **Strategy** | each policy is an interchangeable "permission adjudication" algorithm | +| Assembly / external extension | **Plugin / Microkernel** | minimal kernel + explicit extension points + pluggable policies | +| Landing support | **Registry + Factory** | collect plugins; assemble the chain per `(agent, mode)` on demand | + +Casbin = single Strategy + data-driven. This design = multiple Strategies + chain-of-responsibility composition. Behavior-heavy systems must choose the latter — behavior cannot be flattened into data rows. + +## 5. Target design + +### 5.1 Core principles + +1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node. +2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies). +3. **Guard/review off-chain, risk on-chain:** harness constraints and product reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners (§5.4); risk dimensions contributed by a domain self-register as chain policies in DI, mirroring v2's "domain self-registers tools". +4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally. + +### 5.2 Core abstractions + +```ts +type Phase = + | 'guard' | 'user-deny' | 'mode' | 'session' + | 'user-ask' | 'default' | 'fallback'; + +interface PermissionPolicyEntry { + name: string; + phase: Phase; + modes?: PermissionMode[]; // declare which modes this applies in (no more in-evaluate if) + agentTypes?: AgentType[]; + factory: (accessor: ServicesAccessor) => PermissionPolicy; +} + +// App scope — collects every domain's registration +interface IPermissionPolicyRegistry { + register(entry: PermissionPolicyEntry): IDisposable; + list(): readonly PermissionPolicyEntry[]; +} +``` + +`PermissionPolicyService` (Agent scope) changes from a hardcoded list to "assemble by `(agent, mode)`": + +```ts +this.policies = registry.list() + .filter(e => !e.modes || e.modes.includes(mode)) + .filter(e => !e.agentTypes || e.agentTypes.includes(agentType)) + .sort(byPhaseThenRegistrationOrder) + .map(e => e.factory(accessor)); +``` + +Key points: + +- `modes` / `agentTypes` are **declarations** — they lift the `if (mode !== 'yolo') return` out of `YoloModeApprove` into metadata. +- `factory`, not `instance`: a node may depend on agent-scoped services (mode, rules) and must be instantiated in the Agent scope — symmetric to `IToolDefinitionRegistry` (App) storing factories and `IToolService` (Agent) instantiating tools. +- **Different `(agent, mode)` produce differently-shaped chains** — under yolo the ask/fallback phases are physically filtered out. + +### 5.3 Two contribution paths + +| What is being added | Path | Chain length | +|---|---|---| +| New tool, new org rule, new user preference ("deny `Bash(curl *)`") | **Data path**: add a `PermissionRule` to an existing node | unchanged | +| New cross-cutting behavior (custom approval UI, audit log, new mode) | **Code path**: register a new policy node | +1 | + +Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob). + +### 5.4 Domain dimensions: guard/review via the executor veto event, policy registration for risk + +**Harness constraints and product reviews no longer live on the chain.** A domain that owns one registers an `onBeforeExecuteTool` veto listener and adjudicates through the event: + +```ts +// src/plan/planService.ts — constructor +constructor(@IAgentToolExecutorService executor, ...) { + executor.onBeforeExecuteTool((event) => this.guardToolExecution(event)); +} +``` + +- The veto event carries no id and no ordering contract. Listeners answer with `event.veto(result)` (first one wins, ends adjudication), `event.allow()` (final pass, ends everything including the permission gate's own listener), `event.pass(metadata)` (pass with an `executionMetadata` trace, ends nothing), or `event.waitUntil(factory)` (defer to a cold factory). +- **Guard** (hard deny): call `event.veto(denyToolExecution(toolApproval.formatDenyMessage(...)))`. An immediate veto suppresses every pending `waitUntil` factory, so a deny can never be preceded by someone else's approval prompt. +- **Review** (product approval): intercept the tool with `event.waitUntil(() => ...requestToolApproval(event, ask, origin))`. The factory is cold — the executor only invokes it after every listener ran without a veto or an allow, so the review's Interaction starts only once the call is otherwise clear to proceed; abstain (no statement) for every case you do not review so user rules still apply. +- **Plain allow**: do NOT `allow()` casually — prefer putting the tool in `default-tool-approve`'s whitelist so user deny/ask rules keep their precedence; reserve `allow()` for cases like the plan-file write guard that must bypass even the permission chain. + +**Risk dimensions contributed by a domain still go through the chain** (the registry path below): a domain whose state changes the *risk* verdict registers its policy via `IPermissionPolicyRegistry`, mirroring v2's "domain self-registers tools". A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. + +### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`) + +In `resolveExecution(input)`, before execution, declare accessed resources with the `ToolAccesses.*` builders: + +```ts +resolveExecution(args: WriteInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { kaos, workspace, operation: 'write' }); + return { + accesses: ToolAccesses.writeFile(path), // declares: write this file + approvalRule: literalRulePattern(this.name, path), + matchesRule: (ruleArgs) => matchesPathRuleSubject(ruleArgs, path, ...), + execute: () => this.execution(args, path), + }; +} +``` + +Current resource types: + +```ts +type ToolResourceAccess = + | { kind: 'file'; operation: 'read'|'write'|'readwrite'|'search'; path: string; recursive?: boolean } + | { kind: 'all' }; // non-enumerable side effects (pessimistic, globally exclusive) +``` + +Two complementary channels: + +- **Enumerable resources** (write/read/edit/grep/glob) → use `accesses`; generic file dimensions cover them automatically. +- **Non-enumerable resources** (bash running arbitrary commands) → do not declare `accesses`; use the `matchesRule` DSL (e.g. `Bash(rm *)` globs by command string). + +**kaos's role:** kaos is the execution-environment abstraction (fs/process/pathClass) used by the file dimension for path normalization and judgment — it is **not** the permission-dimension abstraction itself. Permission semantics live one layer above kaos, at "file access". + +**v2 evolution:** extend the `ToolResourceAccess` union so non-file resources can be declared structurally: + +```ts +type ToolResourceAccess = + | { kind: 'file'; operation: FileOp; path: string; recursive?: boolean } + | { kind: 'network'; operation: 'connect'; host: string } + | { kind: 'shell'; command: string } + | { kind: 'datastore'; operation: 'read'|'write'; table: string } + | { kind: 'all' }; +``` + +Each new resource kind can pair with a generic dimension that consumes it; tools always only **declare**. + +### 5.6 Dimension ownership + +| Dimension | Owner | Type | +|---|---|---| +| external hook veto | `externalHooks` domain | generic | +| tool-batch exclusivity | `dynamic_workflow` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan-mode write guard | `plan` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| plan review | `plan` domain — same listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal-start review | `goal` domain — veto listener's `waitUntil` + `toolApproval` | product review (off-chain) | +| goal budget / stale rejection | `goal` domain — `onBeforeExecuteTool` veto listener | harness constraint (off-chain) | +| btw tool disablement | `btw` domain — veto listener on the fork | harness constraint (off-chain) | +| runtime-mode posture (auto/yolo) | `permissionMode` domain (chain nodes, pending the level×routing split) | generic | +| static config rules | `permissionRules` domain | generic (data path) | +| session approval memory | `permissionRules` domain | generic | +| sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) | +| tool intrinsic risk | core permission (`default-tool-approve`) | generic (consumes tool declarations) | +| workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) | +| fallback | core permission | generic | +| approval round-trip | `toolApproval` domain — shared by gate asks and domain reviews | infrastructure | + +Pattern: **harness constraints and reviews ship with their owning domain as `onBeforeExecuteTool` veto listeners; risk dimensions ship as chain policies (self-registered once the registry lands); generic dimensions register centrally and apply across tools via the declared `accesses`.** + +## 6. Evolution path + +Incremental, not big-bang: + +1. ~~**Sink domain dimensions.**~~ **Done** — plan guard/review, goal-start review, dynamic_workflow batch exclusivity, and btw deny-all moved out of the chain into their owning domains as `onBeforeExecuteTool` veto listeners (immediate `veto` / `allow` / `pass` statements plus cold `waitUntil` factories for approval round-trips); the shared approval round-trip was extracted to `IAgentToolApprovalService`; `registerPolicy` was removed (btw was its only production user). The chain now holds 12 risk-adjudication nodes only. +2. **Level × routing split.** Separate "risk level" (read-only / read-write / yolo posture — what `yolo-mode-approve` really is) from "interaction routing" (what `auto-mode-approve` / `auto-mode-ask-user-question-deny` really are: route permission asks and reviews without the user). The routing layer lands on the `session/approval` broker; the three remaining mode policies leave the chain here. +3. **Registry + Composer.** Replace the hardcoded `new`s in `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; lift mode guards into `modes` metadata. Chain shape becomes selectable per `(agent, mode)` and externally extensible. +4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union. +5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before. + +## Red lines (this topic) + +- Do not introduce Casbin — decisions are behavior bundles, not scalar effects. +- The chain adjudicates risk only. A node whose deny/ask the user cannot per-call exempt is a harness constraint: implement it as an `onBeforeExecuteTool` veto listener in the owning domain (`event.veto(...)` / `event.allow()`), never as a chain policy. +- Product reviews (plan/goal) are not permissions either: the owning domain intercepts its tool with a cold `event.waitUntil(factory)` and drives `IAgentToolApprovalService` itself; the gate only handles chain asks. +- The chain encodes dimensions, not tools: a new tool must not lengthen the chain. +- New specifics go through the data path (rules); only new risk behavior goes through the code path (a policy node). +- Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction. +- Use `factory` (Agent-scope instantiation), not `instance`, for registered policies. diff --git a/.agents/skills/agent-core-dev/persistence.md b/.agents/skills/agent-core-dev/persistence.md new file mode 100644 index 00000000..c62555a3 --- /dev/null +++ b/.agents/skills/agent-core-dev/persistence.md @@ -0,0 +1,204 @@ +# Topic — Persistence layering + +How business code persists data in `agent-core-v2`: the three-layer model (`Store → Storage → backend`), the naming rules for each layer, and how to decide which layer a domain should depend on. Read this before adding any persistence to a domain. + +A domain `I{Domain}EntityService` is a business facade over these layers, not a replacement for them. Before naming or bundling EntityServices by `session` / `agent` / `turn`, read [domain-boundaries.md](domain-boundaries.md). + +## The three-layer model + +Persistence is split into three layers, each hiding one kind of change: + +```text +Business Service + │ inject + ▼ +┌────────────────────────────────────────┐ +│ Store (semantic layer) │ ← access-pattern facade +│ IAppendLogStore / IAtomicDocumentStore│ append-log / atomic-doc / blob +└────────────────────────────────────────┘ + │ inject + ▼ +┌────────────────────────────────────────┐ +│ Storage (byte layer) │ ← byte primitives +│ IFileSystemStorageService │ read/write/append/list/delete +└────────────────────────────────────────┘ + │ implements + ▼ +┌────────────────────────────────────────┐ +│ Backend (deployment-specific) │ ← File / Postgres / Redis / S3 +│ FileStorageService / PostgresStorage │ +└────────────────────────────────────────┘ + │ uses + ▼ +┌────────────────────────────────────────┐ +│ Platform primitives │ ← hostFs / dbClient / redisClient +└────────────────────────────────────────┘ +``` + +Each layer hides exactly one concern: + +| Layer | Hides | Business code sees | +|---|---|---| +| **Store** | how an access pattern works (append-log reads, atomic-doc serialization) | "append this record" / "save this document" | +| **Storage** | byte primitives (atomic write, ordered append, prefix list) | `read/write/append/list/delete` over `(scope, key)` | +| **Backend** | deployment environment (file vs DB vs Redis vs S3) | nothing — chosen at the composition root | + +## The one-sentence rule + +> **Business code expresses *what* to store or fetch, never *how* to store it.** + +If business code contains any "how to persist" detail, it has punched through the layer it should depend on: + +| Business code contains | It has punched through | Depend on instead | +|---|---|---| +| `INSERT INTO …` / `SELECT …` | Storage + backend | a Store | +| file paths / `rename` / `fsync` | Storage | Storage or a Store | +| `JSON.parse` / `JSON.stringify` | Store (serialization) | `IAtomicDocumentStore` | +| append offsets / sequential cursors | Store (log semantics) | `IAppendLogStore` | +| `hash(data)` used as a key | Store (blob semantics) | `IBlobStore` | +| `pathe.join / relative / basename` on `homeDir` etc. | Bootstrap (path layout) | `IBootstrapService.scope(...)` / scope contexts | +| only `read/write/list/delete` on bytes | nothing — this is the byte layer | `IFileSystemStorageService` directly ✅ | + +## Where scopes come from — `IBootstrapService` and scope contexts + +Business code **never assembles scope strings from paths**. Scope strings come from three places: + +1. **`IBootstrapService.scope(name)`** — well-known top-level scopes (`'config' | 'sessions' | 'blobs' | 'store' | 'logs' | 'cache' | 'credentials'`). App-scope, deployment-agnostic contract. +2. **`ISessionContext.scope(subKey?)`** — persistence scope rooted at the current session; `scope('agents/main')` etc. +3. **`IAgentScopeContext.scope(subKey?)`** — persistence scope rooted at the current agent; `scope('cron')`, `scope('blobs')` etc. + +The bootstrap layer decides how each semantic scope maps to concrete addressing. In the file deployment, `FileBootstrapService` reads a `ResolvedEnvironment` (the paths bag) and returns homeDir-relative scopes; a server deployment could bind a different `IBootstrapService` implementation that maps `'sessions'` to a DB table without any business change. + +```ts +// ❌ Wrong — path arithmetic on homeDir/sessionDir leaks the file layout +const scope = relative(bootstrap.homeDir, join(session.sessionDir, 'agents', agentId, 'cron')); + +// ✅ Right — the agent already knows its own scope root +const scope = agentCtx.scope('cron'); +``` + +Absolute paths (`sessionDir`, `agentHomedir`) are still available on `IBootstrapService` for the very small number of legacy APIs that expose on-disk paths (session log rotation, background task tail file). Prefer scope strings; ask before adding a new absolute-path caller. + +## Which layer to depend on — decision tree + +```text +Need to persist + │ + ├─ read-whole / write-whole, JSON-serializable? + │ └─ IAtomicDocumentStore + │ + ├─ append-only writes / sequential reads, independent records? + │ └─ IAppendLogStore + │ + ├─ large object, addressed by content hash? + │ └─ IBlobStore + │ + ├─ custom byte layout (index / cache / binary) that read/write/list cover? + │ └─ IFileSystemStorageService directly + │ + ├─ new, reusable access semantics (multi-field query / time-range / graph)? + │ └─ add a new Store; business depends on the Store + │ + └─ business-specific, trivial, one or two lines? + └─ IFileSystemStorageService directly; if it grows, extract a private Store +``` + +## Naming — Store by access pattern, not by business + +A Store abstracts an **access pattern**, not a business data type. Name it after the pattern so its reusability is obvious from the name. + +| Access pattern | Store name | Backend examples | +|---|---|---| +| append-log (append / sequential read) | `IAppendLogStore` | `FileAppendLogStore` / `PostgresAppendLogStore` | +| atomic-document (read/write whole) | `IAtomicDocumentStore` | `FileDocumentStore` / `RedisDocumentStore` | +| blob (hash-addressed large object) | `IBlobStore` | `FileBlobStore` / `S3BlobStore` | + +**Do not name a generic Store after a business concept.** `IRecordStore` / `IConfigStore` make a reusable access pattern look like a private store for one feature. Any domain that needs an append-log uses `IAppendLogStore`; any domain that needs an atomic document uses `IAtomicDocumentStore`. + +**Exception — business-specific Stores are named after the business.** When a Store captures one domain's unique query semantics (not a generic access pattern), name it after the domain: + +```text +ISessionIndex query / enumerate sessions by workspace ← business-specific +``` + +Test: is the Store's semantics a *generic access pattern* (append-log / atomic-doc / blob) or *one domain's unique query*? Generic → name by pattern; unique → name by domain. + +## Storage — a filesystem-specific byte layer + +The byte layer is a single `IFileSystemStorageService` interface (read / readStream / write / append / list / delete / watch / flush / close). As the name says, it is **filesystem-specific**: it exposes the two irreducible durable primitives a local filesystem implements optimally — atomic whole-value replacement (`write`, via tmp + rename) and ordered durable extension (`append`, via `open('a')`). The node-fs Store backends (`AppendLogStore`, `JsonAtomicDocumentStore`, `BlobStoreService`) are built on it. + +```ts +export interface IFileSystemStorageService { + read(scope: string, key: string): Promise; + readStream(scope: string, key: string): AsyncIterable; + write(scope: string, key: string, data: Uint8Array, options?: { atomic?: boolean }): Promise; + append(scope: string, key: string, data: Uint8Array, options?: { durable?: boolean }): Promise; + list(scope: string, prefix?: string): Promise; + delete(scope: string, key: string): Promise; + watch?(scope: string, key: string): Event; + flush(): Promise; + close(): Promise; +} +``` + +Two backends implement it today, both bound at the composition root: + +```ts +// Production — local filesystem rooted at homeDir +collection.set(IFileSystemStorageService, new FileStorageService(homeDir)); + +// Tests — in-memory backend seeded by the test harness +collection.set(IFileSystemStorageService, new InMemoryStorageService()); +``` + +**Non-filesystem backends (Postgres, S3, Redis) do not implement this interface.** Atomic-rename and byte-append have no native equivalent in those stores, so they implement the **Store** interfaces directly via their own clients instead: + +```ts +// Server profile — append-logs on Postgres, atomic documents on Redis. +// Each Store is backed by a native client; IFileSystemStorageService is not involved. +collection.set(IAppendLogStore, new PostgresAppendLogStore(db, 'records')); +collection.set(IAtomicDocumentStore, new RedisDocumentStore(redis, 'config')); +``` + +Use the `scope` parameter to express **business namespace** within a backend. Do not overload `scope` to route backends — bind a different Store implementation at the composition root instead. + +## Store `acquire(scope, key)` — flush-on-dispose handle + +Stores that buffer writes expose an `acquire(scope, key)` handle so a business can flush them on disposal: + +```ts +export interface IAppendLogStore { + // … + /** + * Acquire a disposable handle for `(scope, key)`. Register it with your + * `Disposable` (via `this._register(...)`); when you are disposed, pending + * appends for that log are flushed. The shared store itself is not disposed. + */ + acquire(scope: string, key: string): IDisposable; +} +``` + +`IAppendLogStore.acquire` flushes the log's pending appends on dispose — it exists because `append` is fire-and-forget. `IAtomicDocumentStore.acquire` is a no-op today (atomic documents are durable on write) and exists for interface symmetry. Businesses that do not need flush-on-dispose simply do not call `acquire`. + +## When the byte layer does not apply + +`IFileSystemStorageService` covers only the local-filesystem byte primitives. It is not a universal storage abstraction: + +- **Non-filesystem backends** (Postgres / S3 / Redis) implement the **Store** interfaces directly via native clients — they never implement `IFileSystemStorageService`. +- **Blobs** are a Store-level interface (`IBlobStore`) with their own backends; the node-fs `BlobStoreService` sits on `IFileSystemStorageService`, but an `S3BlobStore` would not. +- **A backend has a fast primitive the Store interface cannot express** (e.g. Postgres `COPY`) → as an exception, extend that backend's Store implementation directly. This is an exception, not the default. + +## Platform primitives are deployment-coupled, not core abstractions + +`hostFs` (local filesystem) is a **platform primitive** used only by local backends (`FileStorageService`, `LocalFileSystemBackend`, `LocalSkillCatalog`, `HostFolderBrowser`). It is **not** a core abstraction and must not appear in business-domain dependency graphs. A server deployment swaps those backends for DB / S3 implementations and never registers `hostFs`. + +## Red lines (this topic) + +- Business code never contains "how to persist" details (serialization / paths / SQL / append offsets) — if it does, drop a layer. +- Business code never assembles scope strings from paths (`pathe.join / relative / basename` on `homeDir` / `sessionDir` / …). Use `IBootstrapService.scope(name)` for well-known scopes, `ISessionContext.scope(subKey?)` for session-rooted scopes, and `IAgentScopeContext.scope(subKey?)` for agent-rooted scopes. +- Name generic Stores by access pattern (`IAppendLogStore` / `IAtomicDocumentStore` / `IBlobStore`), never by business concept (`IRecordStore` / `IConfigStore`). +- Business-specific Stores (unique query semantics) are named after the domain (`ISessionIndex`). +- `IFileSystemStorageService` is the filesystem byte-layer interface; non-filesystem backends implement the **Store** interfaces directly. Route backends by binding a different Store implementation at the composition root, not by overloading `scope`. +- `hostFs` is a local-only platform primitive; business domains must not import `node:fs` or `hostFs` directly. +- Only the file-backed bootstrap (`FileBootstrapService`) and file backends import `pathe`; business domains do not. +- Do not create a pass-through `Store` that only forwards `read/write` — a Store must hide a real access-pattern concern, or it is noise; use `IFileSystemStorageService` directly instead. diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md new file mode 100644 index 00000000..ba61a49a --- /dev/null +++ b/.agents/skills/agent-core-dev/server-align.md @@ -0,0 +1,253 @@ +# Subskill — Server align (expose `agent-core-v2` over `server-v2`) + +Wire a v2 domain into `packages/kap-server`, and — when the endpoint is part of the established `/api/v1` wire contract — keep the wire shape **byte-for-byte compatible** with what released v1 clients expect. This is the server-side counterpart of [align.md](align.md): `align.md` ports v1 *business logic* into v2; this file exposes the v2 result over HTTP / WS, reusing the v1 wire contract where it already exists. + +Use this when the task is "expose the new v2 Service on the server", "add a `/sessions/:sid/...` route to the `/api/v1` surface", or "keep server-v2 speaking the same `/api/v1` contract released clients rely on". + +## The one-paragraph mental model + +`server-v2` serves **two HTTP surfaces** off the same `agent-core-v2` scope tree: + +- **`/api/v2/:sa`** — the native v2 RPC surface, driven by the `actionMap` allowlist (`packages/kap-server/src/transport/actionMap.ts`). One `resource:action` segment maps to one `Service.method`. New v2-native capabilities land here. See [edge-exposure.md](edge-exposure.md). +- **`/api/v1/...`** — the v1-compatible surface, hand-written routes in `packages/kap-server/src/routes/*.ts` that **implement the established v1 wire contract path-for-path and schema-for-schema**, mounted by `registerApiV1Routes.ts`. This surface IS the v1 contract now (the legacy v1 server is gone); it exists so existing v1 clients keep working against server-v2 unchanged. + +The two surfaces can point at **different Services** for the same feature. v2's native `IAgentPromptService` serves `/api/v2`; a v1-shaped `IAgentPromptService` serves `/api/v1`. Keeping them separate is what lets v2's domain design stay clean while the wire stays compatible. + +## Decision: which surface? + +```text +Is the endpoint part of the established /api/v1 wire contract (protocol schema ++ released-client expectation)? +├─ YES → /api/v1 mirror route (this file, §schema-fidelity + §legacy-service). +│ Reuse the protocol schema; add a LegacyService if v2 semantics diverge. +└─ NO → /api/v2 native action (edge-exposure.md). + Add to actionMap, wrapping in a facade if the method fails §2 there. +``` + +A feature often needs **both**: the v1 mirror so old clients keep working, and the v2 action so new clients get the cleaner shape. Do them as two routes / two action-map entries over the same scope tree. + +## The server-align workflow + +```text +Pick surface → Read the v1 route (if any) → Reuse / add the protocol schema +→ Choose native Service vs LegacyService → Wire the route / actionMap entry +→ Map errors → Test against the v1 wire shape → Verify +``` + +### 1. Pick the surface + +Apply the decision above. For a v1-matched endpoint, the **spec** is the protocol schema plus the existing mirror routes: + +- `packages/kap-server/src/protocol/rest-.ts` — the wire schema you must match. +- `packages/kap-server/src/routes/.ts` — the file you are writing (create it if missing); sibling route files show the conventions. + +The protocol schema is the source of truth. Do not re-derive the wire shape from memory or from the v2 domain model. + +### 2. Reuse (or add) the protocol schema + +The wire schema lives in **`packages/kap-server/src/protocol`** under `rest-.ts` (e.g. `promptSubmissionSchema`, `promptListResponseSchema`, `configResponseSchema`) — or in the owning `agent-core-v2` domain contract when the engine's service speaks the shape. Every `/api/v1` route in `packages/kap-server` imports from it — that single import is what guarantees the server speaks the same shape released clients expect. + +Actions: + +- **Schema already in protocol** → import it in the server-v2 route and use it in `defineRoute` (`body`, `success.data`, error `dataSchema` / `detailsSchema`). Do **not** re-declare the schema inline in server-v2. +- **Schema missing** → add it to `packages/kap-server/src/protocol/rest-.ts` first (or to the owning v2 domain contract if its service speaks the shape), then consume it from the route. The shared schema is the source of truth; server-v2 never re-declares a v1 wire schema inline. +- **Schema exists but only v1 uses it** → keep it in `packages/kap-server/src/protocol` and import it into server-v2; do not fork a copy. + +#### Schema-fidelity rule (the hard rule) + +For a `/api/v1` endpoint, the request and response schemas **must be the established protocol schema** (or a strict superset): + +- ✅ **Adding** an optional field is allowed (`field: z.string().optional()`). Old clients ignore it; new clients may send it. +- ❌ **Renaming** a field, **changing** its type, **tightening** its validation, or **changing its meaning** is a wire break — do not do it in a mirror route. If the v2 domain genuinely needs a different shape, that shape belongs on `/api/v2`, not on the `/api/v1` mirror. +- ❌ Re-declaring the schema inline in server-v2 (even if it "looks identical") is forbidden — it drifts. One schema, one home: the owning `agent-core-v2` domain contract or `packages/kap-server/src/protocol`. + +Self-check: "would a released v1 client get a byte-identical envelope from `packages/kap-server` for this request?" If you cannot answer yes from the shared schema, the route is wrong. + +### 3. Choose native Service vs LegacyService + +Resolve the v2 Service that will back the route. Two cases: + +**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceService`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. + +**Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. + +Reach for a LegacyService when **any** hold: + +- The v1 endpoint carries state the v2 domain deliberately dropped (e.g. a FIFO queue, a `prompt_id`, idempotent `abort`/`steer`, auto-start-next). +- The v1 method returns a handle/stream that v2 wraps differently, and the v1 clients expect the old envelope shape. +- Matching v1 would force a `Map`-at-`App` anti-pattern or a scope/domain-direction violation into the native Service (see [align.md](align.md) red lines). +- The native Service's error set / return type would have to grow v1-only branches. + +Do **not** put v1 quirks into the native v2 Service "to keep the route simple". That is the conflict this rule exists to prevent: the native Service serves the v2 architecture; the LegacyService serves the wire contract. + +#### LegacyService recipe + +A LegacyService is a normal v2 Service (service-authoring.md) with one extra convention: its contract is shaped by the **protocol** types, not by the v2 domain model. + +```text +packages/agent-core-v2/src/Legacy/ +├── Legacy.ts ← contract: protocol-typed interface + decorator +├── LegacyService.ts ← impl: delegates to the native v2 Service(s) +└── errors.ts ← v1-compatible error codes (PythinkerError codes) +``` + +Skeleton (matches `prompt/`): + +```ts +// prompt.ts — contract shaped by the v1 wire schema (kap-server/src/protocol) +import type { PromptSubmitResult, PromptSubmission } from '../../protocol/rest-prompt'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentPromptService { + readonly _serviceBrand: undefined; + submit(body: PromptSubmission): Promise; + // ...the rest of the v1 contract, typed by protocol +} +export const IAgentPromptService: ServiceIdentifier = + createDecorator('agentPromptLegacyService'); +``` + +```ts +// promptService.ts — impl delegates to the native v2 Service +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +constructor(@IAgentPromptService private readonly prompt: IAgentPromptService /*, ... */) {} +// submit() builds v2-native input, calls the native Service, projects the result +// back into the protocol PromptSubmitResult. + +registerScopedService( + LifecycleScope.Agent, // scope = the lifetime of the legacy state + IAgentPromptService, + AgentPromptLegacyService, + ScopeActivation.OnDemand, + 'prompt', +); +``` + +Conventions: + +- **Name** the domain `Legacy` and the interface with the scope prefix, `ILegacyService` (e.g. `prompt` / `IAgentPromptService`), per service-authoring.md. +- **Role is carried by the name** — `Legacy` marks it as an `edge adapter`; the v1 contract it implements and the native v2 Service it leaves untouched stay evident from its delegation targets (see `prompt.ts`). +- **Scope** = the lifetime of the *legacy* state it holds (the `prompt` queue is per-agent → `LifecycleScope.Agent`). Apply [orient.md](orient.md) / [design.md](design.md) normally — a LegacyService is not exempt from scope rules. +- **Delegate, do not duplicate** business logic. The LegacyService translates the v1 contract into native-Service calls and translates results back; the real work stays in the native Service. +- **Contract types come from the v1 wire schema homes** (the owning v2 domain contract or `kap-server/src/protocol`), so the interface cannot drift from the wire shape. + +### 4. Wire the route / actionMap entry + +**For `/api/v1` (mirror):** add a route file under `packages/kap-server/src/routes/.ts` using `defineRoute`, then register it in `registerApiV1Routes.ts`. Resolve the scope from the URL (`session_id` → Session scope, agent → Agent scope via `IAgentLifecycleService.getHandle`), then `accessor.get(IX)` the native or Legacy Service. Match the established verbs, paths (`:sid` / `{session_id}`), and `parseActionSuffix` actions (`:steer`, `:abort`) exactly — sibling routes under `packages/kap-server/src/routes/` are the reference. + +```ts +const route = defineRoute( + { + method: 'POST', + path: '/sessions/{session_id}/prompts', + body: promptSubmissionSchema, // ← from kap-server/src/protocol + params: sessionIdParamSchema, + success: { data: promptSubmitResultSchema }, // ← from kap-server/src/protocol + errors: { + [ErrorCode.SESSION_NOT_FOUND]: {}, + [ErrorCode.SESSION_BUSY]: {}, + [ErrorCode.PROMPT_ALREADY_COMPLETED]: { dataSchema: z.object({ aborted: z.literal(false) }) }, + }, + operationId: 'submitPrompt', + tags: ['prompts'], + }, + async (req, reply) => { + try { + const result = await resolveLegacy(core, req.params.session_id).submit(req.body); + reply.send(okEnvelope(result, req.id)); + } catch (error) { + sendMappedError(reply, req.id, error); + } + }, +); +app.post(route.path, route.options, route.handler); +``` + +**For `/api/v2` (native):** add a `resource:action` entry to `actionMap` ([edge-exposure.md](edge-exposure.md) §3). If the method fails the direct-exposure rules (returns a handle / stream / bytes, takes a live object), add a wire-safe orchestration method to the owning domain Service first — as `prompts:submit` maps to `IAgentPromptService.submit`, which settles `{turn_id}` engine-side instead of returning the live `PromptHandle`. + +### 5. Map errors + +The route translates domain `PythinkerError` codes into protocol `ErrorCode` numbers. Two registries must stay in sync: + +- **Domain code** — register in `agent-core-v2/src/errors.ts` (`ErrorCodes`) and throw from the Service (errors.md). Co-located domain errors go in `Legacy/errors.ts` (e.g. `prompt.not_found`, `session.busy`). +- **Wire code** — register the matching number in `packages/kap-server/src/protocol/error-codes.ts` and reference it in the route's `errors` map and `sendMappedError`. + +```ts +function sendMappedError(reply, requestId, err) { + if (isPythinkerError(err)) { + switch (err.code) { + case 'session.not_found': + case 'agent.not_found': + return reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId)); + case 'prompt.not_found': + return reply.send(errEnvelope(ErrorCode.PROMPT_NOT_FOUND, err.message, requestId)); + // ... + } + } + return reply.send(errEnvelope(ErrorCode.INTERNAL_ERROR, String(err), requestId)); +} +``` + +Match the v1 route's status codes and idempotent-conflict envelopes (e.g. `prompt.already_completed` → `40903` with `{ data: { aborted: false } }`). The error envelope is part of the wire contract — it is covered by the same schema-fidelity rule. + +### 6. Test against the v1 wire shape + +Add a `packages/kap-server/test/.test.ts` that boots the server and hits the route. Assert on the **envelope + protocol shape**, not on the v2 domain internals: + +- success envelope `{ code: 0, data: , request_id }`; +- each declared error envelope `{ code: , msg, data, request_id }`; +- the fields v1 clients read are present with the same names/types. + +Where the route mirrors v1, the test is the regression guard for the schema-fidelity rule: if someone drifts the protocol schema or the projection, this test breaks. + +### 7. Verify + +- `pnpm -C packages/kap-server test` — server routes green. +- `pnpm -C packages/kap-server test` — server routes green (incl. any wire-schema guards). +- `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. +- `pnpm -C packages/agent-core-v2 run lint:imports` — the import boundaries (v1 ban, kosong subtree) still hold for a LegacyService. +- `pnpm -C packages/klient test` (optionally with `PYTHINKER_SERVER_URL` for the live legacy suites) when a v1 parity scenario exists. + +## Worked example — porting v1 `/sessions/:sid/prompts` + +This is the reference alignment (commits `feat(server-v2): port v1 /sessions/:sid/prompts routes`, `feat(server-v2): return turn ids for prompt actions`). It shows all three decisions at once. + +**The mismatch.** v1 `IPromptService` is a per-agent *scheduler*: it owns a FIFO queue, assigns `prompt_id`s, supports `steer`/`abort`, and auto-starts the next queued prompt when a turn settles. v2's native `IAgentPromptService` is a *turn driver*: a submission *is* a turn, there is no queue and no `prompt_id`. Forcing the queue into the v2 native Service would distort the v2 domain. + +**The split.** + +- `/api/v2` keeps the native shape — `prompts:submit` / `steer` / `undo` / `clear` / `cancel` map to the domain Services (`IAgentPromptService.submit` / `submitSteer`, `IAgentConversationUndoService.undo`, `IAgentLoopService.cancelFromUser`) in `actionMap`. +- `/api/v1` gets an `AgentPromptLegacyService` (`prompt/`, `LifecycleScope.Agent`) that re-implements the v1 scheduler — queue, `prompt_id`, steer/abort, auto-start-next — **on top of** the native `IAgentPromptService`. The `/api/v1` routes consume the LegacyService. + +**The schema.** Both surfaces import `promptSubmissionSchema` / `promptSubmitResultSchema` / `promptListResponseSchema` / `promptSteerRequestSchema` / `promptSteerResultSchema` / `promptAbortResponseSchema` from the shared v1 wire schemas (see `packages/kap-server/src/protocol`). The `/api/v1` and `/api/v2` routes are therefore compatible with released clients by construction; the LegacyService projects v2 turn results back into those protocol shapes. + +**The errors.** v1 codes (`prompt.not_found`, `session.busy`, `prompt.already_completed`) are registered in `agent-core-v2` (`prompt/errors.ts`) and in `packages/kap-server/src/protocol` (`error-codes.ts`), then mapped in the route's `sendMappedError` — including the idempotent `prompt.already_completed` → `40903 { data: { aborted: false } }`. + +**The lesson.** When the v1 contract and the v2 domain disagree, add an adapter (LegacyService) at the edge; do not let the wire contract leak into the native domain. The two surfaces share the protocol schema but not the Service. + +## Migration checklist + +Before submitting a server-align change: + +- [ ] Surface chosen deliberately: `/api/v1` mirror for a v1-matched endpoint, `/api/v2` for a new native capability (both if needed). +- [ ] For a `/api/v1` mirror, the route matches the established v1 contract (protocol schema + sibling routes) path-for-path, verb-for-verb, action-for-action. +- [ ] Request and response schemas come from their owning home (the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`); no inline re-declaration in server-v2. +- [ ] Existing schema fields are unchanged in name, type, and semantics; only optional fields added (if any). +- [ ] Native v2 Service left clean; v1-only behavior isolated in a `Legacy` / `ILegacyService` edge adapter when the semantics diverge. +- [ ] LegacyService registered with the correct `LifecycleScope` and named as the `Legacy` edge adapter preserving the native Service. +- [ ] Domain error codes registered in `agent-core-v2`; wire codes registered in `packages/kap-server/src/protocol`; route maps them in `sendMappedError`, matching v1's status codes and idempotent envelopes. +- [ ] Route resolves the scope from the URL by `accessor.get(IX)`; no cached scope; finishes before disposal. +- [ ] Tests assert the wire envelope + protocol shape; wire-shape guards added/updated where the route mirrors v1. +- [ ] `lint:imports` passes; the LegacyService did not invert scope direction. + +## Red lines (this subskill) + +- One wire schema, one home: the owning `agent-core-v2` domain contract or `packages/kap-server/src/protocol`. Never re-declare a v1 wire schema inline in server-v2. +- A `/api/v1` mirror route must keep every existing schema field's name, type, and semantics; only optional additions are allowed. A different shape belongs on `/api/v2`, not on the mirror. +- Do not distort the native v2 Service to satisfy a v1 quirk — add a `Legacy` edge adapter instead. The native Service serves the v2 architecture; the LegacyService serves the wire contract. +- A LegacyService is still a v2 Service: it follows scope, domain-direction, and DI rules. "Edge adapter" describes its role, not an exemption. +- The established wire schema (in its owning home — the `agent-core-v2` domain contract or `packages/kap-server/src/protocol`) plus the existing mirror routes are the spec for a `/api/v1` route — match them; do not re-derive the wire shape from the v2 domain model or from memory. +- Register every new error code in **both** `agent-core-v2` and `packages/kap-server/src/protocol/error-codes.ts`; an unmapped code is a wire break. +- Events stream over WS (`listen`), never over the REST mirror; do not invent REST polling for something v1 pushed as an event. diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md new file mode 100644 index 00000000..0648e17e --- /dev/null +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -0,0 +1,353 @@ +# Topic — Service authoring + +How to write a Service in `packages/agent-core-v2`: file layout, naming, what goes in the contract vs the impl, interface style, constructor / field conventions, events, multi-Service domains, and the comment rules. This is the day-to-day reference for stage 3 (implement.md covers the DI *mechanics*; this file covers the *authoring details*). + +## File layout + +One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMemory/`, `toolDedup/`. Inside, six kinds of files: + +```text +/ +├── .ts ← interface file: exactly one IXxx + its createDecorator + the types it owns +├── Service.ts ← impl file: exactly one class + exactly one registerScopedService(...) +├── .ts ← pure function(s): no Service suffix, no class, no registration +├── .ts ← contribution file (common): registers into another domain's extension point +├── .contrib.ts ← contribution file (uncommon / ad-hoc) +└── .types.ts ← shared types that no single interface owns +``` + +- **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `.ts` + `Service.ts` pair. +- **Scope is in the filename.** `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). +- A domain therefore has as many impl files as it has services (e.g. `logService.ts` for the App `ILogService`, `sessionLogService.ts` for the Session `ISessionLogService`). See [Multi-Service domains](#multi-service-domains). + +The package entry `src/index.ts` imports and `export *`s every domain's leaf files precisely (one line per leaf), so importing the package still runs every `registerScopedService(...)` side effect — exactly as the old per-domain barrels did. + +## Naming + +### Interfaces and classes + +| Artifact | Rule | Example | +|---|---|---| +| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Workspace` / `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `IWorkspaceDirs`, `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) | +| Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` | +| Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator('sessionLogService')` | +| Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` | + +The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Workspace, Session and Agent services always carry `Workspace` / `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names. + +> Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md). + +### File names + +File names derive from the interface / class names so that scope and role are visible in the tree: + +| File kind | Rule | Example (interface → file) | +|---|---|---| +| Interface file | interface name minus leading `I`, minus trailing `Service` if present; acronym-aware lowerCamelCase | `ISessionLogService` → `sessionLog.ts`; `IAppendLogStore` → `appendLogStore.ts`; `ILogService` → `log.ts` | +| Impl file | the class name; acronym-aware lowerCamelCase | `SessionLogService` → `sessionLogService.ts`; `AppendLogStoreService` → `appendLogStoreService.ts` | +| Pure-function file | the function / concern name; no `Service` suffix | `formatLogEntry.ts`, `levelEnabled.ts` | +| Contribution file (common) | the **target** domain name | `config.ts` (registers a config section), `tool.ts`, `flag.ts` | +| Contribution file (uncommon) | `.contrib.ts` | `slackWebhook.contrib.ts` | +| Shared-types file | `.types.ts` | `log.types.ts` | +| Errors file | `.errors.ts` | `appendLogStore.errors.ts` | + +Acronym-aware lowerCamelCase lowercases a leading acronym as a group: `ILLMRequester` → `llmRequester.ts`, `IWSGateway` → `wsGateway.ts`, `IOAuthToolkit` → `oauthToolkit.ts`, `IMcpServerService` → `mcpServerService.ts`. + +Because the impl class always ends in `Service` and the interface file never does, the two files of one service never collide — even for `Store` / `Registry` / `Resolver` interfaces (`IAppendLogStore` → `appendLogStore.ts` + `appendLogStoreService.ts`). + +## The contract file (`.ts`) + +Holds the public surface of the domain. A typical contract: + +```ts +/** + * `greet` domain (Ln) — one-line role. + * + * Defines the `Greeting` model and the `IGreeter` used by … Bound at … scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface Greeting { // model — no _serviceBrand + readonly message: string; +} + +export interface IGreeter { // injectable service — carries _serviceBrand + readonly _serviceBrand: undefined; + hello(): Greeting; +} + +export const IGreeter: ServiceIdentifier = + createDecorator('greeter'); +``` + +What belongs here: + +- **Model types** (`type` / `interface`) the domain exposes — `SessionMeta`, `LogEntry`, `ConfigSection`. +- **Service interface(s)** — the contract consumers depend on. +- **Decorator(s)** — one `createDecorator` per injectable service. +- **Helper types and pure functions** tightly bound to the contract — e.g. option bags, `satisfies`-checked seeds, predicate functions like `levelEnabled`. + +### Which interfaces carry `_serviceBrand` + +Only interfaces used as a **DI token** carry `readonly _serviceBrand: undefined`. Everything else does not: + +- ✅ Service interface resolved via `@IX` / `accessor.get(IX)` → carries `_serviceBrand`. +- ❌ Base interface extended by a service (e.g. `ILogger` extended by `ILogService`) → no `_serviceBrand`. +- ❌ Plain model / data interface (`LogEntry`, `SessionMeta`) → no `_serviceBrand`. + +```ts +export interface ILogger { // base interface — no brand + info(message: string): void; +} +export interface ILogService extends ILogger { // DI token — branded + readonly _serviceBrand: undefined; + setLevel(level: LogLevel): void; +} +``` + +## Interface style + +- **Sync methods** return a concrete type; **async methods** return `Promise`. Do not wrap a sync return in `Promise`. +- **Readonly fields** for immutable exposed state: `readonly ready: Promise`, `readonly modelAlias: string | undefined`. +- **Optional members** with `?`: `flush?(): Promise`, `close?(): Promise`. +- **Generics** where the caller supplies the shape: `get(domain: string): T`. +- **Extend** a base interface to share method groups: `interface ILogService extends ILogger`. +- **Events** as `readonly onDid…` / `onWill…` properties typed `Event` — see [Events](#events). + +```ts +export interface IConfigService { + readonly _serviceBrand: undefined; + readonly ready: Promise; + readonly onDidChange: Event; + get(domain: string): T; + set(domain: string, patch: unknown): Promise; + reload(): Promise; +} +``` + +## The impl file (`Service.ts`) + +Holds the concrete class(es) and the top-level registration. A typical impl: + +```ts +/** + * `greet` domain (Ln) — `IGreeter` implementation. + * + * … collaborators as roles ("logs through `log`") … Bound at App scope. + */ + +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/log'; + +import { type Greeting, IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; + + constructor(@ILogService private readonly log: ILogService) {} + + hello(): Greeting { + this.log.info('hello'); + return { message: 'hi' }; + } +} + +registerScopedService(LifecycleScope.App, IGreeter, Greeter, ScopeActivation.OnScopeCreated, 'greet'); +``` + +What belongs here: + +- **Imports** — `LifecycleScope` + `ScopeActivation` + `registerScopedService` from `'#/_base/di/scope'`; collaborators via the `#/` alias; the contract's types + decorator via a relative `./` import. +- **Class** — `XxxService implements IXxxService`, with `declare readonly _serviceBrand: undefined`. +- **Helper classes / functions** used only by this impl (e.g. a built-in writer, an `extractError` helper) — co-located in the same file. +- **Top-level `registerScopedService(...)`** — one per Service the file owns; importing the impl file runs the registration. + +Base class: extend `Service` (from `#/_base/di/service`) when the unit needs capability calls on `this` — `provide` / `effect` / `on` / `get` / `ref` (e.g. contributing a record to a `collection` token). `Service` extends `Disposable`, so `_register` keeps working; constructor-time `provide` / `on` / `effect` calls are buffered and flushed by the kernel after construction, while `get` / `ref` throw inside the constructor (dependencies stay constructor parameters). Otherwise extend `Disposable` — both are full DI units; a service whose own members collide with the `Service` vocabulary (`name` / `state` / `config` / `get`) must stay on `Disposable` (leave a NOTE comment saying so). + +## Constructor conventions + +- Declare every dependency with `@IX` on a constructor parameter. +- Use `private readonly` (or `protected readonly`) to store a used dependency as a field. +- For an injected dependency the class does **not** directly use (e.g. passed through, or only needed to force construction order), drop the visibility modifier and prefix with `_`: `@IEventService _event: IEventService`. +- Service parameters and static parameters may both appear; the ordering rule depends on how the object is created — see below. + +### Parameter order: scoped service vs `createInstance` + +- **`registerScopedService` services** — the container injects only the `@IX` parameters; any static parameters must have defaults and are left at their default when the container builds the instance. Order is therefore not enforced by the container, but the common style is **`@IX` parameters first, optional static parameters after**: + + ```ts + constructor( + @ILogWriterService protected readonly writer: ILogWriterService, + private readonly bound: LogContext = {}, + level: LogLevel = 'info', + ) {} + ``` + +- **`createInstance` objects** (non-singletons built with `instantiation.createInstance(Ctor, …staticArgs)`) — static parameters **must come first**, service parameters after, because the caller passes the static prefix positionally: + + ```ts + constructor( + private readonly input: string, // static — passed by caller + @ILogService private readonly log: ILogService, // service — injected + ) {} + ``` + +### Factory methods + +A scoped Service may expose a factory method that returns a **new** instance of itself (or a related class) with extra context bound — e.g. `ILogger.child(ctx)` returns `new LogService(this.writer, { …this.bound, …ctx }, this._level)`. This is not a DI violation: it is an explicit factory, not a request for the container to build a Service. Do not use it to circumvent scope or singleton semantics. + +## Fields and state + +- `private readonly` for fields set once at construction (injected deps, derived config). +- `private _name` (underscore prefix) for mutable private state: `private _level: LogLevel`. +- `readonly` public fields only for immutable exposed state; prefer a getter (`get level()`) when the value can change. +- Keep state minimal — a Service owns only the state that matches its scope's identity (design.md §2). Anything else belongs in a different Service. + +### Runtime state goes into the per-scope state container + +Workspace/Session/Agent-scope Services register their runtime state into the scope's state container (`IWorkspaceStateService` / `ISessionStateService` / `IAgentStateService`, all over `_base`'s `StateRegistry`) instead of holding it in bare instance fields, so per-scope state lives in one observable place (`snapshot()` / `onDidChange`) and dies with the scope. Reference: `session/interaction/interactionService.ts`. + +- Declare keys in the domain file and export them: `export const interactionPendingKey = defineState>('interaction.pending', () => new Map())` — `.` naming, factory initializers. +- Inject `@ISessionStateService private readonly states` (or the Agent token) and `this.states.register(key)` per key at the top of the constructor. +- Replace the field with accessors: a getter for collections only mutated in place (`this.foo.add(...)` keeps working — the container stores references, never clones); add a setter routed through `states.set` for reassigned scalars. Call sites stay unchanged. +- Values must be plain data: scalars, arrays, and literal objects/Maps/Sets built from them. Never register class instances, resource handles (disposables, abort controllers, Promise locks), or objects holding service references — the regression precedent: one registry key whose class instances reached the whole DI graph deep-copied to hundreds of MB on `snapshot()` and OOM-killed the server. This means registries whose entries carry resources (the tool registry, the task map, prompt queues) stay as instance fields alongside Emitters, hook slots, disposable slots, waiter arrays, caches, and queue instances. +- `snapshot()` additionally recurses plain data only: values with a custom prototype collapse to a `'(ClassName)'` marker — a `_base`-level backstop, not a license to register resource-bearing values. +- Durable, replayable state does NOT belong here — it stays on wire Models. The container is memory-only. + +## Events + +v2 has two distinct event mechanisms. Pick by audience: + +### `Event` / `Emitter` — typed property on a Service + +Use when a Service exposes a typed event its consumers subscribe to. Lives in `'#/_base/event'`. + +```ts +// contract +import type { Event } from '#/_base/event'; +export interface IConfigService { + readonly onDidChange: Event; +} + +// impl +import { Emitter, type Event } from '#/_base/event'; +export class ConfigService extends Disposable implements IConfigService { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + private notify(changed: ConfigChangedEvent): void { + this._onDidChange.fire(changed); + } +} +``` + +Conventions: + +- Back the public `Event` with a private `Emitter`, registered with `this._register(...)` so it disposes with the Service. +- Naming: `onDid…` for "happened" (past tense, after the fact); `onWill…` for "about to happen" (may allow `waitUntil` participation / veto — see `AsyncEmitter` / `IWaitUntil` in `'#/_base/event'`). +- A service must be constructed before consumers can subscribe to its events. Use the default `OnScopeCreated` activation when subscriptions must be available as soon as the scope is ready. + +### `IEventService` — global pub-sub bus + +Use to broadcast protocol events across domains. Lives in `'#/event'`. + +```ts +export interface IEventService { + readonly _serviceBrand: undefined; + publish(event: ProtocolEvent): void; + subscribe(handler: (event: ProtocolEvent) => void): IDisposable; +} +``` + +Inject `@IEventService` and `publish(...)`; `subscribe(...)` returns an `IDisposable` to register with `this._register(...)`. This is the bus for "a fact happened, react if you care" (design.md §4) — not for typed per-Service events. + +## Multi-Service domains + +A domain may define several Services. Each Service gets its own pair of files regardless of scope or coupling: + +- **One pair per Service** → `.ts` for the contract + `Service.ts` for the implementation. +- **Different scopes** → the scope prefix in the Service name makes this obvious (`logService.ts` for App `ILogService`, `sessionLogService.ts` for Session `ISessionLogService`). +- **Same interface, multiple role tokens** (e.g. `IAtomicDocumentStore` and `IAtomicTomlDocumentStore` share one interface type but are distinct DI tokens) → each token is its own Service identity and must be registered and resolved independently. + +There is no `index.ts` barrel: consumers import each contract/impl from its precise leaf path (e.g. `import { ILogService } from '#/log/log'`), never the domain directory. + +## No barrel — the package entry loads leafs precisely + +A domain has **no `index.ts` barrel**. Its files are the contract leaf (`.ts`) and the impl leaf (`Service.ts`), and consumers import the precise file — never the directory: + +```ts +import { IGreeter, type Greeting } from '#/greet/greet'; +``` + +Self-registration is unchanged: `greetService.ts` keeps its top-level `registerScopedService(...)`. The package entry `src/index.ts` loads the domain's leafs precisely — `export *` for the contract, a side-effect `import` for the impl — one line per leaf: + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; +``` + +Importing the package therefore fires every `register*` side effect, exactly as the old per-domain barrels did. When you add a new domain, write the contract + impl leafs (with their top-level `register*`), then add the leaf path(s) to `src/index.ts`. **Do not create an `index.ts`.** + +- Load the impl file too — its top-level `registerScopedService(...)` only runs when the module is imported. +- `export *` helper modules only if they are part of the domain's public surface. + +## Comments + +- **No comments** (orient.md): no file headers, no statement-level narration; the only exception is JSDoc attached to exported symbols. +- **Methods and fields carry no comments by default.** Well-named identifiers and types say *what*; the code is the source of truth for *how*. +- Write an inline comment only when the *why* is non-obvious (a hidden constraint, a subtle invariant, a workaround). One short line. +- For unimplemented stubs, throw `NotImplementedError('feature')` rather than `throw new Error('TODO: …')` (errors.md). + +## Complete minimal example + +```ts +// greet/greet.ts +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface Greeting { readonly message: string; } + +export interface IGreeter { + readonly _serviceBrand: undefined; + hello(): Greeting; +} + +export const IGreeter: ServiceIdentifier = createDecorator('greeter'); +``` + +```ts +// greet/greetService.ts +import { LifecycleScope } from '#/app/scopes'; +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { type Greeting, IGreeter } from './greet'; + +export class Greeter implements IGreeter { + declare readonly _serviceBrand: undefined; + hello(): Greeting { return { message: 'hi' }; } +} + +registerScopedService(LifecycleScope.App, IGreeter, Greeter, ScopeActivation.OnScopeCreated, 'greet'); +``` + +```ts +// src/index.ts +export * from './greet/greet'; +import './greet/greetService'; +``` + +## Red lines (this topic) + +- One folder per domain, camelCase; one service per file pair: contract `.ts` + impl `Service.ts`; **no `index.ts` barrel** — `src/index.ts` loads each leaf file precisely. +- Exactly one injectable interface and one `createDecorator(...)` per contract file. +- Exactly one service implementation class and one `registerScopedService(...)` per impl file. +- `IXxxService` / `XxxService` naming; decorator string is lowerCamelCase, globally unique, and stable. +- Name Services by owning domain, never by scope (`IAgentEntityService`, `ISessionEntityService`). +- `_serviceBrand` only on interfaces used as a DI token — never on base interfaces or plain models. +- Sync methods return concrete types, async return `Promise`; do not `Promise`-wrap sync work. +- `createInstance` objects put static parameters before service parameters; scoped services put `@IX` parameters first (static params need defaults). +- Never `new` a `@IService`-carrying Service — except inside an explicit factory method, which is not a DI request. +- Events: typed per-Service event → `Event`/`Emitter` from `'#/_base/event'`; cross-domain broadcast → `IEventService` from `'#/event'`. +- `src/index.ts` must import/export every leaf file (including the impl) so each `register*` side effect runs. +- No comments by default (orient.md); stubs throw `NotImplementedError`. diff --git a/.agents/skills/agent-core-dev/telemetry.md b/.agents/skills/agent-core-dev/telemetry.md new file mode 100644 index 00000000..742ae39e --- /dev/null +++ b/.agents/skills/agent-core-dev/telemetry.md @@ -0,0 +1,97 @@ +# Topic — Telemetry + +Telemetry infrastructure for agent-core-v2: how business services emit events, how context propagates, and how events reach a destination through appenders. + +Telemetry is a **layer-1 root** domain (alongside `log`): the facade lives at `App` scope (a per-Agent ambient context service is bound at `Agent` scope), stateless, with no business-domain dependencies. It is a thin facade — enrichment, batching, and transport belong to the appenders, not to this layer. + +## Where things live + +- `src/app/telemetry/telemetry.ts`: contract — `ITelemetryService` (facade), `ITelemetryAppender` (destination), `TelemetryProperties`, `nullTelemetryAppender`, and `TelemetryServiceOptions`. +- `src/app/telemetry/events.ts`: event registry — `telemetryEventDefinitions` pairs every business event's property type with review metadata (owner / purpose / per-property comment); the single source of truth for `track2`. Agent-scope events register with `defineAgentTelemetryEvent

` and compose the ambient `AgentTelemetryEventContext` (`agent_id`) into their wire schema; all other events register with `defineTelemetryEvent

`. +- `src/app/telemetry/telemetryService.ts`: `TelemetryService` impl + `registerScopedService(LifecycleScope.App, …)`. +- `src/app/telemetry/agentTelemetryContext.ts` + `agentTelemetryContextService.ts`: `IAgentTelemetryContextService` — Agent-scoped mutable request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) snapshot into turn telemetry at launch. Agent identity (`agent_id`) is not part of it — identity is bound by the Agent-scoped `ITelemetryService` view. +- `src/app/telemetry/consoleAppender.ts`: `ConsoleAppender` — echoes events to a log function (dev / debug). +- `src/app/telemetry/cloudAppender.ts`: `CloudAppender` — sanitizes + PII-cleans properties, batches + enriches + posts to the telemetry endpoint. +- `src/app/telemetry/cloudTransport.ts`: `CloudTransport` — HTTP transport behind `CloudAppender`. +- `src/app/telemetry/privacy.ts`: outbound PII redaction (`cleanTelemetryProperties`) — URLs, emails, tokens, and absolute file paths become `` labels; `node_modules/` tails are kept. + +## Emitting events (business services) + +Inject `ITelemetryService` and call `track2` with a registered event: + +```ts +import { ITelemetryService } from '#/app/telemetry/telemetry'; + +constructor(@ITelemetryService private readonly telemetry: ITelemetryService) {} + +this.telemetry.track2('cron_fired', { task_id: taskId, coalesced_count: 0, stale: false, buffered: false, recurring: true }); +``` + +`track2` is checked against the registry in `events.ts` at compile time: the event name must be a key of `telemetryEventDefinitions`, and the properties must match the registered interface exactly (extra or missing keys are compile errors). **New events must be registered first** — add a properties interface, then register it with `defineAgentTelemetryEvent

({ owner, comment, properties })` when every emission path goes through an Agent-scoped `ITelemetryService` view, or `defineTelemetryEvent

` otherwise (including events with any non-Agent emission path, e.g. `image_compress` from the kap-server prompt routes), documenting every property. For agent-scope events the registered interface is the business payload only: ambient `agent_id` is declared once in `AgentTelemetryEventContext` and composed into the wire schema, so it must not appear in the payload or at call sites. Naming: snake_case for events and properties, unit suffixes (`_ms` / `_count` / `_bytes`), no user content or file paths; `test/app/telemetry/events.test.ts` enforces the conventions. The low-level `track` remains for appender plumbing and tests only. + +`TelemetryService.track` merges the bound context into the properties and fans the event out to every registered appender. A single throwing appender is isolated via `onUnexpectedError` and never blocks the rest. + +### Context (sessionId / agent_id / turn_id) + +The root service carries a bound context (`sessionId`) that is merged into every event, and each Agent scope gets its own telemetry view seeded with `agent_id` (by `agentLifecycle`), so Agent-scoped services emit their identity without call-site plumbing. Mutable per-agent request context (`mode` / `provider_type` / `protocol` / `turn_id` / `trace_id`) lives in `IAgentTelemetryContextService` and is snapshot into a per-turn view at turn launch. Derive a scoped view with `withContext`: + +```ts +const child = telemetry.withContext({ agent_id: 'agent-0' }); +child.track2('tool_call', { turn_id: 1, tool_call_id: 'c1', tool_name: 'bash', outcome: 'success', duration_ms: 12 }); // wire carries sessionId + agent_id +``` + +`withContext(patch)` returns a lightweight forwarding view: transport state (appenders, enabled flag) stays with the root, so later `addAppender` / `setEnabled` calls apply to every view, and per-call properties override bound context on key collision. `setContext(patch)` on the root mutates the root context and propagates to appenders that implement `setContext`; on a view it mutates only that view's own context. + +## Appenders (destinations) + +An appender is the destination an event is fanned out to. It is **not a DI Service** — it is a plain object implementing `ITelemetryAppender`, held by `TelemetryService`. + +```ts +export interface ITelemetryAppender { + track(event: string, properties?: TelemetryProperties): void; + withContext?(patch: TelemetryContextPatch): ITelemetryAppender; + setContext?(patch: TelemetryContextPatch): void; + flush?(): Promise | void; + shutdown?(): Promise | void; +} +``` + +Built-in appenders: + +- `ConsoleAppender` — `[telemetry] ` to a log function (default `console.log`); options `prefix` / `pretty` / `log`. +- `CloudAppender` — batches events, enriches with common context (`app_name` / `version` / `platform` / …), and posts to `https://telemetry-logs.kimi.com/v1/event` through `CloudTransport` (Bearer auth, retry, on-disk fallback). Options: `homeDir` / `deviceId` / `sessionId?` / `appName` / `version` / `uiMode?` / `model?` / `getAccessToken?` / `endpoint?` / `flushThreshold?` / `flushIntervalMs?`. + +### Registering appenders (bootstrap) + +Appenders are added after the App scope exists, by resolving the service and calling `addAppender`: + +```ts +const app = createAppScope(); +const telemetry = app.accessor.get(ITelemetryService); + +telemetry.addAppender(new ConsoleAppender({ prefix: '[dev]' })); // dev echo +telemetry.addAppender(new CloudAppender({ // production + homeDir, deviceId, sessionId, + appName: 'pythinker-code', version, uiMode: 'shell', model, + getAccessToken: () => auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME), +})); +``` + +`addAppender` returns an `IDisposable` that removes the appender when disposed. `setAppender(appender)` resets to a single appender (mainly for tests). `removeAppender(appender)` drops one. + +> There is no production bootstrap wired yet — `TelemetryService` defaults to `[nullTelemetryAppender]`, so `track(...)` is a no-op until `addAppender` is called at startup. + +## Lifecycle + +- `setEnabled(false)` drops `track` (service-level switch); `setEnabled(true)` resumes. `flush` / `shutdown` are unaffected by the switch. +- `flush()` / `shutdown()` fan out to all appenders concurrently; a single rejecting appender is swallowed. Await `shutdown()` before process exit so buffered events (e.g. in `CloudAppender`) are sent. + +## Red lines (this topic) + +- Business services depend only on `ITelemetryService` — never import an appender class. +- Telemetry is layer-1 root: do not inject any business-domain service into it, and keep the facade at `App` scope (only the ambient context service binds at `Agent`). +- Appenders are plain `ITelemetryAppender` objects, not DI Services — register them with `addAppender`, never via `registerScopedService`. +- `track` is fire-and-forget and must not throw; appender `track` must be synchronous — buffer and send asynchronously via `flush` / `shutdown`. +- Await `telemetry.shutdown()` before process exit when a buffering appender is registered. +- Keep event names stable; register every business event in `events.ts` and emit via `track2` — properties must be JSON-serializable primitives (non-primitives are dropped with a warning by `CloudAppender`). +- Agent identity is ambient: agent-scope events go through `defineAgentTelemetryEvent` and get `agent_id` from the scoped telemetry view — do not pass `agent_id` at business call sites (per-event identities such as `subagent_created` and the cron events are the exception). diff --git a/.agents/skills/agent-core-dev/test.md b/.agents/skills/agent-core-dev/test.md new file mode 100644 index 00000000..96e817a3 --- /dev/null +++ b/.agents/skills/agent-core-dev/test.md @@ -0,0 +1,270 @@ +# Stage 4 — Test + +Exercise the **same path production uses**: a service is reached by its interface through the container, its `@IService` dependencies are resolved from the container, and — where the scope layer matters — through the scope tree. Tests that `new` a service and paper over its constructor with hand-rolled objects bypass that path and let the `registerScopedService(IX → Impl)` binding rot untested. + +`@IService` parameter decorators run under vitest (the build uses `experimentalDecorators`), so fixtures declare dependencies exactly like production code. There is **no** `param()` helper, no manual `(Id as …)(Ctor, '', 0)`, and no capturing `accessor` inside a constructor to synchronously `.get()` a peer. + +## The one rule + +**Resolve the system under test by its interface, through the container. Never call `new` on a production service whose constructor carries `@IService` dependencies.** + +```ts +// ✅ resolve by interface — the IX → Sut binding is exercised +ix.set(IMessageService, new SyncDescriptor(MessageService)); +const svc = ix.get(IMessageService); + +// ❌ construct the implementation directly — the registration is never run +const svc = new MessageService(stubContext); +``` + +Resolving by interface is what makes `registerScopedService(ISut, Sut, …)` part of the test. Constructing the class directly (or via `ix.createInstance(Sut)`) tests the class in isolation but leaves the binding, the scope layer, and the delayed/eager flag unverified. + +Pure functions, value objects, and services with **no** `@IService` dependencies may be constructed directly. + +The only other exception is a test that genuinely needs **two independent instances** of the same service with different dependencies (e.g. constructing two `TurnService`s with different `ILoopRunner`s). A singleton-per-container resolution cannot produce both, so `ix.createInstance(Impl)` is acceptable there — annotate it with a comment explaining why. + +## Two harnesses + +Pick the harness by *whether the scope layer is part of what you are testing*. + +| Under test | Harness | Resolve the SUT with | +|---|---|---| +| A single service's behavior (unit) | `TestInstantiationService` (flat) | `ix.get(ISut)` after `ix.set(ISut, new SyncDescriptor(Sut))` | +| Cross-scope wiring, or which layer a service lives in | `createScopedTestHost` (scope tree) | `host..accessor.get(ISut)` | + +### Unit harness — `TestInstantiationService` + +Default for domain service unit tests. It is an `InstantiationService` that also implements `ServicesAccessor` (so you can `ix.get(...)` directly) and owns sinon (so `dispose()` restores stubs). + +```ts +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import type { TestInstantiationService } from '#/_base/di/test'; +import { registerRecordsServices } from '../records/stubs'; + +describe('XxxService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = createServices(disposables, { + base: [registerRecordsServices], + additionalServices: (reg) => { + reg.define(IContextService, ContextService); // 1. real collaborator, by interface + reg.define(IXxxService, XxxService); // 2. system under test, by interface + }, + }); + }); + afterEach(() => disposables.dispose()); + + it('does the thing', () => { + const svc = ix.get(IXxxService); // 3. resolve by interface + expect(svc.thing()).toBe('…'); + }); +}); +``` + +`createServices` builds the container from domain **service groups** plus per-test overrides (see Service groups). Reach for `ix.stub(...)` / `ix.set(...)` directly only inside an `it` when a single test needs to swap a registration: + +- whole service, partial object: `ix.stub(IId, { method() { return … } })`; +- single method: `ix.stub(IId, 'method', value)` returns a sinon stub; `ix.spy(IId, 'method')` returns a spy; +- a prebuilt instance or descriptor: `ix.set(IId, instance)` / `ix.set(IId, new SyncDescriptor(Impl))`; +- when a collaborator's behavior must vary per test, model it as a `Test*Service` subclass whose methods read suite-scoped `let` variables rather than rebuilding the container each test. + +### Scope harness — `createScopedTestHost` + +Reach for this only when *which layer a service lives in* is itself the thing being asserted, or when the SUT reads from parent/child scopes. It builds the real `Scope` tree and resolves through it. + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { LifecycleScope } from '#/app/scopes'; +import { + ScopeActivation, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; + +describe('XxxService (scoped)', () => { + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.Agent, + IXxxService, + XxxService, + ScopeActivation.OnDemand, + 'xxx', + ); + }); + + it('resolves from the Agent scope with ancestor deps injected', () => { + const host = createScopedTestHost([stubPair(ILogService, stubLog())]); + const agent = host.child(LifecycleScope.Agent, 'main'); + const svc = agent.accessor.get(IXxxService); // by interface + expect(svc.thing()).toBe('…'); + host.dispose(); + }); +}); +``` + +Always `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`. Do not rely on a production module's top-level `registerScopedService(...)` side effect: import order then becomes part of the test, and another suite's `_clearScopedRegistryForTests()` can wipe it. + +## Register the SUT by interface + +Whichever harness you use, the SUT is registered under its interface (`ix.set(IX, new SyncDescriptor(Impl))` or `registerScopedService(scope, IX, Impl, …)`) and resolved by that interface. This is non-negotiable: it is the only thing that keeps the production registration honest. + +A test that does `ix.createInstance(Impl)` is testing the class, not the service. Convert those (see Migration). + +## Shared stubs + +Hand-rolled stubs (`noopLog`, `noneEvent`, `unusedRecords`, …) must not be copied between test files. Each domain that owns a frequently-stubbed interface exports a stub from a `stubs.ts` **in the `test/` tree**, never from `src/`: + +```text +test/log/stubs.ts → stubLog() / stubLogger() +test/turn/stubs.ts → stubTurn() +test/records/stubs.ts → stubAgentRecords() +test/environment/stubs.ts → stubEnvironment() +``` + +All test support lives under `test/` so test-only code stays out of the production source tree. Because `tsdown` builds from `src/index.ts`, anything under `test/` is unreachable from the entry and is never bundled into `dist/`. + +Conventions: + +- export a **factory** (`stubXxx()`), not a shared singleton, so tests cannot leak state through a stub; +- name it `stub` — e.g. `stubAgentRecords`; +- the stub satisfies the full interface so the compiler, not a cast, guarantees it stays in sync; +- import it with a **relative path** — `./stubs` from the same domain's tests, `..//stubs` from another domain. Never import stubs from `#/…` (that alias is for production `src/`) and never import one test file from another; +- a `stubs.ts` may import its domain's production types via `#//…`. + +If a stub is needed by two test files, it belongs in that domain's `test//stubs.ts`. + +## Service groups + +Most unit tests stub the same handful of collaborators (`ILogService`, `IAgentRecords`, `IConfigService`, `ITelemetryService`, …). Rather than repeat `ix.stub(...)` lines in every `beforeEach`, each domain exports a `register*Services` function from its `stubs.ts` that registers the default test doubles for that domain: + +```ts +// test/log/stubs.ts +export function registerLogServices(reg: ServiceRegistration): void { + reg.defineInstance(ILogService, stubLog()); +} +``` + +`createServices(disposables, { base, additionalServices })` composes them: + +- `base` — an ordered list of service groups. Each group's registrations are deduped (first writer wins), so groups supply safe defaults without clobbering each other. +- `additionalServices` — applied after `base`. Registrations here **overwrite** any base default, so a test can swap a stub for a spy, register the system under test, or supply a one-off collaborator. + +```ts +ix = createServices(disposables, { + base: [registerLogServices, registerConfigServices, registerRecordsServices], + additionalServices: (reg) => { + reg.definePartialInstance(IAgentKaos, {}); // one-off collaborator + reg.define(IAgentRecords, spyRecords); // override a base default + reg.define(IXxxService, XxxService); // system under test + }, +}); +``` + +`ServiceRegistration` offers three verbs: + +- `define(id, Ctor)` — lazy `SyncDescriptor`; the service is instantiated on first resolve. Use for real collaborators and the system under test. +- `defineInstance(id, instance)` — a fully-built instance (a fake such as `stubLog()`, or `new ConfigRegistry()`). +- `definePartialInstance(id, { ... })` — a partial mock; only the supplied members are provided. Use for collaborators the test does not exercise. + +Conventions: + +- a group registers the domain's services **as dependencies** (a fake, or a `{}` partial when no fake exists yet). When a service is the system under test, the test registers the real implementation via `additionalServices` and does not rely on the group's default for it; +- keep groups small and domain-local. A service that is almost always the system under test, or that every consumer configures differently, should not have a group — register it inline via `additionalServices`; +- import groups with a **relative path** (`..//stubs`), never from `#/…`. + +`createServices` defaults to `strict: false` (missing dependencies warn rather than throw), matching `new TestInstantiationService()`. Pass `strict: true` to surface unregistered `@IService` dependencies. + +## Declaring dependencies + +Always use `@IService` constructor decorators — in fixtures and in production services alike. + +```ts +// ✅ +class Consumer { + constructor(@IGreeter private readonly greeter: IGreeter) {} +} + +// ❌ no param() helper, no inline cast +class Consumer { + constructor(private readonly greeter: IGreeter) {} +} +param(IGreeter, Consumer, 0); +``` + +Because the decorator runs when the class is defined, the `createDecorator` identifier must be initialized **before** the class that uses it. Declare the identifier, then the class: + +```ts +const IDep = createDecorator('dep'); +class Consumer { + constructor(@IDep private readonly dep: IDep) {} +} +``` + +For two services that depend on each other (a cycle), declare both identifiers first, then both classes, so neither class references an uninitialized binding. + +Declare fixtures at module top, interface + decorator + implementation co-located, and keep `_serviceBrand` on the interface when it represents a real service — `GetLeadingNonServiceArgs` relies on the brand to tell service parameters apart from static ones. Pure throwaway fixtures may omit `_serviceBrand`. + +## Lifecycle / teardown + +One `DisposableStore` per suite. Add the **container** and any event subscriptions to it; dispose in `afterEach`. + +```ts +beforeEach(() => { disposables = new DisposableStore(); /* … */ }); +afterEach(() => disposables.dispose()); +``` + +Do **not** add the system-under-test itself to the store. `TestInstantiationService` disposes every service it creates when the container is disposed, so `ix.get(IX)` instances are cleaned up automatically via `disposables.add(ix)`. Wrapping the SUT in `disposables.add(...)` would double-dispose it. For the same reason, do not call `svc.dispose()` at the end of a test unless you are asserting something about disposal itself. + +Scope-host tests call `host.dispose()` in `afterEach` (or at the end of the `it`). Route teardown through the store so ordering is deterministic and nothing leaks when a test fails mid-way. + +## Cascade: asserting unit state + +The cascade engine's test vocabulary lives in two files: `test/_base/di/cascade.test.ts` (the mechanism matrix, including cross-scope orchestration) and `test/_base/di/provide.test.ts` (provide/unprovide semantics). + +- **Assert unit states, not internals.** Every container exposes its engine as `container.cascade`: `stateOf(IX)` → `'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'`; `failureOf(IX)` → the sticky error of a `Failed` unit; `pendingSnapshot()` → the waiting-area contents. +- **The waiting area parks units with unregistered dependencies** — a unit whose declared deps are missing stays `Pending` (no throw), so a test must seed the full dependency chain. Example: a root→agent chain with no session container must seed the session-scope dependency explicitly — `ix.set(ISessionStateService, new SessionStateService())` in `test/session/agentLifecycle/agentLifecycle.test.ts` — or the dependent unit never activates. +- **Eager activation failure is sticky `Failed`, not a scope-creation throw.** Assert state + rethrow: `expect(ix.cascade.stateOf(IX)).toBe('Failed')`, then `expect(() => ix.invokeFunction((a) => a.get(IX))).toThrow(…)`. Do not expect scope/host creation itself to throw for a failing eager constructor. + +## Assertions and naming + +- One behavior per `it`; describe observable behavior (`child shadows parent registration`), not implementation (`calls _getOrCreateServiceInstance`). +- For cycles, assert `CyclicDependencyError` and its `path` array (e.g. `['A', 'B', 'A']`), not merely `toThrow`. +- For disposal order, capture events in an array and assert the sequence (`['C', 'B', 'A']` — children before parents). + +## Migrating existing tests + +Most legacy tests build the SUT with `ix.createInstance(Impl)`. Converting one is mechanical: + +1. import the interface (`IX`) and the descriptor; +2. register the SUT by interface — `reg.define(IX, Impl)` inside `additionalServices` (or `ix.set(IX, new SyncDescriptor(Impl))`); +3. replace `ix.createInstance(Impl)` with `ix.get(IX)`; +4. drop the `disposables.add(...)` wrapper around the SUT and any trailing `svc.dispose()` — the container disposes it; +5. replace any hand-rolled collaborator object with the domain's shared stub or service group (or add one to `test//stubs.ts` if it does not exist); +6. delete now-unused imports. + +Before / after: + +```ts +// before +const svc = ix.createInstance(MessageService); + +// after — registration in beforeEach additionalServices +reg.define(IMessageService, MessageService); +// after — resolution in the test body +const svc = ix.get(IMessageService); +``` + +## Red lines (this stage) + +- Resolve the SUT by interface — never `new` a production service with `@IService` deps; prefer `ix.get(IX)` over `ix.createInstance(Impl)`. +- Shared stubs live in `test//stubs.ts` (never `src/`); import by relative path, never `#/...`. +- Scope tests call `_clearScopedRegistryForTests()` and re-register explicitly in `beforeEach`; do not rely on production import-order side effects. +- One `DisposableStore` per suite; add the container, dispose in `afterEach`; do not add the SUT itself. +- Declare fixture dependencies with `@IService`; initialize `createDecorator` identifiers before the classes that use them. diff --git a/.agents/skills/agent-core-dev/verify.md b/.agents/skills/agent-core-dev/verify.md new file mode 100644 index 00000000..b394d8ed --- /dev/null +++ b/.agents/skills/agent-core-dev/verify.md @@ -0,0 +1,32 @@ +# Stage 5 — Verify & submit + +Run the guards and re-scan the red lines before submitting. + +## Commands + +Run from the package (or with `--filter @pymodel/agent-core-v2`): + +- `pnpm --filter @pymodel/agent-core-v2 lint:imports` — import-boundary guard (`scripts/check-import-boundaries.mjs`). Catches v1 imports (`@pymodel/agent-core`) and kosong subtree violations. +- `pnpm --filter @pymodel/agent-core-v2 typecheck` — `tsc -p tsconfig.json --noEmit`. +- `pnpm --filter @pymodel/agent-core-v2 test` — `vitest run`. + +## Changesets (when the change ships through the CLI) + +If the change is user-facing and ships through the CLI, generate a changeset with the repository's `gen-changesets` skill (root `AGENTS.md` workflow). `agent-core-v2` is an internal package; if its change enters the CLI bundle, the changeset lists `@pymodel/pythinker-code` and describes the real change — do not present an internal-only change as a user-facing feature. Never write a `major` bump without explicit user confirmation. + +## Pre-submit checklist + +Walk the stages you touched and confirm: + +- **Design** — scope follows state identity; no `Map` at `App`; dependency arrows do not make a foundational layer know an upstream one; no cycle was routed around. +- **Implement** — no `new` on `@IService`-carrying classes; `@IX` on constructor params only (service params after static params); interface + impl carry `_serviceBrand`; decorator names unique; coded errors only; flags for unreleased behavior. +- **Test** — SUT resolved by interface; stubs under `test/`; scope tests re-register after `_clearScopedRegistryForTests()`; teardown through one `DisposableStore`. +- **Files** — no comments (exported-symbol JSDoc excepted); registration runs from the impl file's top level; the new domain is exported from `src/index.ts`. + +Then re-read the [global red lines](SKILL.md#global-red-lines) once — they catch most cross-stage mistakes in a single scan. + +## Red lines (this stage) + +- Do not skip `lint:imports` — it is the only automated check for the v1-import ban and the kosong subtree rules. +- Do not list internal packages in a changeset when the change enters the CLI bundle — list `@pymodel/pythinker-code` and describe the real change. +- Never write a `major` changeset without explicit user confirmation. diff --git a/.agents/skills/agent-core-review/SKILL.md b/.agents/skills/agent-core-review/SKILL.md new file mode 100644 index 00000000..64df636e --- /dev/null +++ b/.agents/skills/agent-core-review/SKILL.md @@ -0,0 +1,21 @@ +--- +name: agent-core-review +description: Use ONLY for code review and test write/review guidance in `packages/agent-core-v2` (the DI × Scope agent engine). Does NOT apply to the legacy `packages/agent-core` or to any other package — for those, do not load this skill. Groups the review and testing lenses used for agent-core-v2 — `slop` (single-level-of-abstraction / layered error-handling review, invoked only on explicit request) and `test` (contract-driven per-test rules for both authoring and reviewing tests). Apply the sub-skill that matches the task; do not apply `slop` unprompted. +has-sub-skill: true +--- + +# kc-review + +> **Scope: `packages/agent-core-v2` only.** These lenses are calibrated for the v2 engine (DI × Scope). Do not apply them to the legacy `packages/agent-core` or to other packages. + +A bundle of the lenses used when reviewing or testing `packages/agent-core-v2`. Each sub-skill is self-contained; invoke the one that matches the task. + +## Sub-skills + +- **`slop/`** — Single Level of Abstraction & layered error handling. A *review dimension*: a function should read as a straight-line description of its own layer, with errors handled above or below. The agent reports detections and measurements, not severity grades. **Invoke only when the user explicitly asks for this lens** — do not apply it unprompted to general reviews or refactors. +- **`test/`** — Per-test rules behind "test the contract / responsibility, not the implementation," serving two modes. **Write mode:** author a test — one behavior per `it`, drive through the public surface, stub only the true external boundary, control time/config via documented knobs, keep tests clear, isolated, and refactor-resilient (CCCR). **Review mode:** audit existing tests against the same rules and report findings with `file:line`. Use when writing, modifying, or reviewing tests, or when asked how to write a good single test. + +## Routing + +- Reviewing code structure / abstraction layers / where error handling belongs → `slop` (only on explicit request). +- Writing or modifying tests, reviewing test quality, or advising on a single test → `test`. diff --git a/.agents/skills/agent-core-review/slop/SKILL.md b/.agents/skills/agent-core-review/slop/SKILL.md new file mode 100644 index 00000000..7e97d4a6 --- /dev/null +++ b/.agents/skills/agent-core-review/slop/SKILL.md @@ -0,0 +1,133 @@ +--- +name: slop +description: Invoke only when the user explicitly asks to review code through the "single level of abstraction / layered error handling" lens — a function does only its own layer's business logic while errors are handled above or below. The agent reports detections, raw-count measurements, and move directions. Apply only when the user explicitly requests this lens. +--- + +# Single Level of Abstraction & Layered Error Handling + +North star: **a function should read as a straight-line description of what its own layer does. Anything that is not that — input validation, error handling, error-to-response translation, logging, retries, low-level mechanics — belongs to a layer above or below, not inline.** + +This is a review dimension, not a hard rule. See "Exemption checklist" at the end. + +## Scope of this skill — detect and measure + +The agent applying this lens is a **sensor**. Its one job is to report *whether* a function mixes levels and *by how much*; deciding *how serious* it is belongs downstream. Severity labels (`Block` / `Request changes` / `Nit`) compress a continuous quantity into an uncalibrated three-point scale and are the main source of review-to-review variance, so they are produced downstream — by a deterministic rubric, anchored examples, or a human — from the facts the agent reports. + +The agent's output is exactly these four things: + +- **Detection (yes/no):** does this statement / block / function violate a rule of the lens? +- **Measurement (raw factual counts only):** mechanically countable quantities — body size, control-flow keywords, named syntactic shapes (see "Quantify"). Anything that first requires classifying a line (core/foreign, happy/error, high/low level) is recorded under detection, not here. +- **Direction (where it moves):** for each foreign concern, the destination layer — push **down** into a value / parser / infra helper, or push **up** into the edge handler. +- **Exemption flags:** which items, if any, hit the exemption checklist — recorded, not weighed. + +Severity grades, merge/block verdicts, and "is splitting worth it" calls live downstream, derived from the four items above. + +## When to use + +Apply this lens only when the user asks for it explicitly (for example "用单一抽象层次审视一下", "check whether this function does too much", "errors should be handled above/below, right?"). Leave general reviews and refactors to other lenses unless the user names this one. + +## The principle + +One function, one level of abstraction, one responsibility. Three mutually reinforcing rules: + +1. **Single Level of Abstraction (SLAP).** Every statement inside a function sits at the same conceptual level. High-level intent ("reserve inventory, charge payment, create the order") must not be interleaved with low-level mechanics (building headers, escaping strings, opening sockets, parsing bytes). If some lines read as "what" and others as "how", they belong in different functions. +2. **Error handling is its own concern (Clean Code).** A function either does the work or handles the error — not both. Business logic describes the happy path and *signals* failure (throw or return a result); the catch, mapping, logging, and recovery live in a dedicated handler, usually one layer up. Prefer exceptions / result types over threaded check-and-return ladders that interrupt the main flow. +3. **Separation of concerns by layer.** Each layer owns exactly one kind of knowledge: low-level code knows formats and protocols; mid-level code knows business rules; edge code knows the outside world (HTTP / CLI / UI). A function that knows two of these at once is leaking a layer. + +The combined test: **could you explain this function to someone without using the word "and"?** If the explanation is "it reserves stock AND validates the email format AND maps the error to a status code AND logs to metrics", it is doing more than its layer's job. + +Concerns that usually do **not** belong in a business function: + +- Format / range / null validation that a lower value or parser could guarantee once. +- Mapping domain failures to an external protocol (status code, exit code, UI message) — that is the edge layer's job. +- Catch-and-swallow, retry loops, backoff, timeout, circuit breaking around a single call — infrastructure, push down. +- Cross-cutting telemetry / log / metric noise woven through every step — extract or push to a wrapper. +- Check-and-return ladders that occupy more space than the business core — replace with signal + a handler above. + +## Methodology — fixing a function that violates it + +Work top-down. Never start by shuffling lines. + +1. **Name the level.** In one sentence, write what this function is for at its own layer. If you cannot, the function has no clear level — split before polishing. +2. **Classify every statement.** Tag each line or block as: **core** (this layer's business), **down** (a detail a lower abstraction should own), **up** (a concern an upper / edge layer should own), or **cross-cutting** (log / metric / retry). Unlabeled lines are where the mess hides — do not "just leave them". +3. **Decide down vs. up for each foreign item.** + - Push **down** when it is a guarantee a lower building block can provide: a value that can only be constructed valid, a parser that returns a typed result, an infra helper that already retries. The business function then assumes validity and stays clean. + - Push **up** when it is about translating or reacting to failure for the outside world: status codes, messages, exit codes, aggregation of many errors. The edge layer catches once and maps; business code just signals. + - Rule of thumb: if removing it would change what the business rule says, it is core and stays; if removing it only changes how a failure is reported or a detail is computed, it moves. +4. **Extract, do not interleave.** Pull each foreign concern into its own named function or layer. Keep the original function as a readable sequence of same-level calls. For error handling specifically, separate the work body from the recovery body into distinct functions so neither clutters the other. +5. **Signal, do not handle, in the middle.** Mid-layer business functions throw / return and let the right layer react. Do not catch-and-log-and-continue in business code unless continuing is itself the business rule. +6. **Re-read for level.** After the moves, every remaining line should be explainable at the same altitude. If not, repeat from step 1. + +Keep the change minimal: move the smallest thing that restores the level. Do not invent abstractions, frameworks, or generic "handler" machinery beyond what the function actually needs. Three straight-line, same-level calls beat a premature pipeline. + +## Review method — applying the lens to a diff + +Read each changed or touched function and, for each check, record only: **the hit (yes/no) plus evidence (`file:line`)**, and — where the check points at a construct — a raw factual count from "Quantify". + +1. **Altitude check.** Are all lines at the same level of abstraction? Record each place where a "what" line is immediately followed by a "how" block (or vice versa) inside the same function, with `file:line`. +2. **Happy-path check.** Can you read the business intent top to bottom without stepping through error branches? Record whether error handling sits inline between business steps (yes/no + `file:line`), supported by raw counts from "Quantify" (e.g. number of `catch` clauses, `continue` statements). +3. **Ownership check.** For each validation, catch, mapping, log, retry: is this layer the rightful owner, or is it borrowed from above / below? Record each borrowed item with `file:line` and its destination (down / up), using the rules from the methodology. +4. **Layer-leak check.** Does a business function mention an external protocol (status code, exit code, UI text, wire field)? Does an edge function contain a business rule? Record each leak candidate with `file:line` and whether it names an *external* protocol or an *internal* domain shape. +5. **Explanation test.** Describe the function in one sentence with no "and". Record whether "and" was needed; if so, list the proposed split as candidate moves (down / up). + +### Quantify — report only raw factual counts + +Report only quantities that can be counted **mechanically from the text**. Anything that first requires classifying a line (core vs foreign, happy-path vs error-handling, high-level vs low-level) is recorded under detection (the five checks above) as evidence, not as a number here. + +Report, per function: + +- **Body size** — lines and/or statements of the function body; state the basis (e.g. "statements, excluding lone braces"). +- **Control-flow keywords (raw counts)** — `if`, `continue`, early `return`, `throw`, `try` / `catch` / `finally`, `await`, loops (`for` / `while` / `.forEach`). +- **Named syntactic shapes a check points at** — when a check cites a construct, count it verbatim and name the exact token: e.g. number of object literals, string literals, `.trim()` calls, `.length` reads, `origin.` property reads, spread `[...x]` operations. +- **Recovery presence (raw)** — number of `catch` clauses, and number of log / metric calls inside them. + +Quantities that embed a prior classification — out-of-level vs core counts, guard-to-core ratios, happy-path vs error-handling volume, "repeated boundary checks a lower layer could guarantee once", "low-level literals in a high-level flow" — are captured as evidence under the relevant check (`file:line` + the verbatim tokens). A downstream rubric derives any ratio from those raw facts. + +### Red flags + +Record each as evidence (yes/no + `file:line`); these are candidates, not verdicts: + +- A body that is mostly check-and-return / check-and-throw ladders around a thin core. +- A recovery block that logs, maps, and returns inline, sitting next to business steps. +- A function that both computes a value and decides how that value's failure is shown to the user. +- Low-level literals (byte offsets, header strings, format codes) inside a high-level workflow. +- A name that needs "And" / "Or" / "With" to be honest, or a name so vague ("handle", "process", "do") that it hides multiple levels. +- Catch-and-swallow that hides a failure the caller needed to see. +- Defensive null / format checks repeated at every call site instead of guaranteed once at the boundary. + +### Severity grading belongs downstream + +The agent's facts (detections, raw counts, directions, exemptions) feed a downstream grade; the agent reports those facts and stops there. Grades compress a continuous quantity into an uncalibrated three-point scale and are exactly where identical evidence gets labeled differently across runs. Grading happens above the agent: + +- A **deterministic rubric** — a versioned threshold table over the raw counts from "Quantify"; or +- **Anchored examples** — the reviewer judges relative to repo-known reference functions rather than against an absolute adjective like "materially"; or +- A **human**, for items that land near a threshold boundary. + +If a downstream consumer still asks the agent for a grade, the agent returns the underlying facts and the threshold band it would fall under, with `confidence: low` on boundary cases; the grade itself is produced downstream. + +### How to report findings + +Report **evidence + direction**. Lead with the location and the level, then the proposed move. Prefer "this block is one level lower than the rest of the function (`file:line`) — move it **down** into X" over "this is ugly" or "this is a request-changes". The destination layer (down into a value / parser / infra helper, or up into the edge handler) is the actionable output and the deliverable. Attach the "Quantify" numbers and any exemption flags to each finding. + +## Exemption checklist + +This is a lens, not a law. For each foreign concern, check whether any exemption below applies and **record the hit (yes/no) plus the reason**. The agent records exemptions as facts; a recorded exemption is then used downstream to cap the grade (e.g. to `Nit`) deterministically. + +- **Tiny function:** the function is small enough that splitting would add indirection with no reader benefit. +- **Foreign concern is the single job:** the "foreign" concern is in fact the function's one purpose — a dedicated error mapper, a validator, an infra wrapper, or an index-bookkeeping helper whose low-level arithmetic *is* its level. +- **Atomicity / correctness / performance:** the steps genuinely must stay together (e.g. a re-check after an `await` to guard state that may have changed). +- **Edge-translator role:** an edge / handler function whose job is to translate an external event into internal indices; naming the wire fields is its job. + +Keep a split that would make the code harder to read as a recorded candidate for downstream review. When the evidence lands on an exemption boundary, record both sides and set `confidence: low`. + +## Output contract + +Return, per function, items 1–5 only: + +1. **Level statement** — one sentence: what the function is for at its own layer. +2. **Per-check results** — for each of the five review checks: `hit: yes/no`, evidence `file:line`, and (only where the check points at a construct) a raw factual count. +3. **Measurements** — the raw factual counts from "Quantify". +4. **Exemptions** — checklist hits (yes/no + reason). +5. **Proposed moves** — for each foreign concern: `file:line` → destination (down into X / up into Y). This is the actionable deliverable. + +Severity grades, block/merge verdicts, and "worth splitting" calls live downstream, derived from items 1–4. When a consumer asks for a label, hand back items 1–4 and the threshold band, with `confidence: low` on boundary cases. diff --git a/.agents/skills/agent-core-review/test/SKILL.md b/.agents/skills/agent-core-review/test/SKILL.md new file mode 100644 index 00000000..28ac09e5 --- /dev/null +++ b/.agents/skills/agent-core-review/test/SKILL.md @@ -0,0 +1,115 @@ +--- +name: test +description: Use when writing or reviewing tests, or when asked how to write a good single test. Encodes the per-test rules behind the "test the contract / responsibility, not the implementation" principle — name and structure one behavior per `it`, drive through the public surface, stub only true external boundaries, control time and config via documented knobs, and keep tests clear, isolated, and refactor-resilient. The same rules drive both authoring (write mode) and auditing existing tests (review mode). +--- + +# Tests — write & review + +Per-test rules that operationalize one principle: **test the contract / responsibility, not the implementation**. This is the how-to for a single `it`, and the lens for reviewing one. + +## Two modes, one rule set + +- **Write mode** — authoring a test. Apply the rules below to produce it. +- **Review mode** — auditing an existing test or test diff. Apply the same rules as a checklist; report each violation with `file:line`, the rule it breaks, and the fix. See "Review mode" near the end. + +The rules are identical in both modes — only the posture changes (produce vs. audit). + +## Test contract, not implementation + +- Drive the system through its **public control plane** and assert on **observable effects** (returned values, persisted state, emitted events, injected messages), never on source details. +- Resolve collaborators through their contract — the interface plus its identifier — not the module that binds a concrete implementation. +- Do not reach into private fields or add backdoors "for testing". If you feel the need, the seam is wrong — fix the design, not the test. + +## One behavior per `it` + +Each `it` covers exactly one responsibility / scenario. If the name needs "and", split it. + +```ts +it('returns 401 when the caller is unauthorized', ...); +it('does not double-fire when the same tick repeats', ...); +``` + +## Name and structure + +- `describe(' ()'` — name the **responsibility**, not the class. +- An `it(...)` reads as a sentence, but it must still encode three things — the **behavior / method**, the **state or condition**, and the **expected outcome**: `it(' when , ')`. A name like `does X when Y` with no result is too vague to fail usefully. + - Use spaces, not the Java-style `method_state_outcome` underscores — that convention exists only because Java test methods cannot contain spaces. A string-named test reads fine as a sentence. + - Good: `it('returns 401 when the caller is unauthorized')` · `it('advances the cursor and does not double-fire on a repeat tick')` + - Bad: `it('works')` · `it('handles auth correctly')` — no condition, no outcome +- Arrange / Act / Assert. A short `// Given` `// When` `// Then` is fine when it aids reading; do not paste it mechanically on trivial tests. + +## Build a small rig + +When several tests share setup, write a factory (`rig()`, `createHost()`, whatever fits the codebase) that returns the **smallest surface the test needs**. Tests reach into the rig; they do not rebuild the world each time. Keep the rig dumb: wiring only, no assertions. + +## Stub only the real external boundary + +Default to real collaborators wired the way production wires them. Stub the **minimum seam** that is genuinely external: + +- A remote / model / service boundary — spy on the contract method (the interface), and capture what the system sends across it. Do not stand up the real external thing. +- Network / other-process boundaries — stub at the boundary, not the internals. +- Time, timers, jitter — use the documented control knobs the system exposes (env, an injected clock, a manual tick). Do **not** use fake timers or real `setTimeout` to drive time. +- Env / config knobs are usually snapshotted at bootstrap — set them **before** building the system under test, and restore them in `afterEach`. + +## Keep tests DAMP and keep cause next to effect + +- DAMP over DRY: use **literal expected values** in assertions; do not compute the expectation with the same logic as the code under test. +- Keep the key preconditions inside the `it` (or its rig), where the reader can see cause next to effect. Reserve `beforeEach` for cross-cutting plumbing (env snapshot, cleanup), not for hiding the scenario's setup. + +```ts +// Good — the expected value is a literal the reader can check. +expect(discount).toBe(15); +// Bad — re-derives the expectation; mirrors the implementation. +expect(discount).toBe(price * rate); +``` + +## Assert only what is relevant + +Assert the effect that proves the contract. Use matchers / partial-object matching to ignore incidental fields. Do not assert internal counters, call orders, or shapes the user cannot rely on. + +## Isolate and clean up (no flakes) + +Every test must be hermetic and order-independent. In `afterEach`: + +- restore every mock / spy +- restore every env var you touched (snapshot in `beforeEach`) +- dispose the host / container and reset its reference + +No dependence on wall-clock time, run order, or leftover on-disk state — give each scenario its own isolated identity / workspace when state persists. + +## Quality bar: CCCR + +Before finishing, check each test against: + +- **Clarity** — a stranger can tell what broke from the failure message alone. +- **Completeness** — covers the responsibility's success, error, and boundary paths. +- **Conciseness** — no duplicate or speculative cases; one scenario per `it`. +- **Resilience** — survives an internal refactor with no test change (because it asserts contract, not implementation). + +## Per-file scenario header + +Start each test file with a short header comment: the **scenario**, the **responsibilities** asserted, the **wiring** (which collaborators are real vs. the single stubbed boundary), and how to run it. + +## Review mode — auditing existing tests + +Apply the rules above as a checklist against each test in scope (a file, a diff, or a named `it`). For every hit, report `file:line` + the rule it breaks + the fix; do not rewrite unless asked. Lead with the contract question: *what observable behavior does this test prove, and would it survive a refactor?* + +Check, in order: + +1. **Contract, not implementation** — asserts observable effects, not private fields, call order, or internal shapes the user cannot rely on. +2. **One behavior per `it`** — the name carries behavior + condition + outcome; "and" in the name means a split is owed. +3. **Boundary discipline** — only the true external seam is stubbed; time is driven by documented knobs, not fake timers / real `setTimeout`. +4. **DAMP expectations** — expected values are literals, not re-derived by the code under test's logic. +5. **Isolation** — mocks / spies / env / host restored in `afterEach`; no wall-clock, run-order, or leftover on-disk dependence. +6. **CCCR read-through** — Clarity, Completeness (success / error / boundary), Conciseness, Resilience. + +Report findings as evidence + fix, e.g. "`foo.test.ts:42` asserts on `service.internalMap` (contract) — assert the returned value instead." If a test passes the lens, say so briefly; silence on a rule means it held. + +## Quick checklist (write & review) + +- Resolved through the contract; no concrete-impl import +- One behavior per `it`; name carries behavior + condition + outcome; AAA +- Stubbed only the true external seam; time via knobs, not fake timers +- Literal expectations; relevant assertions only +- Mocks / env / host restored in `afterEach`; hermetic, no flakes +- CCCR read-through done diff --git a/.agents/skills/gen-changesets/SKILL.md b/.agents/skills/gen-changesets/SKILL.md index 35f06507..6de9e2cc 100644 --- a/.agents/skills/gen-changesets/SKILL.md +++ b/.agents/skills/gen-changesets/SKILL.md @@ -9,25 +9,36 @@ description: Use when generating changesets in the pythinker-code repository, in - `@pymodel/pythinker-code`: the CLI -All other `@pymodel/*` packages are treated as internal packages, including `@pymodel/pythinker-code-sdk`, `agent-core`, `kosong`, `kaos`, `pythinker-code-oauth`, and `pythinker-telemetry`. +All other `@pymodel/*` packages are treated as internal packages, including `@pymodel/pythinker-code-sdk`, `agent-core`, `kosong`, `kaos`, `pythinker-code-oauth`, `pythinker-telemetry`, and `migration-legacy`. + +`@pymodel/pi-tui` is a special internal package: it is a private fork (`private: true`) that is never published, but it keeps its own changelog through changesets. It is an exception to Core Rule 4 — see the dedicated section below. + +Only the CLI changelog gets a curated, user-facing presentation (the docs-site changelog sync). The SDK and other internal package changelogs are raw changesets output kept for version history — nobody curates them, so write those entries honestly and technically; their wording does not need to suit end users. ## Core Rules 1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed. 2. **List packages that changesets can release.** If a changed package is ignored in `.changeset/config.json`, do not put that ignored package in frontmatter together with a non-ignored package; changesets rejects mixed ignored/non-ignored frontmatter. 3. **Map ignored internal changes to the affected released package.** If an ignored internal package changes CLI output or behavior, list `@pymodel/pythinker-code` and describe the actual user-visible or release-artifact change in the changelog text. -4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@pymodel/pythinker-code` inline-bundles `@pymodel/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, list `@pymodel/pythinker-code`. - - **Web app (`@pymodel/pythinker-web`) changes always enter the CLI bundle.** `@pymodel/pythinker-web` is ignored by changesets (see `.changeset/config.json`) and cannot be mixed with `@pymodel/pythinker-code` in one changeset frontmatter. Describe the web change in the changelog text, but list `@pymodel/pythinker-code` so the CLI release carries the bundled `dist-web` output. +4. **Internal package source changes that enter the CLI bundle must manually list the CLI — when they get a changeset at all.** `@pymodel/pythinker-code` inline-bundles `@pymodel/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output and is user-perceivable, list `@pymodel/pythinker-code`. See rule 6 for when to skip the changeset entirely. 5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump. -6. `@pymodel/dashboard` / `dashboard-server` / `dashboard-web` are ignored by changesets and should not be handled. +6. **Skip changes users cannot perceive — write no changeset at all.** The CLI changelog is user-facing; a changeset is a changelog entry, not a shipping gate. Internal changes merged to `main` still ship in the next release triggered by any user-facing changeset, so skipping the changeset loses nothing. Do not write changesets for: + - `agent-core-v2` internal architecture: new services, refactors, config-persistence or journal/wire mechanisms. + - `kap-server` WebSocket / REST protocol changes consumed only by the bundled web UI, pythinker-inspect, or other dev tooling (new endpoints, subscribe protocols, stream baselines). + - Behavior that only takes effect on the experimental engine (e.g. experimental `pythinker -p`), unless it exposes documented user configuration such as a `config.toml` section or env vars that also work on a shipped surface (TUI or `pythinker web`). + - When unsure whether users can perceive a change, ask before writing. +7. `@pymodel/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled. `@pymodel/pythinker-inspect` (a private dev app that never ships) is likewise ignored and must never appear in a changeset frontmatter. ## Workflow 1. List the changed packages and check whether each one is ignored by `.changeset/config.json`. -2. Choose a bump level for each package. -3. If an ignored internal package change enters the CLI bundle, put `@pymodel/pythinker-code` in frontmatter instead of mixing the ignored package into the same changeset. -4. Create a short kebab-case file under `.changeset/`. -5. Split unrelated changes into separate changesets; keep one logical change in one file. +2. Decide whether the change is user-perceivable (Core Rule 6); if not, stop — no changeset. +3. Choose a bump level for each package. +4. If an ignored internal package change enters the CLI bundle, put `@pymodel/pythinker-code` in frontmatter instead of mixing the ignored package into the same changeset. +5. Create a short kebab-case file under `.changeset/`. +6. Split unrelated changes into separate changesets; keep one logical change in one file. + +Before a release, review the accumulated `.changeset/` entries against Core Rule 6 and prune non-user-facing ones; the release PR regenerates from `.changeset/` on `main`, so deleting a changeset removes its changelog entry without affecting the shipped code. Format: @@ -44,10 +55,14 @@ Format: | Level | When to use | |---|---| -| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades | -| `minor` | New backwards-compatible features or capabilities | +| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades; small improvements to existing features with limited user-facing impact (e.g. a new keyboard shortcut, a flag alias, a minor UX tweak) | +| `minor` | A substantial new user-facing feature, such as a new slash command, a new built-in tool, or a new mode | | `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar | +When in doubt between `patch` and `minor`: if the change improves an existing feature and the user-facing impact is small, choose `patch` even when the change is technically "new". Reserve `minor` for a substantial new capability that introduces something users could not do before. + +New configuration surface is not automatically `minor`. Additions to an existing feature's configuration — env var overlays, config-file fallbacks, global defaults under per-item settings — are `patch`. Examples: a global default MCP timeout when per-server timeouts already exist; env-based credentials for a service already configurable in `config.toml`. + ### Major Rule Never write `major` on your own. @@ -57,13 +72,26 @@ If you believe a change qualifies as major, stop first, explain why, and ask the ## Wording Rules - Changelog entries **must be written in English**. -- **Keep it short — ideally a single sentence that states what was done.** Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **Keep the whole entry concise.** Aim for one short sentence that states what was done; at most a short sentence plus a one-line usage hint. Do not write a paragraph, do not pile on technical detail, and do not enumerate every sub-change. +- **For new user-facing features, append a brief usage hint** so users know how to try it. Keep it to a single short line — a command name, a subcommand, a flag, or a one-line "how to use". Do not explain design rationale or list edge cases. Skip the hint for bug fixes, internal changes, and refactors. + - Slash command: `Add the /foo slash command to list active sessions. Run /foo to see them.` + - CLI subcommand: `Add the pythinker web subcommand to open the web UI. Run pythinker web to launch it.` + - Flag: `Add a --bar flag to skip confirmation prompts. Pass --bar to skip.` + - Too long: `Add the /foo command to list active sessions. It accepts an optional --all flag to include background sessions, supports filtering by name with /foo , and writes the result to the transcript...` - User-facing CLI wording should only be used when CLI users can perceive the change. - Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature. - Do not mention file names, class names, function names, PR numbers, or commit hashes. - Do not include real internal endpoints, key names, account names, or service names. If an example is needed, use neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY`. - Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording. +## When You Are Unsure About a Change + +Generate the changeset from what the diff clearly shows. If part of a change is unclear and you cannot confidently describe what it does for users, do not guess or pad the entry with vague wording. + +1. Finish the changeset for the parts that are clear. +2. Then ask the user once, in a short list: name the specific change(s) you do not understand, and ask whether you may dig into the repository (read related source, tests, or call sites) to describe it more accurately. +3. Only read more code after the user agrees. If the user says no or does not reply, keep the concise wording you already have and do not invent detail. + ## Common Examples An internal package fixes a bug visible to CLI users: @@ -76,6 +104,36 @@ An internal package fixes a bug visible to CLI users: Fix occasional loss of tool call results in long conversations. ``` +A new user-facing slash command (note the short usage hint): + +```markdown +--- +"@pymodel/pythinker-code": minor +--- + +Add the /foo slash command to list active sessions. Run /foo to see them. +``` + +A new CLI subcommand: + +```markdown +--- +"@pymodel/pythinker-code": minor +--- + +Add the pythinker web subcommand to open the web UI. Run pythinker web to launch it. +``` + +A new flag on an existing command: + +```markdown +--- +"@pymodel/pythinker-code": patch +--- + +Add a --bar flag to skip confirmation prompts. Pass --bar to skip. +``` + An internal package has an internal-only change, but it enters the CLI bundle: ```markdown @@ -96,44 +154,48 @@ Only SDK source changed, and the CLI does not use it: Clarify session status typing for internal SDK callers. ``` -## Web app changes +## `@pymodel/pi-tui` changes -`@pymodel/pythinker-web` is ignored by changesets and must **never** appear in a changeset frontmatter. Because the web app is bundled into the CLI release artifact, any web change that ships must list `@pymodel/pythinker-code` instead and describe the actual web-facing change in the text. +`@pymodel/pi-tui` is a vendored fork that lives in `packages/pi-tui`. It is `private: true` and is never published, but it is **not** ignored by changesets: changesets versions it and writes `packages/pi-tui/CHANGELOG.md` so the fork keeps its own history. Because it is bundled into the CLI like other internal packages, it is an exception to Core Rule 4 — do **not** list `@pymodel/pythinker-code` for a change that only touches pi-tui. -- If a PR contains both web UI changes and server API changes, split them into separate changesets so each entry has a focused description. -- Do not enumerate every micro-tweak; keep it to one sentence that captures what the web user gets. +- Changes that only affect pi-tui (build, package, strict-mode cleanup, renderer fixes): list `@pymodel/pi-tui` only. No CLI changeset. +- If the same change is also user-visible in the CLI (for example a terminal rendering fix that CLI users can see), add a **separate** changeset that lists `@pymodel/pythinker-code` with CLI-focused wording, in addition to the pi-tui changeset. Do not mix both packages in one frontmatter — the two changelogs need different wording. -Web-only fix: +pi-tui-only change: ```markdown --- -"@pymodel/pythinker-code": patch +"@pymodel/pi-tui": patch --- -Fix the web chat not scrolling to the bottom after sending a message. +Export the package manifest so the bundled binary can locate its native assets. ``` -Web UI plus server APIs in the same PR (split into two changesets): +pi-tui change that is also visible in the CLI (two separate changesets): ```markdown --- -"@pymodel/pythinker-code": minor +"@pymodel/pi-tui": patch --- -Add the server-hosted web UI, including chat layout and session list behaviors. +Clamp the differential render to the visible viewport so scrolling up during streaming no longer jumps to the top. ``` ```markdown --- -"@pymodel/pythinker-code": minor +"@pymodel/pythinker-code": patch --- -Add the server REST and WebSocket APIs that power the web UI. +Fix the transcript jumping to the top when scrolling up through history during streaming output. ``` ## Red Flags - You are about to write `major` without asking the user. +- You are writing a changeset for something users cannot perceive — `agent-core-v2` internals, `kap-server` WS/REST protocol plumbing, experimental-engine-only behavior. Skip the changeset instead (Core Rule 6). +- A new env var overlay or config fallback for an existing feature is bumped `minor` — configuration additions to existing features are `patch`. +- A new user-facing feature entry has no usage hint, or the hint runs to multiple lines and explains design rationale. +- You guessed wording for a change you do not understand instead of asking the user whether you may dig into the repo. - Internal package source enters the CLI bundle, but `@pymodel/pythinker-code` is missing. - A changeset frontmatter mixes ignored internal packages with non-ignored packages. - `packages/node-sdk` was not changed, but `@pymodel/pythinker-code-sdk` was listed for "internal package sync". @@ -141,3 +203,4 @@ Add the server REST and WebSocket APIs that power the web UI. - The wording claims more than the diff actually did. - The CLI wording mentions internal package names, class names, or PR numbers. - The entry includes real internal identifiers instead of neutral placeholders. +- A change that only touches `@pymodel/pi-tui` lists `@pymodel/pythinker-code` instead of `@pymodel/pi-tui`, or mixes both packages in one frontmatter. diff --git a/.agents/skills/gen-docs/SKILL.md b/.agents/skills/gen-docs/SKILL.md index 5ceb63cf..5cde626f 100644 --- a/.agents/skills/gen-docs/SKILL.md +++ b/.agents/skills/gen-docs/SKILL.md @@ -7,7 +7,7 @@ description: Update Pythinker Code CLI user documentation after meaningful code ## Overview -This repository (`github.com/PyModel/pythinker-code`) maintains English user documentation under `docs/`, published at **https://code.pythinker.com**. +This repository maintains bilingual user documentation under `docs/`. `docs/en/` and `docs/zh/` are mirrored pairs for most pages; update both in the same change. **Changelog is the exception** — English is the source, and Chinese is translated from English. Use this skill to update the corresponding documentation whenever the codebase has changes that affect product behavior or user experience. @@ -17,8 +17,10 @@ For a **full pre-release audit** of all pages (detecting hallucinations and cove This skill depends on the following being in place. If any are missing, stop and report to the user before continuing: -- `docs/` directory with documentation pages and `docs/.vitepress/config.ts` set up (VitePress site, deployed to code.pythinker.com). -- `docs/AGENTS.md` style guide — defines terminology, typography, and writing style. +- `docs/` directory with `docs/zh/`, `docs/en/`, and `docs/.vitepress/config.ts` set up (VitePress site). +- `docs/AGENTS.md` style guide — defines source-of-truth rules, terminology table, typography, and writing style. +- `docs/scripts/sync-changelog.mjs` — auto-syncs root `CHANGELOG.md` to `docs/en/release-notes/changelog.md`. +- `translate-docs` skill in `.agents/skills/` — handles bilingual synchronization. ## Workflow @@ -39,13 +41,19 @@ This skill depends on the following being in place. If any are missing, stop and If after the scan you conclude there is no user-facing impact, say so and stop. -3. **Keep release changelog syncing separate** +3. **Sync English changelog** - Do not copy unreleased changesets into `docs/release-notes/changelog.md`. After a release is published, use the `sync-changelog` skill to sync `apps/pythinker-code/CHANGELOG.md` into the docs site with release dates and section classification. + Run: + + ```bash + node docs/scripts/sync-changelog.mjs + ``` + + This updates `docs/en/release-notes/changelog.md` from the root `CHANGELOG.md`. Never edit the docs changelog by hand. 4. **Update user docs** - Following the rules in `docs/AGENTS.md`, edit the affected pages under `docs/`. Match terminology with the term table in `docs/AGENTS.md` and the existing wording in surrounding pages. + Following the rules in `docs/AGENTS.md`, edit the affected pages in whichever locale you are working in, then sync the mirror. Match terminology with the term table in `docs/AGENTS.md` and the existing wording in surrounding pages. Cover all relevant sections: @@ -53,17 +61,29 @@ This skill depends on the following being in place. If any are missing, stop and - Customization (skills, agents, MCP, hooks, plugins, etc.) - Configuration (config files, env vars, providers, data locations) - Reference (CLI subcommands, slash commands, keyboard shortcuts) - - Release notes (`docs/release-notes/breaking-changes.md` if a breaking change is involved) + - Release notes (`docs/zh/release-notes/breaking-changes.md` if a breaking change is involved) + +5. **Sync bilingual content** + + Invoke the `translate-docs` skill. It will: + + - Sync updated non-changelog pages between `docs/en/` and `docs/zh/` + - Translate the English changelog → Chinese under `docs/zh/release-notes/changelog.md` ## Rules and conventions -- **Terminology**: Use the term table in `docs/AGENTS.md` exactly. Do not invent synonyms. +- **Locale sync**: Non-changelog pages stay mirrored between `docs/en/` and `docs/zh/`. Changelog flows English → Chinese. +- **Terminology**: Use the term table in `docs/AGENTS.md` exactly. Do not invent new translations or use synonyms. - **Scope discipline**: Only update sections affected by the recent changes. Do not opportunistically rewrite unrelated docs. - **Public examples**: Never write real internal endpoints, key names, account names, or service names into docs. Use neutral placeholders such as `https://api.example.com/v1`, `https://registry.example.com/v1/models/api.json`, `example.test`, and `YOUR_API_KEY`. -- **Breaking changes**: If any change is breaking, also update `docs/release-notes/breaking-changes.md` (under `## Unreleased`) with `**Affected**` + `**Migration**` subsections. -- **Do not edit auto-synced files**: `docs/release-notes/changelog.md` is regenerated by the sync script; any manual edit will be overwritten. +- **Breaking changes**: If any change is breaking, also update `docs/en/release-notes/breaking-changes.md` (under `## Unreleased`) with `**Affected**` + `**Migration**` subsections, and mirror it in `docs/zh/release-notes/breaking-changes.md`. +- **Do not edit auto-synced files**: `docs/en/release-notes/changelog.md` is regenerated by the sync script; any manual edit will be overwritten. ## Common mistakes - Describing what code changed instead of what the user can now do (or can no longer do). - Adding a new section heading per feature instead of weaving the change into existing prose. +- Updating only one locale and leaving its mirror stale. +- Editing only the mirror to fix wording that should be corrected in the locale you changed first. +- Inventing new terminology that drifts from the `docs/AGENTS.md` term table. +- Using real internal values in examples instead of neutral `example` placeholders. diff --git a/.agents/skills/pre-changelog/SKILL.md b/.agents/skills/pre-changelog/SKILL.md new file mode 100644 index 00000000..ccea54da --- /dev/null +++ b/.agents/skills/pre-changelog/SKILL.md @@ -0,0 +1,72 @@ +--- +name: pre-changelog +description: Use before merging a pythinker-code release PR to preview the user-facing CLI changelog in Chinese. Reads the changelog that changesets pre-generated in the release PR, then reuses sync-changelog's strip / classify / translate logic to render a Chinese preview. Writes no files. +--- + +# Pre-Changelog + +Preview the user-facing **Chinese** changelog of an open `pythinker-code` release PR **before** it is merged. Read-only: this skill writes no files and commits nothing. + +This skill reuses `sync-changelog`'s strip / classify / translate rules. Read `sync-changelog` first; only the data source (release PR diff instead of a published `CHANGELOG.md`) and the output (preview instead of docs files) differ. + +## Workflow + +### 1. Locate the release PR + +```bash +gh pr list --state open --search "ci: release packages in:title" \ + --json number,title,url,headRefName,baseRefName +``` + +Pick the one with `headRefName: changeset-release/main`; record `number`, `url` as ``. If none is open, nothing to preview — stop. + +### 2. Read the pre-generated CLI changelog block + +changesets already pre-generates `apps/pythinker-code/CHANGELOG.md` inside the release PR. Extract the new version block from the diff: + +```bash +gh api repos/PyModel/pythinker-code/pulls//files \ + --jq '.[] | select(.filename=="apps/pythinker-code/CHANGELOG.md") | .patch' +``` + +Take the added lines (`+`) from the top `## ` down to (but not including) the next `## `. That is the version block to preview. + +If the CLI changelog is not in the diff (for example an SDK-only release), stop and tell the user — there is no user-facing CLI changelog to preview. + +### 3. Render the Chinese preview (reuse `sync-changelog`) + +Process the version block exactly as `sync-changelog` does for the docs site, but only in memory: + +- **Strip** (`sync-changelog` step 3): drop the H1, the `### Patch Changes` / `### Minor Changes` / `### Major Changes` subheadings, PR links, and commit-hash links; keep only each entry's body text. The `Thanks [@user](...)!` credit (including the multi-author form) must be removed every time. Within each entry, drop SDK-only and provider-internal sentences (SDK capability mapping / API exposure, provider wire-format mechanics, internal XML markers, hook/event payload mechanics such as what an event reports or carries) and keep only the user-facing effect and required constraints. +- **Merge and deduplicate** (`sync-changelog` step 4): merge micro-tweaks to the same surface into one higher-level entry; when three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry (do not merge broad or genuinely distinct fixes); and drop a server/API entry that only backs a web feature already listed. +- **Collapse low-signal entries** (`sync-changelog` step 4): keep standalone only entries that pass both gates — the reader-action test (the reader must do or re-evaluate something) and the channel test (the product cannot push it into the user's path: hidden controls, habit invalidations, capabilities users would not know to seek — a control merely sitting in the UI is not surfacing, users do not explore). Polish keeps only must-react items; experiences the product shows at the moment of need (recovery cards, post-install guidance) fold. Fixes keep only behavior-change entries (readers must update a habit, config, or workaround); loud failures fold (the fix itself notifies the victim), and silent past damage folds too — the changelog does not repair the past, and a notice that names no locatable instance and no realistic action is noise, not diligence. Section sizes follow density defaults (about 2 polish, 3 fixes) that yield to genuinely qualifying entries — flag the overflow for the reviewer instead of folding to hit the number. Fold everything else into one catch-all line placed last under 修复 — `修复了一些已知问题。` (or `修复了一些已知问题,并做了若干细节优化。` when non-fix entries were also collapsed; when nothing folded is a fix, place it under 优化 instead as `做了若干细节优化和内部改进。`), followed by a separate pointer sentence: `更详细的变更记录见 [GitHub](https://github.com/PyModel/pythinker-code/blob/main/apps/pythinker-code/CHANGELOG.md)。` (file link, no version anchor; before the release PR merges, the target does not yet contain this version's block — expected for a preview). +- **Classify** (`sync-changelog` step 4): bucket into Features / Bug Fixes / Polish / Refactors / Other; order within each section by reader value (in Polish, user-visible improvements before protocol/internal adjustments). +- **Translate** (`sync-changelog` step 6): translate entry bodies to Chinese; keep one sentence per entry with a parallel rhythm within a section; section headings become 新功能 / 修复 / 优化 / 重构 / 其他. + +If an upstream entry is not in English, flag it and stop (changeset entries must be English). + +### 4. Output + +Print the preview directly. Use `(预览)` as the heading because the version is not released yet. Write `无` for empty sections. Do not write any file. + +After the preview block, append a reviewer-only section titled `### 审稿参考(不进入文档)`: list every entry folded into the catch-all (short English title, one line each), note any section that exceeds the density defaults, and flag borderline calls for the reviewer to confirm. This breakdown is how reviewers see what was folded — before merge, the catch-all pointer's target does not yet contain the version's block. Never write this section into the docs pages. + +The preview is pasted into chat tools (for example Lark), where relative docs links do not resolve. Rewrite every docs link to its absolute published URL: map `../.md[#anchor]` to `https://code.pythinker.com/pythinker-code/zh/.html[#anchor]` — for example `../configuration/config-files.md#loop-control` → `https://code.pythinker.com/pythinker-code/zh/configuration/config-files.html#loop-control`. Never emit raw relative paths, and never wrap a link in backticks; code-style the link text inside the brackets instead ([`loop_control`](...)). + +``` +发版 PR: + +## (预览) + +### 新功能 +- ... + +### 修复 +- ... +``` + +## Rules + +- Read-only. Never write `CHANGELOG.md`, docs files, or commit anything. +- Classification, ordering, and translation follow `sync-changelog` exactly — do not reword or reclassify beyond what it specifies. +- If the release PR has no CLI changelog diff, report it and stop. diff --git a/.agents/skills/sync-changelog/SKILL.md b/.agents/skills/sync-changelog/SKILL.md index 82e508db..fab49f8f 100644 --- a/.agents/skills/sync-changelog/SKILL.md +++ b/.agents/skills/sync-changelog/SKILL.md @@ -1,6 +1,6 @@ --- name: sync-changelog -description: Use after a release succeeds, when maintainers need to sync apps/pythinker-code/CHANGELOG.md into docs/release-notes/changelog.md. +description: Use after a release succeeds, when maintainers need to sync apps/pythinker-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md, then open a PR on a dedicated branch. --- # Sync Changelog @@ -15,12 +15,12 @@ apps/pythinker-code/CHANGELOG.md This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site. -After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site (published at https://code.pythinker.com). +After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site, translate the English increment into Chinese, wait for an optional human review, then commit on a dedicated branch and open a PR. ## When To Use - A new version has been published to npm. -- The top of `apps/pythinker-code/CHANGELOG.md` contains version blocks that are not yet in `docs/release-notes/changelog.md`. +- The top of `apps/pythinker-code/CHANGELOG.md` contains version blocks that are not yet in `docs/en/release-notes/changelog.md`. - The `gen-docs` flow does not run this automatically; maintainers must explicitly do it after release. Do **not** run this before the Release PR is merged. At that point, changesets has not yet written the new version into `apps/pythinker-code/CHANGELOG.md`. @@ -30,49 +30,76 @@ Do **not** run this before the Release PR is merged. At that point, changesets h | File | Role | Edited by | |---|---|---| | `apps/pythinker-code/CHANGELOG.md` | **Only upstream source**, generated by changesets | Never edit manually | -| `docs/release-notes/changelog.md` | English docs changelog; source of truth for docs | This skill | +| `docs/en/release-notes/changelog.md` | English docs changelog; source of truth for docs | This skill | +| `docs/zh/release-notes/changelog.md` | Chinese docs changelog, translated from English | This skill, following `translate-docs` | -Core rule: the English docs changelog is the source of truth for user-facing release notes. +Core rule: the English docs changelog is the source of truth, and Chinese is translated from English. This matches `translate-docs`. ## Preconditions Before editing, confirm: -- The released version exists on npm (`npm view @pymodel/pythinker-code versions --json`) or has a matching GitHub Release tag on `PyModel/pythinker-code`. +- The released version exists on npm (`npm view @pymodel/pythinker-code versions --json`) or has a matching GitHub Release tag. - The top of `apps/pythinker-code/CHANGELOG.md` is that new version. -- The current branch is clean, or you are on a dedicated docs-sync branch. If any condition is not true, stop and confirm with the user. +Do **not** edit or commit directly on `main`. All sync work happens on a dedicated branch created in step 1. + ## Workflow -### 1. Find The Version Range +### 1. Prepare Branch + +Start from an up-to-date default branch: + +```bash +git fetch origin +git checkout main +git pull --ff-only origin main +``` + +Before creating the branch, peek at the version range so the branch name matches the newest version being synced: ```bash -# Upstream versions -rg '^## ' apps/pythinker-code/CHANGELOG.md | head -20 +rg '^## ' apps/pythinker-code/CHANGELOG.md | head -5 +rg '^## ' docs/en/release-notes/changelog.md | head -5 +``` + +Name the branch after the newest upstream version that is not yet in the English docs page: + +```text +docs/changelog-sync- +``` -# Latest version already synced into the English docs page -rg '^## ' docs/release-notes/changelog.md | head -5 +Example: syncing `0.2.1` only → `docs/changelog-sync-0.2.1`. + +```bash +git checkout -b docs/changelog-sync- ``` +If the branch already exists locally or on the remote, stop and confirm with the user instead of reusing it. + +### 2. Find The Version Range + +Use the same version lists from step 1. Confirm: + - First sync: copy all upstream version blocks into the English page. - Incremental sync: copy every upstream version block above the latest version already present in the English page. Use upstream order: newest version first. -### 2. Strip Decorations And Extract Entry Text +### 3. Strip Decorations And Extract Entry Text Upstream entries look like this: ```markdown -- [#317](https://github.com/PyModel/pythinker-code/pull/317) [`2f51db4`](https://github.com/PyModel/pythinker-code/commit/2f51db4) - Clean up lint warnings ... +- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) Thanks [@user](https://github.com/...)! - Clean up lint warnings ... ``` -Keep: +Changesets may add a `Thanks ...!` credit, but it must be removed every time. Keep: - Version headings such as `## 0.2.0`. -- Only the body text of each entry, after the PR/hash decoration. +- Only the body text of each entry, after the PR/hash decoration and any `Thanks ...!` credit have been removed. Remove: @@ -80,28 +107,54 @@ Remove: - Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`. - PR links such as `[#317](...)`. - Commit hash links such as ``[`2f51db4`](...)``. +- The `Thanks [@user](...)!` credit, including the multi-author form `Thanks [@a](...), [@b](...)!`. Drop the whole `Thanks ...!` segment every time, regardless of whether the feature is enabled. -After stripping, each entry should be only: +After stripping, each entry is `- `. -```markdown -- -``` +Drop SDK-only and provider-internal detail. This changelog serves `@pymodel/pythinker-code` CLI and web users. Within an entry, keep only what CLI/web users can perceive, and remove sentences that document internals instead of user-visible behavior. Apply this on both the English and Chinese pages: + +- Drop sentences about how the SDK maps a capability, builds model aliases, or exposes a flag through an API such as `getExperimentalFeatures()` — that belongs in the SDK changelog, not here. +- Drop provider / wire-format implementation mechanics (XML markers like ``, protocol field explanations, "the wire protocol is unchanged", cache-hit mechanics) unless they are the behavior a user perceives. +- Drop hook/event payload mechanics — clauses about what extra fields an event payload carries or what an event reports in a specific case (for example "enrich hook payloads with the session title and client type", "`SessionEnd` reports `archive` when a session is archived"). Keep the new events or capability itself and how to configure it. +- Keep the user-facing effect and any constraints users must follow (for example "question texts must be unique"). + +Do not change facts or drop a real user-facing behavior — only trim the internal-only scaffolding. For over-long, internal-heavy entries, this trim applies on the English page too, not only in translation. Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs. Public-text rule: do not copy real internal endpoints, key names, account names, or service names into docs changelogs. Replace examples with neutral placeholders such as `example.com`, `example.test`, or `YOUR_API_KEY` while preserving the user-visible meaning. -### 3. Classify Entries +### 4. Merge, Deduplicate, And Classify Entries + +Before classifying, merge related entries and drop redundant ones from the user-facing changelog: + +- **Curate for end users: collapse low-signal entries into one catch-all line.** The docs changelog is the only curated, user-facing outlet; the full entry list always remains in the upstream package changelog, so hiding detail here loses nothing. Apply two gates to every candidate entry. Gate 1, the reader-action test: **after reading this, is there something the reader must do, or something they must re-evaluate?** Gate 2, the channel test: **is the changelog the only channel that can deliver this?** The changelog is the channel of last resort — when the product itself surfaces the information in context, at the moment of need, to exactly the affected users, the entry is redundant no matter how real the improvement is. "Surfaced" means pushed into the user's path, not merely present on screen: an event-triggered card, prompt, or post-install screen forces the encounter, while a toggle, menu item, command, or settings page only waits to be found. Users do not explore — a capability that lives only in ambient UI is effectively undiscoverable, so the changelog must announce it. What in-product surfacing cannot deliver: hidden controls (env vars, config keys, opt-out flags nobody would find unprompted), invalidations of existing habits or expectations (in-product discovery comes as confusion), and capabilities users would not know to seek. An entry that fails either gate folds. Anchor both gates to the changelog's reader, never to the bug's victim: someone who hit a loud failure does not need the changelog to confirm the fix — the product working again is the notification — and a reader who never hit it gets nothing from the entry. + - `Features`: keep when users would try it or must react to it — new capabilities create demand readers did not know to seek. Collapse only behavior that takes effect solely behind an experimental flag. + - `Polish`: keep only must-react items — a notification users may want to turn off, a behavior change to a command they already use, a default flip with an opt-out. Fold improved experiences the product surfaces in context (recovery cards, post-install guidance, progress or status displays): they are discovered at the moment of need, and pre-reading about them helps nobody. Also fold subtle or transient tweaks (status wording, spacing, animations) and internal-behavior adjustments — nobody acts on them. + - `Bug Fixes`: keep only **behavior-change** fixes — the fix changes how something works going forward, so readers must update a habit, a config, or a widely-adopted workaround. Everything else folds, for one of two opposite reasons. Loud failures (crashes, refusals, interrupted runs): the fix itself notifies whoever was hit — announcement value falls as bug visibility rises. Silent past damage (dropped data, wrong results the user never noticed): the changelog cannot repair the past, and in this product the notice names no locatable instance and no realistic action — users cannot enumerate which old sessions were affected, and they do not audit finished sessions; a "some past outputs may be wrong" line is anxiety without an outlet, not diligence. The rare exception is a retrospective notice with a concrete, locatable action (for example rotating a token after a credential-handling flaw); keep those. Never keep a fix merely because it was severe, and never keep one because the bug class feels important. + - Do not grade entries by engineering importance. Severity and effort are already represented upstream; the curated changelog is not a credit ledger — its only job is to change what the reader does or knows. + - **Density, not quota.** Standalone sections stay short so the changelog actually gets read — as a default, expect about 2 Polish and 3 Bug Fixes entries per version, while `Features` is gated by the test alone and has no count. The defaults yield whenever more entries genuinely pass the reader-action test: keep them and flag the overflow for the human reviewer; never fold a qualifying entry just to hit the number, and never pad a section to reach it. The reviewer owns the final cutoff — the curator's job is to surface the borderline calls, not to resolve them silently. + - Everything else collapses into a single catch-all bullet placed last under `Bug Fixes`: `Fix several known issues.` When entries beyond fixes were also collapsed, use `Fix several known issues and make various refinements.` instead (Chinese: `修复了一些已知问题。` / `修复了一些已知问题,并做了若干细节优化。`). End the catch-all line with a pointer to the upstream file so folded entries stay reachable, phrased as a separate short sentence — `See the [changelog on GitHub](https://github.com/PyModel/pythinker-code/blob/main/apps/pythinker-code/CHANGELOG.md) for more technical entries.` (Chinese: `更详细的变更记录见 [GitHub](https://github.com/PyModel/pythinker-code/blob/main/apps/pythinker-code/CHANGELOG.md)。`). Link the file itself, never a per-version anchor — GitHub's generated heading anchors are fragile. Keep the pointer wording restrained ("more technical entries"): upstream only contains changes that received a changeset, so never claim the list is complete. + - If no fix survives, the `Bug Fixes` section is the catch-all line alone; if the whole version has no user-facing change, the version block is a single section with that line. Match the catch-all to what was folded — never claim fixes that did not happen: when the folded entries include fixes, use the forms above under `Bug Fixes`; when everything folded is polish or internal work, place the catch-all under `Polish` as `Make several refinements and internal improvements.` (Chinese: `做了若干细节优化和内部改进。`). +- **Merge micro-tweaks to the same surface.** Collapse several small tweaks to the same UI area or feature into one concise entry at the higher level. For example, "change the composer's default height" and "change the composer's default font" merge into "Polish the composer's default styling." Use the most specific common ancestor (composer, settings page, tool card, and so on). Classify the merged entry by its combined effect +- **Merge same-surface or same-kind fixes when you have three or more.** The `Bug Fixes` section tends to accumulate many narrow UI/polish fixes that read as noise when listed one by one. When three or more fixes target the same area (for example several tool cards in the TUI, or the web session/conversation surface) or the same class of problem (for example several "jumping/flickering/collapsing during streaming" fixes), merge them into one higher-level entry. Examples: + - "Fix the Bash tool card collapsing...", "Fix the Edit tool card jumping in height...", "Fix the Edit tool card flickering while its result streams in" → "Fix several TUI tool cards jumping, flickering, or collapsing in height when results stream in or end with short output." + - "Fix the collapsed sidebar not hiding...", "Stop the chat history from replaying its entrance animation...", "Fix tool components jumping the conversation when expanded/collapsed" → "Fix several layout and display glitches when switching sessions, including the collapsed sidebar not hiding, the chat history replaying its entrance animation, and tool components jumping the conversation." + - Classify the merged fixes as `Bug Fixes`. + - **Do not over-merge.** Leave a fix standalone when it is broad, high-value, or genuinely distinct (for example model/provider tool-calling bugs, session-list corruption, file-completion gaps). Merging is for low-reader-value, similar-shape fixes that read as a wall of similar bullets. A merged fix entry must still pass the standalone test from the catch-all rule above; if the merged group is low-signal too, fold it into the catch-all line instead of listing it. +- **Drop server/API plumbing covered by a web entry.** If one entry adds a web UI feature (for example, an Archived sessions page) and another entry only adds the server or REST/WebSocket endpoints that exist solely to power that web feature, keep the web UI entry and drop the API entry. CLI and web users perceive the web page; the backing API is implementation detail with no independent user value on this changelog. Keep the API entry only when it has independent user value — a new public endpoint that SDK or server consumers call directly, or a capability usable outside the web feature. When unsure, keep both and let the reviewer decide. The docs changelog uses five section types: -| English section | Meaning | -|---|---| -| `### Features` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | -| `### Bug Fixes` | Fixes for behavior that was broken | -| `### Polish` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | -| `### Refactors` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | -| `### Other` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | +| English section | Chinese section | Meaning | +|---|---|---| +| `### Features` | `### 新功能` | New user-facing functionality, such as a new command, flag, mode, or capability that did not exist before | +| `### Polish` | `### 优化` | User-visible improvements to existing functionality, including UX adjustments, behavior tweaks, and performance improvements that are not fixes or new capabilities | +| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken | +| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames | +| `### Other` | `### 其他` | Anything that does not fit above, such as CDN/endpoint swaps and docs-related artifacts | + +With the catch-all rule above, `Refactors` and `Other` rarely appear in newly synced versions: entries with no user-perceivable effect fold into the catch-all, and an entry that does change user-perceivable default behavior (for example an engine default flip with an opt-out flag) is classified by that effect, usually `Polish`. Reserve `Other` for genuinely unclassifiable but user-facing entries. Older versions keep whatever sections they already have — do not rewrite history. Classification process: @@ -113,6 +166,8 @@ Classification process: Features vs. Polish: ask whether the entry introduces something the user could not do before. If yes (new command, flag, mode, viewer, or capability), use `Features`. If it only improves an existing surface (a UI panel that already existed, an existing prompt, an existing tool card, an existing payload pipeline), use `Polish`. Verbs like `Add` do not automatically mean `Features` — a small visual addition to an existing UI is still polish. +Default-behavior changes: changing the default value of an existing capability (for example flipping a feature on by default) is usually `Polish`, because the capability already existed. Use `Features` only when the new default materially changes the out-of-box experience for most users in a way they could not get before. When genuinely ambiguous, flag it and confirm with the reviewer rather than guessing. + Keyword hints: - **Features**: `Add ... command/flag/option/mode/viewer`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option` @@ -124,18 +179,19 @@ Keyword hints: Within each version, section order is: ```text -Features → Bug Fixes → Polish → Refactors → Other +Features → Polish → Bug Fixes → Refactors → Other ``` Omit empty sections. Within each section, order entries by reader value, not upstream order: 1. Put the most valuable, obvious, and larger changes first. 2. Prefer broad user-visible features, workflow-changing fixes, high-frequency bugs, and large cross-cutting improvements over small polish, narrow edge cases, and internal cleanup. -3. If entries have similar value, preserve upstream order. +3. Within `Polish`, put directly user-visible UX or performance improvements (something users can see or feel) before protocol or internal-behavior adjustments (something that makes the model or pipeline behave more reliably but is invisible to users). +4. If entries have similar value, preserve upstream order. Do not reword or exaggerate entries just to make them look more important; only reorder existing entries. -### 4. Write The English Page +### 5. Write The English Page Never change the English page header: @@ -176,21 +232,103 @@ Example: - Update the native release workflow to use current GitHub artifact actions. ``` -### 5. Verify +Doc links: an entry that changes a documented config surface may end with a pointer to the docs page — `see [X](...) for details` (Chinese: `详见 [X](...)。`). Keep it a real Markdown link into the docs tree with a relative path (for example `../configuration/config-files.md#loop-control`). When the link text is a config key or another identifier, code-style the text inside the brackets: [`loop_control`](../configuration/config-files.md#loop-control). Never wrap the whole link in backticks — `` `[loop_control](...)` `` renders as raw inline code that exposes the relative path instead of a clickable link. + +### 6. Translate The Increment Into Chinese + +After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`. + +Follow `translate-docs`, direction `en → zh`. Changelog direction is English-to-Chinese even though many other docs flows use Chinese-to-English. + +Chinese page requirements: + +- Header: + + ```markdown + # 变更记录 + + 本页记录 Pythinker Code CLI 每个版本的变更内容。 + ``` + +- Preserve version headings including the release date, but use full-width parentheses on the Chinese page, such as `## 0.2.0(2026-05-26)`. The date must match the English page; only the parenthesis style differs (half-width `()` in English, full-width `()` in Chinese). +- Translate section headings exactly: + - `### Features` → `### 新功能` + - `### Bug Fixes` → `### 修复` + - `### Polish` → `### 优化` + - `### Refactors` → `### 重构` + - `### Other` → `### 其他` +- The Chinese page must mirror the English page 1:1 for versions, sections, section order, entry order, and entry counts. +- Keep the classification and entry order from the English page. Do not reclassify or reorder while translating. +- Translate only entry body text. Do not add entries that are not present in English. +- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary. + +#### Chinese wording style + +Structural fidelity does not mean literal translation. The Chinese entries should read like a concise, idiomatic Chinese changelog. Keep the same facts as the English entry, but rephrase for natural Chinese prose. + +Guidelines: + +- **One entry, one sentence.** Avoid chaining multiple effects with commas or semicolons. If the English entry is long, split it into shorter sentences or keep only the most important effect. +- **Drop SDK-only and provider-internal detail.** Apply the trim from step 3 while translating: keep the user-facing effect and required constraints, drop SDK-mapping sentences, provider / wire-format mechanics, and internal XML markers. A long internal entry should collapse to one short Chinese sentence about what the user gets. +- **Prefer common changelog verbs**: 新增、支持、修复、优化、改进、调整. +- **Avoid indirect "through... make..." structures**. Do not write "通过 X,使 Y"; prefer direct cause-effect or just state the result. + - Bad: `通过缓存已渲染消息行,使终端在长篇对话中保持响应。` + - Better: `缓存已渲染消息行,提升长对话下终端的响应速度。` +- **Be specific, not vague**. Prefer concrete actions over abstract quality words. + - Bad: `加固默认系统提示词和内置工具描述。` + - Better: `优化默认系统提示词与内置工具描述,避免 Agent 阻塞后台任务。` +- **Name concrete files or config keys when it helps clarity**. + - Bad: `插件现在可以在其清单中声明 hooks。` + - Better: `插件现支持在 pythinker.plugin.json 中声明生命周期 hooks。` +- **Include required argument placeholders in CLI options**. + - Bad: `--allowed-host` + - Better: `--allowed-host ` +- **Keep usage hints to one short clause**. + - Bad: `传入 --allowed-host 以允许额外的 host。例如 ... (多句展开)` + - Better: `例如 pythinker web --allowed-host example.com。` +- **Do not translate technical identifiers**: keep command names, flag names, file names, env vars, config keys as-is. +- **Keep parallel rhythm within a section.** When several entries fix similar web surfaces (layout, animation, sizing), phrase them with a consistent structure (for example 修复 <问题>,现 <行为>) so the section reads as a tidy list rather than a mix of shapes. + +Example — translating a feature entry: + +English source: + +```markdown +- Add a --allowed-host flag to pythinker web that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. +``` + +Before (literal, wordy): + +```markdown +- 为 `pythinker web` 新增 `--allowed-host` 标志,允许额外的 Host 请求头值通过 DNS 重绑定检查,并在 403 错误消息中包含允许指引。传入 `--allowed-host ` 以允许额外的 host。例如 `pythinker web --allowed-host example.com`。 +``` + +After (concise, idiomatic): + +```markdown +- `pythinker web` 新增 `--allowed-host ` 选项,可将指定 Host 加入 DNS 重绑定白名单;403 错误会提示如何通过 `--allowed-host` 或 `PYTHINKER_CODE_ALLOWED_HOSTS` 放行,例如 `pythinker web --allowed-host example.com`。 +``` + +### 7. Verify Review: ```bash -git diff docs/release-notes/changelog.md +git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md ``` Check: -- Every version heading carries its release date from the published tag. -- Each version has the expected section set and order. +- Versions and version counts match between English and Chinese. +- Every version heading carries its release date from the published tag, with half-width parentheses in English and full-width in Chinese. +- Each version has the same section set and order on both pages. +- Each section has the same number of entries on both pages. - Within each section, the most valuable, obvious, and larger entries appear before smaller or narrower entries. +- Low-signal entries were collapsed into the single catch-all line, placed last under `Bug Fixes` — or under `Polish` when nothing folded is a fix (both the reader-action test and the channel test applied); the catch-all wording matches what was folded and never claims fixes that did not happen; section sizes stay within the density defaults (about 2 Polish, 3 Bug Fixes) unless extra qualifying entries were deliberately kept and flagged for review. The catch-all line ends with the upstream changelog pointer (file link, no version anchor). - PR links and commit hashes were stripped. +- No `Thanks ...!` credit remains (remove it every time). - Real internal identifiers were replaced with neutral placeholders. +- Doc links are real Markdown links (code-styled text inside the brackets when needed), never wrapped in backticks. - There are no empty sections. - Markdown indentation and blank lines are intact. @@ -200,7 +338,36 @@ Then run the docs build: pnpm --filter docs run build ``` -### 6. Commit +### 8. Human Review Checkpoint + +After verification passes, **before committing**, ask the user whether they want to review the sync result. Use `AskQuestion` with options such as: + +- **Review first** — show the diff and wait for the user to finish checking. +- **Skip review, commit and open PR** — proceed directly to steps 9 and 10. + +If the user chooses review: + +1. Show the uncommitted diff: + + ```bash + git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md + ``` + +2. Summarize synced versions, section counts, and anything that needed manual classification. List every entry folded into a catch-all line (short titles, one line each), any section that exceeds the density defaults, and every borderline call flagged during curation — the reviewer cannot own a cutoff they cannot see. +3. Tell the user to reply when they are done reviewing, or to ask for edits. +4. Do **not** commit, push, or open a PR until the user explicitly says review is complete, or asks to proceed. + +If the user requests edits during review, make the changes, re-run verification from step 7, and return to this checkpoint. + +### 9. Commit + +Only run this step when the user skipped review or confirmed review is complete. + +Stage only the changelog docs files: + +```bash +git add docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md +``` Use a neutral docs-sync commit message: @@ -210,12 +377,65 @@ docs(changelog): sync from apps/pythinker-code/CHANGELOG.md Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle. +### 10. Push And Open PR + +Run immediately after step 9. + +Push the branch: + +```bash +git push -u origin HEAD +``` + +Create the PR with `gh pr create`. Title follows Conventional Commits: + +```text +docs(changelog): sync from apps/pythinker-code/CHANGELOG.md +``` + +Fill in `.github/pull_request_template.md`. For changelog sync PRs: + +- **Related Issue**: write `N/A — post-release docs maintenance` (no issue required). +- **Problem**: the docs-site changelog is behind the published CLI release(s). +- **What changed**: list synced version(s), note English source + Chinese translation, and mention verification (`pnpm --filter docs run build`). +- **Checklist**: check CONTRIBUTING; explain no issue, no tests, no changeset, and that `gen-docs` is not needed because this is the dedicated changelog sync flow. + +Example body: + +```markdown +## Related Issue + +N/A — post-release docs maintenance + +## Problem + +The docs-site changelog has not yet been synced for `` after the npm release. + +## What changed + +- Synced `` from `apps/pythinker-code/CHANGELOG.md` into `docs/en/release-notes/changelog.md` +- Translated the new English increment into `docs/zh/release-notes/changelog.md` +- Verified with `pnpm --filter docs run build` + +## Checklist + +- [x] I have read the CONTRIBUTING document. +- [x] I have linked a related issue, or explained the problem above. +- [ ] I have added tests that prove my feature works. (N/A — docs-only sync) +- [x] Ran `gen-changesets` skill, or this PR needs no changeset. (No changeset — docs sync is out of bundle) +- [x] Ran `gen-docs` skill, or this PR needs no doc update. (This PR is the dedicated changelog sync) +``` + +Return the PR URL to the user when done. + ## Rules - The English docs changelog is the source of truth. - Never edit upstream `apps/pythinker-code/CHANGELOG.md`. - Do not backfill unreleased `.changeset/*.md` drafts into the docs site. - If upstream wording is wrong, leave upstream alone and fix it in a future changeset. +- Always sync on a `docs/changelog-sync-*` branch and open a PR; never push changelog docs sync directly to `main`. +- Wait for the human review checkpoint before committing, pushing, or opening a PR. ## Common Mistakes @@ -223,7 +443,20 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter |---|---| | Adding entries directly to the English docs page without reading upstream | Use `apps/pythinker-code/CHANGELOG.md` as the source | | Copying PR links or commit hashes into docs | Strip them; keep only body text | +| Leaving the `Thanks ...!` credit in docs | Remove it every time, including the multi-author form | +| Leaving near-duplicate micro-tweaks as separate bullets | Merge small tweaks to the same surface into one higher-level entry (e.g. composer height + font → composer's default styling) | +| Listing many narrow fixes to the same surface as separate bullets | When three or more fixes target the same UI area or the same class of problem, merge them into one higher-level fix entry; keep genuinely distinct or high-value fixes standalone | +| Listing low-signal fixes or internal changes as standalone bullets | Collapse them into the single catch-all line (`Fix several known issues.`) placed last under Bug Fixes; treat the section-size defaults (about 2 Polish, 3 Bug Fixes) as a density guard, not a quota | +| Folding a qualifying entry just to hit the section-size default | The defaults are density guards; keep entries that genuinely pass the reader-action test and flag the overflow for the human reviewer | +| Keeping a fix because it was severe or hard-won | Severity makes the announcement redundant — the fix itself notifies whoever was hit; keep only behavior-change fixes and retrospective notices with a concrete, locatable action | +| Keeping an improvement the product surfaces in context (recovery cards, post-install guidance, progress displays) | The product is the better channel — right users, moment of need; fold it (channel test) | +| Folding a new capability because its control is visible somewhere in the UI | Visible is not discoverable — users do not explore; a toggle, menu item, or settings page that only waits to be found needs the changelog announcement | +| Keeping a silent-impact fix out of diligence (dropped data, wrong results the user never noticed) | The changelog does not repair the past; if the notice names no locatable instance and no realistic action, it is anxiety without an outlet — fold it | +| Overstating the catch-all pointer (for example claiming the upstream changelog is complete) | Keep the pointer restrained — `See the [changelog on GitHub](...) for more technical entries.`; upstream only contains changes that received a changeset | +| Writing `Fix several known issues.` when nothing folded is a fix | Never claim fixes that did not happen; all-polish/internal folds go under Polish as `Make several refinements and internal improvements.` | +| Listing a server/API entry that only backs a web feature already listed | Drop the API entry and keep the web UI entry, unless the API has independent user value | | Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise | +| Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms | | Editing upstream changelog text | Do not edit upstream | | Losing two-space indentation in multi-line list items | Restore indentation so Markdown lists stay valid | | Copying `### Patch Changes` into docs | Remove changesets headings and classify under Features / Bug Fixes / Polish / Refactors / Other | @@ -231,15 +464,25 @@ Do **not** create a changeset for changelog docs sync. Docs sync does not enter | Treating any `Add ...` line as Features | If the entry only adds a small element to an existing UI/surface, use Polish | | Filing UX or performance tweaks under Other | Use Polish for user-visible improvements to existing functionality | | Preserving upstream order when a small entry hides a larger change | Reorder within the section so the highest-value, most obvious items appear first | +| Reclassifying entries while translating | Chinese classification must mirror English | | Leaving empty sections | Delete sections with no entries | | Putting everything under Other for convenience | Classify what can be classified first | | Translating tool names, command names, or config keys | Keep them as written | +| Wrapping a whole doc link in backticks | Code-style the link text inside the brackets instead, so the link stays clickable: [`loop_control`](...) | +| Keeping hook/event payload-mechanics clauses | Drop what an event reports or carries; keep the new capability and how to configure it | | Creating a changeset for docs sync | Do not create one | -| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` taken from the published tag | +| Committing or pushing directly on `main` | Create `docs/changelog-sync-`, commit there, then open a PR | +| Committing or opening a PR before the user skips review or confirms review is done | Wait at the human review checkpoint | +| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` | +| Omitting the release date from a version heading, or guessing it | Add ` (YYYY-MM-DD)` (full-width `()` in Chinese) taken from the published tag | ## Stop Signals - The top version in `apps/pythinker-code/CHANGELOG.md` is not published on npm or GitHub Releases. - You are about to edit `apps/pythinker-code/CHANGELOG.md`. - You are about to add docs sync to a changeset. +- English and Chinese versions, entry counts, or section sets do not match. - A section is empty. +- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it. +- A `docs/changelog-sync-*` branch already exists for the same version and you cannot confirm whether it is stale. +- The user asked to review but has not yet confirmed review is complete. diff --git a/.agents/skills/translate-docs/SKILL.md b/.agents/skills/translate-docs/SKILL.md new file mode 100644 index 00000000..e081f1a0 --- /dev/null +++ b/.agents/skills/translate-docs/SKILL.md @@ -0,0 +1,67 @@ +--- +name: translate-docs +description: Translate and sync bilingual user documentation between docs/zh/ and docs/en/ following the source-of-truth rules in docs/AGENTS.md. +--- + +# Translate Docs + +## Overview + +This repository keeps bilingual user documentation under `docs/zh/` and `docs/en/`. This skill synchronizes the two locales, page by page, after either side has been updated. + +This skill is invoked by both `gen-docs` (incremental updates) and `audit-docs` (full pre-release audit) to keep locale mirrors in sync. + +## Prerequisites + +If any of the following are missing, stop and report to the user before continuing: + +- `docs/zh/` and `docs/en/` mirrored directory structure. +- `docs/AGENTS.md` — terminology table, typography rules, and source-of-truth rules. + +## Locale sync rules + +- **Changelog** (`release-notes/changelog.md`): English is the source. Translate to Chinese. +- **Breaking changes** (`release-notes/breaking-changes.md`): English is the source. Translate to Chinese. +- **All other pages**: `docs/en/` and `docs/zh/` are mirrored pairs. After either side changes, update the other locale in the same change. + +When non-changelog pages change in either locale, sync the mirror before release. When the English changelog changes, sync the Chinese changelog. + +## Workflow + +1. **Detect what needs syncing** + + - `git diff main..HEAD --stat docs/` — see which files changed + - For each changed file under `docs/en/` or `docs/zh/`, locate its mirror in the other locale (same relative path). + +2. **Translate page by page, section by section** + + - Keep heading hierarchy, list structure, code blocks, callout blocks, and link targets identical between the two versions. + - When in doubt about a technical term, **read the actual code** to confirm behavior rather than guessing. + +3. **Apply terminology and typography rules from `docs/AGENTS.md`** + + - Use the term table exactly. Do not invent translations or use synonyms. + - English H2+ uses sentence case (proper nouns excepted, per the term table). + - Chinese typography: full-width punctuation (`,。;:?!()`), space between Chinese and ASCII (letters / numbers / inline code / links). + - Callout titles (`::: tip` / `::: warning` / `::: info` / `::: danger`) use the short Chinese labels from `docs/AGENTS.md`. + +4. **Verify** + + - `git diff docs/` — scan for terminology drift or punctuation regressions. + - Run the docs build if available (`pnpm --filter docs run build` or equivalent) to catch broken links and Markdown errors. + +## Rules and conventions + +- **Do not one-sided fixes**: if the changed locale has an unclear or incorrect statement, fix it there first; do not patch only the mirror. +- **Match style, not just words**: Chinese docs use a narrative tone (see `docs/AGENTS.md` writing-style examples); preserve that tone in Chinese; preserve sentence-case headings and concise English style in English. +- **Code blocks and identifiers stay as-is**: do not translate code, command names, flag names, or file paths. +- **Public examples**: Do not introduce real internal endpoints, key names, account names, or service names while translating. Keep or replace them with neutral placeholders such as `example.com`, `example.test`, and `YOUR_API_KEY` in both locales. + +## Common mistakes + +- Rewriting only the mirror because a phrase feels awkward in the target language — fix the changed locale first, then sync. +- Letting English headings slip into Title Case (only sentence case is allowed for H2+). +- Forgetting to add spaces between Chinese characters and inline code or English words. +- Translating proper nouns listed in the term table (`Wire`, `MCP`, `ACP`, `JSON`, `OAuth`, `macOS`, `uv`, etc.). +- Updating only one direction and leaving the other locale stale — always finish all pages flagged by the diff. +- Copying real internal values into the mirror instead of using neutral `example` placeholders. diff --git a/.agents/skills/write-tui/DESIGN.md b/.agents/skills/write-tui/DESIGN.md index e551d2e8..fab4f235 100644 --- a/.agents/skills/write-tui/DESIGN.md +++ b/.agents/skills/write-tui/DESIGN.md @@ -1,181 +1,178 @@ -# TUI Design Spec +# TUI 设计规范(Design Spec) -> Single source of truth for every dialog, selector, and input in this directory. Read this file before adding or changing interactive components, and check the checklist at the end before submitting. -> Reference component: `components/dialogs/model-selector.ts` (`/model`). All list-style dialogs align header, hint, search, selection, and current-state styling to it. +> 本目录所有 dialog / selector / 输入框的**单一真值源**。新增或改造交互组件前先读本文件,提交前对照文末「自查清单」。 +> 基准组件:`components/dialogs/model-selector.ts`(`/model`)。所有列表型 dialog 的头部、hint、搜索、选中/当前态都以它为准对齐。 --- -## 1. Visual states +## 1. 视觉状态 -| Semantics | Spec | Constant / token | +| 语义 | 规范 | 常量 / token | |---|---|---| -| Selected pointer | `❯ ` (`primary`) | `constant/symbols.ts` → `SELECT_POINTER` | -| Selected text | `primary` + bold | `chalk.hex(colors.primary).bold` | -| Current / active item | trailing ` ← current` (`success`) | `constant/symbols.ts` → `CURRENT_MARK` | -| Danger item / action | `error` (bold when selected) | `chalk.hex(colors.error)` | -| Danger confirm `[y/N]` | `warning` + bold | `chalk.hex(colors.warning)` | -| Toggle on | trailing ` enabled` (`success`) | `chalk.hex(colors.success)` | -| Toggle off | trailing ` disabled` (`textDim`) | `chalk.hex(colors.textDim)` | -| List / selector border | flat `─` (`primary`), top and bottom only | — | -| Input border | rounded `╭ ╮ ╰ ╯` (`primary`) | — | - -- **Do not** invent custom selection pointers (`>` / `▶` / `→`, etc.); always use `SELECT_POINTER`. -- **Do not** use `● ` / `(current)` for the current item; always use `CURRENT_MARK` (trailing, `success`, with a leading space). -- Current item and selected item are **independent**: current item is the value in effect (trailing marker); selected item is the row under the cursor (pointer + highlight). Both can be on the same row. -- **Primary chat composer exception:** the main chat composer is compact and unboxed while it occupies one visual row, using `› ` as its prompt. It gains the normal rounded `╭ ╮ ╰ ╯` border only after wrapping or an explicit newline. Dialog and multi-field inputs retain the rounded-border rule. - -## 2. Colors - -- Always use **semantic tokens**: `chalk.hex(colors.)`. The repo's `chalk-named-color-guard` enforces this; **do not** use named colors like `chalk.red` / `chalk.gray`. -- `ThemeStyles` (`state.theme.styles.*()`) is an optional convenience wrapper. Either style is fine, but colors must come from `ColorPalette` tokens. -- Available semantic tokens are in `theme/colors.ts`: `primary` `accent` `text` `textStrong` `textDim` `textMuted` `border` `borderFocus` `success` `warning` `error` `status` … -- Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the background and `inverseText` for the foreground. Keep this pair at 4.5:1 contrast or higher. -- The runtime validates six-digit hex syntax for each color, but it does not enforce or repair color contrast. -- **Do not highlight keys in hint lines**: the whole hint line uses `textMuted`; do not color `Enter` / `Esc` / `D` separately. - -## 3. Standard list-dialog layout - -Use `model-selector` as the template. Top to bottom: +| 选中项指针 | `❯ `(`primary`) | `constant/symbols.ts` → `SELECT_POINTER` | +| 选中项文字 | `primary` + bold | `chalk.hex(colors.primary).bold` | +| 当前 / 激活项 | 行尾 ` ← current`(`success`) | `constant/symbols.ts` → `CURRENT_MARK` | +| 危险项 / 操作 | `error`(选中再加 bold) | `chalk.hex(colors.error)` | +| 危险确认 `[y/N]` | `warning` + bold | `chalk.hex(colors.warning)` | +| 开关项状态:开 | 名称后 ` enabled`(`success`) | `chalk.hex(colors.success)` | +| 开关项状态:关 | 名称后 ` disabled`(`textDim`) | `chalk.hex(colors.textDim)` | +| 列表 / 选择器边框 | 平直 `─`(`primary`),仅顶/底各一条 | — | +| 输入框边框 | 圆角 `╭ ╮ ╰ ╯`(`primary`) | — | + +- **不要**自造选中指针(`>` / `▶` / `→` 等);统一用 `SELECT_POINTER`。 +- **不要**用 `● ` / `(current)` 表示当前项;统一用 `CURRENT_MARK`(行尾、`success`、前置一个空格)。 +- 当前项与选中项**互相独立**:当前项是「现在生效的值」(行尾 marker),选中项是「光标所在行」(指针 + 高亮);两者可同时落在同一行。 + +## 2. 颜色 + +- 一律使用**语义 token**:`chalk.hex(colors.)`。仓库 `chalk-named-color-guard` 已强制此约定,**禁止** `chalk.red` / `chalk.gray` 等 named color。 +- `ThemeStyles`(`state.theme.styles.*()`)是可选的便捷封装;用与不用都可,但颜色必须来自 `ColorPalette` token。 +- 可用语义 token 见 `theme/colors.ts`:`primary` `accent` `text` `textStrong` `textDim` `textMuted` `border` `borderFocus` `success` `warning` `error` `status` … +- **hint 行不做键位高亮**:整行 `textMuted`,不给 `Enter` / `Esc` / `D` 等键位单独上色。 + +## 3. 列表 dialog 标准布局 + +以 `model-selector` 为准,自上而下逐行固定为: ``` -───────────────────────────────────────── ① top border (primary, full-width ─) - Select a model (type to search) ② title (primary+bold) + searchable suffix when query is empty (textMuted) - ↑↓ navigate · Enter select · Esc cancel ③ hint (textMuted, directly under title, no key highlighting) - ④ blank line - Search: gpt ⑤ search row: only when query is non-empty (` Search: ` primary + query text) - ❯ GPT-5 openai ⑥ list row: pointer + name (left) + secondary column (right, textMuted) - Pythinker K2 Pythinker Code ← current current item trailing ` ← current` (success) - ⑦ blank line - ▼ 3 more ⑧ scroll / match indicator: `▼ N more` without query, `x / y` with query -───────────────────────────────────────── ⑨ bottom border (primary, full-width ─) +───────────────────────────────────────── ① 顶部边框(primary,整宽 ─) + Select a model (type to search) ② 标题(primary+bold)+ 可搜索且无 query 时的后缀(textMuted) + ↑↓ navigate · Enter select · Esc cancel ③ hint(textMuted,紧贴标题,无键位高亮) + ④ 空行 + Search: gpt ⑤ 搜索行:仅在有 query 时出现(` Search: ` primary + query text) + ❯ GPT-5 openai ⑥ 列表项:指针 + 名称(左)+ 次要列(右,textMuted) + Kimi K2 Pythinker Code ← current 当前项行尾 ` ← current`(success) + ⑦ 空行 + ▼ 3 more ⑧ 滚动 / 匹配指示:无 query 时 `▼ N more`,有 query 时 `x / y` +───────────────────────────────────────── ⑨ 底部边框(primary,整宽 ─) ``` -Hard rules: +硬性约定: -- **Only one top `─` in the header**. Title is followed immediately by the hint; **no** extra `─` between them. The dialog has exactly two full-width `─` lines (top + bottom). -- **`(type to search)` appears only in the title suffix** (searchable list, empty query); the hint line **must not** repeat "type to search". -- **`Search:` row sits below the blank line and above the list**, rendered only when query is non-empty. -- Hint sits directly under the title (no blank line between); one blank line separates hint from body. -- Every line ends with `truncateToWidth(line, width)` so wide characters and narrow terminals do not overflow. +- **头部只有顶部一条 `─`**。标题下方紧跟 hint,**不得**再插一条 `─`。整个 dialog 全宽 `─` 仅 2 条(顶 + 底)。 +- **`(type to search)` 只出现在标题后缀**(可搜索且 query 为空时);hint 行**不再**重复出现「type to search」。 +- **`Search:` 行在空行之下、列表之上**,只在有 query 时渲染。 +- hint 紧贴标题(中间无空行);hint 与正文之间有 1 空行。 +- 每行最终经 `truncateToWidth(line, width)`,CJK / 窄终端不超宽。 -## 4. Hint lines and copy (English UI) +## 4. hint 行与文案词汇(英文 UI) -Each hint segment is **key + description**, separated by ` · ` (space-middle-dot). +每段 hint 形如「**键位 + 描述**」,段间用 ` · `(单空格中点)分隔。 -| Action | Key token | Description | Full segment | +| 动作 | 键位 token | 描述词 | 完整片段 | |---|---|---|---| -| Move | `↑↓` | navigate | `↑↓ navigate` | -| Page | `←→` or `PgUp/PgDn` | page | `←→ page` | -| Confirm / select | `Enter` | select | `Enter select` | -| Cancel / close | `Esc` | cancel | `Esc cancel` | -| Delete | `D` | delete | `D delete` | -| Clear search | `Backspace` | clear | `Backspace clear` | -| Switch provider | `Tab` | toggle provider | `Tab toggle provider` | -| Search (title suffix) | typing | — | `(type to search)` | +| 移动 | `↑↓` | navigate | `↑↓ navigate` | +| 翻页 | `←→` 或 `PgUp/PgDn` | page | `←→ page` | +| 确认 / 选中 | `Enter` | select | `Enter select` | +| 取消 / 关闭 | `Esc` | cancel | `Esc cancel` | +| 删除 | `D` | delete | `D delete` | +| 清空搜索 | `Backspace` | clear | `Backspace clear` | +| 切 provider | `Tab` | toggle provider | `Tab toggle provider` | +| 搜索(标题后缀) | 打字 | — | `(type to search)` | -- **Key tokens are capitalized** (`Enter` / `Esc` / `Tab` / `Backspace` / `D`); **descriptions are lowercase** (navigate / select / cancel / page / delete / clear). Direction glyphs `↑↓` / `←→` stay as-is. -- Direction glyphs use `↑↓` (not `▲/▼`). -- Leaving a dialog is always `cancel` (do not mix close / back / exit / dismiss). Domain-specific wording (e.g. approval reject) is an exception. -- Hints stay minimal by state: when a searchable list has no query, "type to search" already appears in the title suffix, so the hint does not repeat it; with a query, append `Backspace clear`. +- **键位 token 首字母大写**(`Enter` / `Esc` / `Tab` / `Backspace` / `D`),**描述词全小写**(navigate / select / cancel / page / delete / clear);方向符 `↑↓` / `←→` 原样。 +- 方向符统一 `↑↓`(不用 `▲/▼`)。 +- 「离开对话框」统一只说 `cancel`(不混用 close / back / exit / dismiss)。业务语义(如审批的 reject)例外。 +- hint 随状态精简:可搜索列表无 query 时,「type to search」在标题后缀已出现,hint 不重复;有 query 时 hint 追加 `Backspace clear`。 -## 5. Tab bar (`/model` provider switching) +## 5. Tab 条(`/model` 的 provider 切换) -`tabbed-model-selector` wraps the flat `model-selector` with provider tabs, styled like **AskUserQuestion** tabs: +`tabbed-model-selector` 在 flat `model-selector` 外包一层 provider tab,样式对齐 **AskUserQuestion** 的 tab: ``` Select a model (type to search) - Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint starts with Tab switching - ← blank line - All Pythinker Code openai ← tab bar: active tab filled background (selectionBg bg + inverseText fg + bold), others textMuted - ← blank line + Tab toggle provider · ↑↓ navigate · Enter select · Esc cancel ← hint 首项即 Tab 切换 + ← 空行 + All Pythinker Code openai ← tab 条:激活项填充背景(primary 底 + text 字 + bold),其余 textMuted + ← 空行 ❯ ... ``` -- Tab bar position: **below the hint line**, with **one blank line above and below** (separated from hint and list). -- Active tab: `chalk.bgHex(colors.selectionBg).hex(colors.inverseText).bold(\` ${label} \`)`; inactive: `chalk.hex(colors.textMuted)`. Visible widths must match so switching does not jitter. -- First tab is always `All` (all providers aggregated); **default to `All`**. Only pass `initialTabId` explicitly (e.g. after `/provider` add flow) to land on a specific provider tab. -- `Tab` / `Shift+Tab` cycle tabs; hint's first segment is `Tab toggle provider`. -- Current model in its tab still uses `❯` + ` ← current`; switching tabs does not lose positioning. +- tab 条位置:**在 hint 行下方**,且**上下各一空行**(与 hint、与列表都隔开)。 +- 激活 tab:`chalk.bgHex(colors.primary).hex(colors.text).bold(\` ${label} \`)`;非激活:`chalk.hex(colors.textMuted)`。两者可见宽度一致,切换不抖动。 +- 第一个 tab 恒为 `All`(聚合所有 provider);**默认停在 `All`**。仅当显式传 `initialTabId`(如 `/provider` 新增完跳转)才停在指定 provider tab。 +- `Tab` / `Shift+Tab` 循环切换;hint 行首项即 `Tab toggle provider`。 +- 当前模型在所在 tab 内仍以 `❯` + ` ← current` 标记,切 tab 不丢失定位。 -## 6. Keybindings +## 6. 键位 -| Action | Key | Detection | +| 动作 | 键 | 判定方式 | |---|---|---| -| Move | `↑` / `↓` | `matchesKey(data, Key.up/down)` | -| Page | `PgUp` / `PgDn` | `matchesKey(data, Key.pageUp/pageDown)` | -| Confirm / select | `Enter` | `matchesKey(data, Key.enter)` | -| Cancel / close | `Esc` | `matchesKey(data, Key.escape)` | -| Delete | `D` | `printableChar(data) === 'D'` (also accepts `'d'`) | -| Search | typing | `printableChar(data)` | +| 移动 | `↑` / `↓` | `matchesKey(data, Key.up/down)` | +| 翻页 | `PgUp` / `PgDn` | `matchesKey(data, Key.pageUp/pageDown)` | +| 确认 / 选中 | `Enter` | `matchesKey(data, Key.enter)` | +| 取消 / 关闭 | `Esc` | `matchesKey(data, Key.escape)` | +| 删除 | `D` | `printableChar(data) === 'D'`(也接受 `'d'`) | +| 搜索 | 打字 | `printableChar(data)` | -- **Character comparisons must go through `printableChar()`** (Kitty protocol), enforced by `printable-key-guard`; function keys use `matchesKey(data, Key.*)`. -- **Two-stage `Esc`**: when query is non-empty, clear query first (`list.clearQuery()`); only call `onCancel()` when query is empty. -- `←` / `→` are context-dependent: in components without paging they switch values (e.g. `/model` thinking effort); in lists like `choice-picker` they page. **Do not** use `←→` for paging in components that already use it for thinking effort. -- **Delete is always letter `D`** (`/provider`, `/plugins`). Letter keys require the list **not** be type-to-search (otherwise input goes into search). Current delete lists are not searchable; if a list needs both search and delete, delete must use a non-printable key. +- **字符比较必须经 `printableChar()`**(Kitty 协议),由 `printable-key-guard` 强制;功能键用 `matchesKey(data, Key.*)`。 +- **`Esc` 两段式**:有 query 时先清空 query(`list.clearQuery()`),无 query 时才 `onCancel()`。 +- `←` / `→` 不固定语义:无翻页结构的组件里承担「值切换」(如 `/model` 的 thinking on/off);`choice-picker` 这类无横向值的列表里用作翻页。**不要**在有 thinking 切换的组件里又拿 `←→` 翻页。 +- **删除键统一用字母 `D`**(`/provider`、`/plugins` 一致)。字母键要求该列表**不可 type-to-search**(否则会打进搜索框)——当前所有带删除动作的列表都不可搜索;若某列表既要搜索又要删除,删除须改用非打印键。 -## 7. Toggle lists and multi-select +## 7. 开关列表与多选(toggle / multi-select) -For per-row on/off lists (e.g. installed plugins in `/plugins`, MCP server lists). Unlike single-select (`Enter` commits and closes), toggle lists use `Space` to flip the current row in place without closing the dialog. +适用于「每行可独立开 / 关」的列表(如 `/plugins` 的已装插件、MCP server 列表)。区别于单选(`Enter` 选中即提交并关闭),开关列表用 `Space` 就地切换每行状态,dialog 不关闭。 ``` Plugins ↑↓ navigate · Space toggle · Enter details · Esc cancel - ← blank line - Installed plugins (2) ← section title (textStrong / bold) - ❯ Example Plugin enabled ← selected row (❯ + primary+bold name) + status label (success) - id example-plugin · 1 skill · MCP 1/1 · via code.pythinker.com · official ← secondary line (textMuted, ` · ` separated) - Superpowers disabled ← unselected row (text name) + off label (textDim) - id superpowers · 14 skills · via code.pythinker.com · curated + ← 空行 + Installed plugins (2) ← 分区标题(textStrong / 加粗) + ❯ Pythinker Datasource enabled ← 选中行(❯ + primary+bold 名称)+ 状态标签(success) + id pythinker-datasource · 1 skill · MCP 1/1 · via code.kimi.com · official ← 次要信息行(textMuted,` · ` 分隔) + Superpowers disabled ← 未选中行(text 名称)+ 关态标签(textDim) + id superpowers · 14 skills · via code.kimi.com · curated ``` -Rules: +约定: -- **`Space` toggles the current row** (on ↔ off), applies immediately, dialog stays open; hint includes `Space toggle`. -- **Status labels** follow the name with two spaces: on ` enabled` (`success`), off ` disabled` (`textDim`). Other semantics (`installed`=success, `install…`=primary) follow the same `statusStyle` pattern. -- `Enter` has a separate role in toggle lists (e.g. `Enter details`); it does not toggle. -- When multiple actions exist (toggle / details / delete / submenu), list every hint segment with capitalized keys: `Space toggle · Enter details · D remove` (see section 4). -- Rows may have one secondary line below (id / counts / source / trust level), `textMuted`, ` · ` separated. +- **`Space` 切换当前行状态**(开 ↔ 关),即时生效、dialog 保持打开;hint 含 `Space toggle`。 +- **状态标签**紧跟名称、空 2 格:开 ` enabled`(`success`)、关 ` disabled`(`textDim`)。其它语义(如 `installed`=success、`install…`=primary)按 `statusStyle` 同源处理。 +- `Enter` 在开关列表里另作他用(如「查看详情」`Enter details`),不承担 toggle。 +- 多套独立动作时(toggle / 详情 / 删除 / 进子菜单),hint 逐项列全,键位首字母大写:`Space toggle · Enter details · D remove`(参照第 4 节大小写规则)。 +- 行下可附 1 行次要信息(id / 数量 / 来源 / 信任级),`textMuted`、` · ` 分隔。 -## 8. Thinking control (`/model` only) +## 8. Thinking 控件(`/model` 专属) -Below the list, show the selected model's thinking effort levels as segments: +列表下方展示当前选中模型的 thinking 三态,外观固定 `[ On ] Off` 段式: -- Title: `Thinking (←→ to switch)` when the model offers more than one level; `Thinking` only otherwise. -- `toggle`: one segment per selectable level — `off` plus the model's `supportEfforts` (fallback `low / med / high`), e.g. `off low [ med ] high`; active segment `primary+bold`, labels via `shortEffortLabel` (`medium` → `med`). -- `always-on`: the supported levels without an `off` segment. -- `unsupported`: a single muted `Off (Unsupported)` (textMuted). -- `←` / `→` move the draft one level within the list (no wraparound); the draft commits on `Enter`. Availability/level helpers live in `utils/thinking-levels.ts` (`effortLevelsForModel`, `coerceEffortForModel`). +- 标题:`Thinking (←→ to switch)`(仅 `toggle` 态显示括号提示);其余态只显示 `Thinking`。 +- `toggle`:`[ On ] Off` / `On [ Off ]`,激活段 `primary+bold`。 +- `always-on`:`[ Always on ]`。 +- `unsupported`:`[ Off ]` + `unsupported`(textMuted)。 +- `←` / `→` 翻转草稿;提交时经 `effectiveThinking()` 归一(always-on→true、unsupported→false)。 -## 9. Multi-field inputs +## 9. 输入框(多字段) -- Rounded box `╭ ╮ ╰ ╯` (`primary`). -- Field switching: `Tab` / `Shift+Tab` / `↑` / `↓`. -- `Enter`: non-final field → advance; final field → submit. -- Cancel: `Esc` / `Ctrl+C` / `Ctrl+D`. -- Footer follows focus: non-final fields show `Enter next`, final field shows `Enter submit`. -- Required-field validation focuses in field order (e.g. custom-registry: empty URL → focus URL, empty token → focus token), with matching sub-prompt error state. +- 圆角盒 `╭ ╮ ╰ ╯`(`primary`)。 +- 字段切换:`Tab` / `Shift+Tab` / `↑` / `↓`。 +- `Enter`:非末段→推进到下一字段;末段→提交。 +- 取消:`Esc` / `Ctrl+C` / `Ctrl+D`。 +- footer 随焦点动态:非末段显示 `Enter next`,末段显示 `Enter submit`。 +- 必填校验按字段顺序定位(如 custom-registry:URL 空→定位 URL,token 空→定位 token),错误用对应的子提示态。 -## 10. Shared components (reuse; do not reinvent) +## 10. 共享组件(优先复用,不另起炉灶) -| Pattern | Component | +| 形态 | 组件 | |---|---| -| List cursor / search / paging state machine | `utils/searchable-list.ts` → `SearchableList` | -| Paged view | `utils/paging.ts` → `pageView` | -| Kitty printable chars | `utils/printable-key.ts` → `printableChar` / `isPrintableChar` (with guard) | -| Selection pointer / current marker | `constant/symbols.ts` → `SELECT_POINTER` / `CURRENT_MARK` | - -New list components **must reuse `SearchableList`** (cursor / search / paging) and manually align layout, keybindings, and copy with sections 3–8 of this file. - -## 11. Checklist for new / changed dialogs - -- [ ] Header follows section 3: top `─`, title (+ `(type to search)` suffix), hint, blank line, `Search:` row, list, bottom `─`; **no** inner `─` under the title. -- [ ] Hint line is all `textMuted`, **no** per-key highlighting; keys capitalized, descriptions lowercase, ` · ` separators. -- [ ] Selection pointer is `SELECT_POINTER`, current item is `CURRENT_MARK`; no custom `>` / `▶` / `→` / `● ` / `(current)`. -- [ ] All colors from `colors.`; no named colors. -- [ ] Keys: `↑↓` move, `PgUp/PgDn` page, `Enter` confirm, `Esc` cancel (searchable lists: two-stage Esc — clear query then close), `D` delete; character checks via `printableChar()`. -- [ ] Leaving a dialog says `cancel` only; no close / back / exit / dismiss mix. -- [ ] Toggle lists use `Space toggle` in place without closing; status labels ` enabled` (`success`) / ` disabled` (`textDim`) two spaces after the name (section 7). -- [ ] Long lists show scroll / page indicators (`▼ N more` or `x / y`); empty states are explicit (`No matches`, etc.). -- [ ] Every line uses `truncateToWidth(line, width)` so wide characters and narrow terminals do not overflow. -- [ ] Reuse `SearchableList`; input boxes use rounded borders; multi-field inputs support `Tab/↑↓` switching and Enter advance / final submit. -- [ ] Component tests cover render snapshots and `handleInput` key behavior. +| 列表光标 / 搜索 / 翻页状态机 | `utils/searchable-list.ts` → `SearchableList` | +| 分页视图 | `utils/paging.ts` → `pageView` | +| Kitty 可打印字符 | `utils/printable-key.ts` → `printableChar` / `isPrintableChar`(含 guard) | +| 选中指针 / 当前项标记 | `constant/symbols.ts` → `SELECT_POINTER` / `CURRENT_MARK` | + +新列表组件**必须复用 `SearchableList`**(光标 / 搜索 / 翻页),并手工对齐本文件第 3–8 节的布局、键位、文案。 + +## 11. 新增 / 改造 dialog 自查清单 + +- [ ] 头部按第 3 节:顶部一条 `─`、标题(+`(type to search)` 后缀)、hint、空行、`Search:` 行、列表、底部一条 `─`;标题下**无**内层 `─`。 +- [ ] hint 整行 `textMuted`,**不**做键位高亮;键位首字母大写、描述词小写、` · ` 分隔。 +- [ ] 选中指针用 `SELECT_POINTER`,当前项用 `CURRENT_MARK`,未自造 `>` / `▶` / `→` / `● ` / `(current)`。 +- [ ] 颜色全部来自 `colors.`,无 named color。 +- [ ] 键位:`↑↓` 移动、`PgUp/PgDn` 翻页、`Enter` 确认、`Esc` 取消(可搜索列表 `Esc` 两段式:先清 query 再关闭)、`D` 删除;字符比较经 `printableChar()`。 +- [ ] 「离开对话框」只说 `cancel`,不混用 close / back / exit / dismiss。 +- [ ] 开关列表用 `Space toggle` 就地切换、不关闭;状态标签 ` enabled`(`success`) / ` disabled`(`textDim`) 紧跟名称空 2 格(见第 7 节)。 +- [ ] 长列表有滚动 / 翻页指示(`▼ N more` 或 `x / y`),空态文案明确(`No matches` 等)。 +- [ ] 每行经 `truncateToWidth(line, width)`,CJK / 窄终端下不超宽。 +- [ ] 复用 `SearchableList`;输入框圆角盒,多字段支持 `Tab/↑↓` 切换、Enter 推进 / 末段提交。 +- [ ] 有对应的组件测试(render 快照 + handleInput 键行为)。 diff --git a/.agents/skills/write-tui/SKILL.md b/.agents/skills/write-tui/SKILL.md index e7ae0398..45088ab8 100644 --- a/.agents/skills/write-tui/SKILL.md +++ b/.agents/skills/write-tui/SKILL.md @@ -25,7 +25,7 @@ For any list dialog, selector, input box, or status/toggle list, the interaction - `src/tui/commands/` — slash-command declaration, parsing, ordering, and dynamic skill-command generation. Parsing and types only; execution is dispatched from `PythinkerTUI`'s slash-command handler section, and complex execution sinks into `utils` or focused components. - `src/tui/components/` — pi-tui components by UI type: `chrome/` (footer, todo, welcome, loader, device code), `dialogs/` (selectors, approval/question panels, settings popups that replace the editor), `editor/` (input box + mention provider), `media/` (image, diff, code highlight), `messages/` (transcript blocks + tool-renderers), `panes/` (activity, queue). - `src/tui/reverse-rpc/` — adapts SDK approval/question callbacks into UI panel data and the user's choice back into an SDK response. -- `src/tui/theme/` — themes, color tokens, Pythinker markdown/editor theme adapters, terminal-background detection. The single source of truth for color. +- `src/tui/theme/` — themes, color tokens, style helpers, pi-tui markdown theme, terminal-background detection. The single source of truth for color. - `src/tui/utils/` — TUI-only utilities (need `TUIState` or a component). App-wide, UI-independent helpers go in `src/utils/`. When a controller or `PythinkerTUI` section keeps growing, split pure functions, state projections, and presentation components into the matching directory rather than expanding the file. @@ -62,12 +62,13 @@ The feature type decides the landing spot: Themes are managed centrally under `src/tui/theme/`: - `colors.ts` — semantic tokens: `ColorPalette`, `darkColors`, `lightColors`. -- `pythinker-theme.ts` — Pythinker markdown and editor theme adapters (`createPythinkerMarkdownTheme`, `createPythinkerEditorTheme`). -- `theme.ts` — the global `Theme` singleton and `currentTheme` accessor. +- `styles.ts` — common chalk helpers built on top of `ColorPalette`. +- `pi-tui-theme.ts` — the markdown/pi-tui theme config. - `terminal-background.ts` — terminal background detection used by auto resolution. +- `bundle.ts` — packs `colors`, `styles`, `markdownTheme` into a `PythinkerTUIThemeBundle`. - `index.ts` / `detect.ts` — theme type and auto/dark/light resolution. -> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/pythinker-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`). +> **Keep the color-token set in sync.** `ColorPalette` in `colors.ts` is the source of truth for color tokens. When you add, rename, or remove one, update its mirrors in the same change: the custom-theme JSON schema (`apps/pythinker-code/src/tui/theme/theme-schema.json`), the token tables in the custom-theme docs (`docs/en/customization/themes.md` and `docs/zh/customization/themes.md`), and the token table in the `custom-theme` built-in skill (`packages/agent-core/src/skill/builtin/custom-theme.md`). Apply / switch flow: diff --git a/.changeset/README.md b/.changeset/README.md index 21a91350..1acc9cc7 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -20,14 +20,12 @@ All other workspace packages are private internal packages, are not published to - `@pymodel/kaos` - `@pymodel/pythinker-code-oauth` - `@pymodel/pythinker-telemetry` -- `@pymodel/pythinker-web` - `@pymodel/kosong` +- `@pymodel/migration-legacy` - `@pymodel/protocol` -- `@pymodel/server` -- `@pymodel/server-e2e` -- `@pymodel/dashboard` -- `@pymodel/dashboard-server` -- `@pymodel/dashboard-web` +- `@pymodel/vis` +- `@pymodel/vis-server` +- `@pymodel/vis-web` Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@pymodel/*` internal workspace packages. diff --git a/.changeset/clean-staged-media.md b/.changeset/clean-staged-media.md new file mode 100644 index 00000000..01a56342 --- /dev/null +++ b/.changeset/clean-staged-media.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep pasted image and video attachments available in session history, and clean up temporary uploads automatically. diff --git a/.changeset/config.json b/.changeset/config.json index 6ca02f02..c63ff2ea 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "changelog": ["@changesets/changelog-github", { "repo": "PyModel/pythinker-code", "disableThanks": true }], + "changelog": ["@changesets/changelog-github", { "repo": "PyModel/pythinker-code" }], "commit": false, "fixed": [], "linked": [], @@ -7,19 +7,10 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@pymodel/acp-adapter", - "@pymodel/agent-core", - "@pymodel/kaos", - "@pymodel/pythinker-code-oauth", - "@pymodel/pythinker-telemetry", - "@pymodel/pythinker-web", - "@pymodel/kosong", - "@pymodel/protocol", - "@pymodel/server", - "@pymodel/server-e2e", - "@pymodel/dashboard", - "@pymodel/dashboard-server", - "@pymodel/dashboard-web" + "@pymodel/vis", + "@pymodel/vis-server", + "@pymodel/vis-web", + "@pymodel/pythinker-inspect" ], "snapshot": { "useCalculatedVersion": true, diff --git a/.changeset/daemon-file-ref-drop-path.md b/.changeset/daemon-file-ref-drop-path.md new file mode 100644 index 00000000..281418a2 --- /dev/null +++ b/.changeset/daemon-file-ref-drop-path.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code-sdk": minor +--- + +Daemon file references no longer persist a materialization path: the daemon-file URL builder takes only a file id, and the parsed reference no longer carries a `path` field. The display path is derived from the session media store at read time, so a session fork or home relocation can no longer stale a persisted reference. Urls with a legacy `?path=` query still parse. diff --git a/.changeset/fix-gemini-thought-signature.md b/.changeset/fix-gemini-thought-signature.md new file mode 100644 index 00000000..aef0670f --- /dev/null +++ b/.changeset/fix-gemini-thought-signature.md @@ -0,0 +1,6 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-code-sdk": patch +--- + +Fix Gemini tool-calling sessions failing on follow-up requests: preserve the tool-call thought signature and keep trailing user text before function results. diff --git a/.changeset/fix-utf8-text-binary-detection.md b/.changeset/fix-utf8-text-binary-detection.md new file mode 100644 index 00000000..4a6f3e6d --- /dev/null +++ b/.changeset/fix-utf8-text-binary-detection.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix UTF-8 text files containing Chinese or emoji being misdetected as binary, so log files preview correctly in the web UI. diff --git a/.changeset/fork-print-resume-command.md b/.changeset/fork-print-resume-command.md new file mode 100644 index 00000000..3aa55e1e --- /dev/null +++ b/.changeset/fork-print-resume-command.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Print the full `pythinker --resume` command after `/fork` and copy it to the clipboard, so the fork can be entered directly from a new CLI process. diff --git a/.changeset/goal-objective-length-warning.md b/.changeset/goal-objective-length-warning.md new file mode 100644 index 00000000..607177ed --- /dev/null +++ b/.changeset/goal-objective-length-warning.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Warn in the footer while a typed `/goal` objective exceeds the 4000-character limit, and restore the input instead of losing it when an over-limit objective is rejected. The error message now suggests putting long content in a file and referencing the file path. diff --git a/.changeset/goal-objective-too-long-message.md b/.changeset/goal-objective-too-long-message.md new file mode 100644 index 00000000..b35292a8 --- /dev/null +++ b/.changeset/goal-objective-too-long-message.md @@ -0,0 +1,6 @@ +--- +"@pymodel/agent-core": patch +"@pymodel/agent-core-v2": patch +--- + +Include the file-reference workaround in the `GOAL_OBJECTIVE_TOO_LONG` error message so clients surface how to submit long objectives. diff --git a/.changeset/inline-multi-skill-sdk.md b/.changeset/inline-multi-skill-sdk.md new file mode 100644 index 00000000..7b66344b --- /dev/null +++ b/.changeset/inline-multi-skill-sdk.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code-sdk": minor +--- + +Add `session.promptWithSkills(input, skills)` to submit one prompt with one or more skill activations bundled into the same user message — one turn, one undo unit (v2 engine only; rejects on the v1 engine). diff --git a/.changeset/inline-multi-skill-tui.md b/.changeset/inline-multi-skill-tui.md new file mode 100644 index 00000000..f2f4252f --- /dev/null +++ b/.changeset/inline-multi-skill-tui.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Activate multiple skills in a single prompt. Type `/` after whitespace to insert a skill token; all referenced skills run with the prompt as one turn (and undo as one unit). diff --git a/.changeset/inline-slash-trigger-pi-tui.md b/.changeset/inline-slash-trigger-pi-tui.md new file mode 100644 index 00000000..dbeccca7 --- /dev/null +++ b/.changeset/inline-slash-trigger-pi-tui.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pi-tui": patch +--- + +Add an opt-in inline slash autocomplete trigger that fires after whitespace mid-input and at the start of subsequent editor lines. diff --git a/.changeset/lazy-global-search-startup.md b/.changeset/lazy-global-search-startup.md new file mode 100644 index 00000000..d8056ba9 --- /dev/null +++ b/.changeset/lazy-global-search-startup.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix several seconds of startup lag: the global search index (used only by the web UI's search) was being opened and synced in every terminal session, including ones that never search. It now loads on demand, so interactive startup stays fast. diff --git a/.changeset/media-registrar-stale-alias.md b/.changeset/media-registrar-stale-alias.md new file mode 100644 index 00000000..bea0fd5a --- /dev/null +++ b/.changeset/media-registrar-stale-alias.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix an `[unexpected] Error2: Model "" is not configured in config.toml` error printed on startup when a restored session references a model that is no longer configured (e.g. after logging out of the managed Pythinker Code account). Media tool registration now degrades gracefully instead of throwing from the `agent.status.updated` listener. diff --git a/.changeset/persist-token-counting-ledger.md b/.changeset/persist-token-counting-ledger.md new file mode 100644 index 00000000..a9590e41 --- /dev/null +++ b/.changeset/persist-token-counting-ledger.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Persist the token counting ledger (`token_counting.measured` / `truncated` / `rebased`) to the wire journal, so the displayed context size keeps its measured value after archiving and unarchiving a session (or any close → resume) instead of dropping to a smaller estimate until the next LLM call. diff --git a/.changeset/queue-skill-commands-while-busy.md b/.changeset/queue-skill-commands-while-busy.md new file mode 100644 index 00000000..2262cd43 --- /dev/null +++ b/.changeset/queue-skill-commands-while-busy.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Queue slash skill commands entered while the agent is busy instead of rejecting them with "Cannot / while streaming" — they now behave exactly like normal input: queued visibly by default, and Ctrl-S steers them into the running turn as real skill activations. diff --git a/.changeset/sdk-upload-file.md b/.changeset/sdk-upload-file.md new file mode 100644 index 00000000..e997c348 --- /dev/null +++ b/.changeset/sdk-upload-file.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code-sdk": minor +--- + +Add `uploadFile` for uploading media to the engine's file store and referencing it from prompts, plus an optional `promptId` on prompt submissions for correlating them with turn-started events. Both require the v2 harness. diff --git a/.changeset/tower-slash-command.md b/.changeset/tower-slash-command.md new file mode 100644 index 00000000..bfb19f9e --- /dev/null +++ b/.changeset/tower-slash-command.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add the /tower slash command to orchestrate multiple agents iterating on one repo in parallel — you act as the control tower while worker agents execute missions in their own git worktrees. Run /tower to start. diff --git a/.changeset/v1-mcp-management-plane.md b/.changeset/v1-mcp-management-plane.md new file mode 100644 index 00000000..3b3e8b3d --- /dev/null +++ b/.changeset/v1-mcp-management-plane.md @@ -0,0 +1,8 @@ +--- +"@pymodel/pythinker-code": patch +"@pymodel/pythinker-code-sdk": minor +--- + +On the legacy engine, plugin MCP server changes (install / enable / disable / remove / reload) now apply to open sessions immediately, and an MCP server OAuth sign-in or credential reset automatically refreshes the affected sessions instead of leaving them stuck until a manual reconnect; a connection that fails mid-session for auth reasons is now reported as needing sign-in rather than as a generic failure. + +`@pymodel/pythinker-code-sdk`: the MCP management surface is now backed by a unified, source-tagged registry — `listMcpServers` also covers plugin-declared servers (read-only, with their effective config) and returns `source` / `origin` / `mutable` markers; new `getMcpServer` for a single effective config; `testMcpServerConfig` probes an unsaved inline config; sessions can connect a server at runtime via `addMcpServer` with an optional persist flag; `reconnectMcpServer` accepts an optional replacement config and otherwise re-resolves the current config instead of reusing a stale snapshot; `listMcpServerAuthStatuses` accepts `cwd` / `verify` (online probe) and distinguishes dead grants via the new `oauth-expired` state; stored OAuth grants now record their absolute expiry and are refreshed proactively and single-flight per credential. Session status entries and read-only management entries redact secret-bearing stdio `env` / remote `headers` values to key lists, and concurrent logins for the same credential join a single browser flow. A new app-level inspection, `inspectAppMcpServers`, reports every server's effective config and real (probe-verified) authorization state — including plugin servers and runtime-name collisions — and the OAuth flow RPCs have locator-addressed variants (`authenticateAppMcpServer` / `resetAppMcpServerAuth`) so plugin servers can be signed in and reset directly. diff --git a/.changeset/web-title-flag.md b/.changeset/web-title-flag.md new file mode 100644 index 00000000..2624a25c --- /dev/null +++ b/.changeset/web-title-flag.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add `pythinker web --web-title ` to set a custom browser tab title for the web UI instance, so multiple instances on different machines are easy to tell apart; without the flag the tab title shows the active workspace's directory name. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..b3726523 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Enforce LF line endings in the working tree on every platform so that +# raw-imported text (e.g. `*.md?raw` templates) is byte-identical on Windows +# and POSIX. Without this, Git for Windows' default `core.autocrlf=true` +# checks text files out as CRLF, which shifts token-count snapshots. +* text=auto eol=lf + +# Binary assets — never normalize line endings. +*.gif binary +*.ico binary +*.png binary diff --git a/.github/workflows/_native-build.yml b/.github/workflows/_native-build.yml index 22adf349..89546576 100644 --- a/.github/workflows/_native-build.yml +++ b/.github/workflows/_native-build.yml @@ -98,8 +98,8 @@ jobs: echo "PYTHINKER_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV" - name: Build Pythinker web assets - # The SEA blob step embeds apps/pythinker-code/dist-web; build the web app - # and stage its assets before producing the native executable. + # The SEA blob step embeds apps/pythinker-code/dist-web. This repo keeps + # the web source (apps/pythinker-web), so build it and stage the bundle. run: | pnpm --filter @pymodel/pythinker-web run build node apps/pythinker-code/scripts/copy-web-assets.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11c02f39..847e12de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,10 @@ jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5] steps: - uses: actions/checkout@v4 @@ -42,7 +46,57 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm run test + - run: pnpm run test --shard=${{ matrix.shard }}/5 + + test-complete: + name: test + if: always() + needs: test + runs-on: ubuntu-latest + + steps: + - name: Verify all test shards passed + if: needs.test.result != 'success' + run: exit 1 + + # pi-tui's suite runs on node:test (not vitest), so the root `pnpm run test` + # does not execute it; it needs its own job. + test-pi-tui: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @pymodel/pi-tui test + + test-windows: + runs-on: windows-latest + # Temporarily disabled while Windows tests are being stabilized. + if: false + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + # Windows runners are slower and run the whole suite (including + # in-process e2e tests) under more contention, so the default 5s test + # timeout causes flaky failures. Give it more headroom. + - run: pnpm run test -- --testTimeout=30000 lint: runs-on: ubuntu-latest @@ -82,6 +136,8 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck VS Code extension + run: pnpm --filter pythinker-code run typecheck - name: Typecheck pythinker-web (vue-tsc) run: pnpm --filter @pymodel/pythinker-web run typecheck - name: Typecheck dashboard-server diff --git a/.github/workflows/pkg-pr-new.yml b/.github/workflows/pkg-pr-new.yml index 45735a77..e27ab655 100644 --- a/.github/workflows/pkg-pr-new.yml +++ b/.github/workflows/pkg-pr-new.yml @@ -36,9 +36,6 @@ jobs: - name: Build package dependencies run: pnpm run build:packages - - name: Build Pythinker web assets - run: pnpm --filter @pymodel/pythinker-web run build - - name: Generate Pythinker Code built-in catalog shell: bash run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c8b5fa..cf7599ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,7 +76,7 @@ jobs: registry-url: "https://registry.npmjs.org" - name: Upgrade npm for Trusted Publishing - run: npm install -g npm@latest + run: npm install -g npm@11 - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 1ade961a..0ee2b051 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/ -dist-web/ dist-single/ dist-native/ .tmp-api-extractor/ @@ -40,3 +39,17 @@ plan/ # local agent docs (machine-specific, never commit) AGENTS.local.md CLAUDE.md + +# merged from upstream sync 2026-08 +.contract-types-tmp/ +.local/ +.vite/ +.pythinker-code/local.toml +.pythinker-sandbox/ +.vscode/ +!apps/vscode/.vscode/ +!apps/vscode/.vscode/*.json +HANDOVER*.md +HANDOFF*.md +handoff.md +handover.md diff --git a/.oxlintrc.json b/.oxlintrc.json index 28c1821a..ced4ead3 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -122,6 +122,30 @@ "eslint/no-console": "off" } }, + { + // The worker closures: these modules (and everything + // packages/minidb/src/worker/ and + // packages/kap-server/src/search/worker/ pull in) are loaded by a bare + // node:worker_threads Worker under Node's native type stripping with + // `execArgv: ['--experimental-transform-types']`, which requires + // explicit `.ts` import specifiers (the strip loader does not remap + // `.js` -> `.ts`). Keep the exception scoped to exactly those closures. + "files": [ + "packages/minidb/src/worker/**/*.ts", + "packages/minidb/src/codec.ts", + "packages/minidb/src/crc32.ts", + "packages/minidb/src/trigram.ts", + "packages/minidb/src/text-postings.ts", + "packages/minidb/src/text-index/tokenize.ts", + "packages/minidb/src/gen-codec.ts", + "packages/kap-server/src/search/worker/**/*.ts", + "packages/kap-server/src/search/indexCore.ts", + "packages/kap-server/src/search/match.ts" + ], + "rules": { + "import/extensions": "off" + } + }, { "files": ["packages/kosong/src/providers/**/*.ts"], "rules": { @@ -178,11 +202,12 @@ ], "ignorePatterns": [ "dist/", + "dist-web/", "coverage/", "node_modules/", "apps/*/scripts/", "docs/smoke-archive/", - "plugins/curated/superpowers/", + "packages/pi-tui/", "*.generated.ts" ] } diff --git a/AGENTS.md b/AGENTS.md index 6d843f54..483c6393 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add | ------- | ----------- | ----- | | `apps/pythinker-code` | CLI / TUI app | Consumes `@pymodel/pythinker-code-sdk`; no `agent-core` dep. Use `write-tui` skill. | | `apps/pythinker-web` | Browser UI (Vue 3 + Vite + vue-i18n) | REST + WS `/api/v1`; no `agent-core` dep. See its `AGENTS.md`. | -| `apps/dashboard` | Session dashboard & replay | `server/` + `web/` subdirs. | +| `apps/vis` | Session replay & debugging visualizer | `server/` + `web/` subdirs. | | `packages/agent-core` | Agent engine | Agent, Session, profile, skills, tools, plan, permission, DI. | | `packages/node-sdk` | Public TS SDK & harness | | | `packages/kosong` | LLM provider abstraction | Wire types, catalog, capability registry. | @@ -58,7 +58,6 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add | `packages/oauth` | Auth utilities | | | `packages/telemetry` | Client-side telemetry | | | `packages/server` | Server | Hosts `agent-core` over REST + WS `/api/v1`. See its `AGENTS.md`. | -| `packages/server-e2e` | E2E tests | `PYTHINKER_SERVER_URL` (default `http://127.0.0.1:58627`). See its `AGENTS.md`. | ## Environment diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2314fd51 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# Repository-level Agent Guide + +Reply in the same language as the user. + +This is a TypeScript monorepo built for agent-assisted development. Keep the root `AGENTS.md` limited to hot-path rules: the project map, hard constraints, and workflow requirements — things every task needs to know. + +## Working Principles + +- Think from first principles. Start from real requirements, code facts, and verification results; if the goal is unclear, discuss it with the user first. +- Treat code, not documentation, as the source of truth. Unless the user explicitly says otherwise, do not read ordinary Markdown just to understand the implementation. +- Before making code changes, read the relevant code and the most recent constraints, and follow the nearest `AGENTS.md` in the directory tree. +- Keep changes focused. Do not slip in unrelated refactors along the way. +- When committing, do not add any co-author attribution, and do not reveal the identity of the agent in commit messages, PR descriptions, or any explanatory text. + +## Project Map + +- `apps/pythinker-code`: the CLI / TUI application. It consumes core capabilities through `@pymodel/pythinker-code-sdk` and must not depend directly on `@pymodel/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). +- the browser web UI: **its source no longer lives in this repo.** It is developed in the code-app repo (`apps/web`) and shipped as the committed, prebuilt bundle `apps/pythinker-code/dist-web` (gitignored, force-added), synced from code-app with `PYTHINKER_CODE_REPO=<this checkout> pnpm run sync:web` — sync and commit the bundle in the same change whenever the web UI should ship differently. `apps/pythinker-code/scripts/check-web-assets.mjs` guards packaging against a missing bundle. To hack on the web UI against this repo's server, run `pnpm dev:server` here and point code-app's `pnpm dev:web` at it via `PYTHINKER_SERVER_URL`. +- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. +- `apps/pythinker-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/pythinker-inspect/AGENTS.md`. +- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. See `packages/agent-core/AGENTS.md`. +- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind kap-server). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/node-sdk`: the public TypeScript SDK and harness. +- `packages/kosong`: the LLM / provider abstraction layer. +- `packages/kaos`: the execution environment and file/process abstractions. +- `packages/oauth`: Pythinker OAuth and managed auth utilities. +- `packages/telemetry`: shared client-side telemetry infrastructure. +- `packages/transcript`: the isomorphic transcript rendering data layer — L1 agent-granular store, L2 idempotent operations, L3 `off/turn/block/delta` subscription granularity, L4 framework-free view registry, plus turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports); the sole owner of the transcript contract types (`src/contract/`) and the op-batch sequencing contract. See `packages/transcript/AGENTS.md`. +- `packages/kap-server`: the Pythinker Code server, backed by `@pymodel/agent-core-v2`; exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`), plus the `/api/v1/debug/*` reflection RPC surface (`--debug-endpoints`, loopback bind + bearer auth). See `packages/kap-server/AGENTS.md`. +- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 (`global.*` / `session(id).*` / `agent(id).*`, zod-validated); transport via subpath entry (`@pymodel/klient/ipc|memory`, both return the same `Klient`); also hosts the e2e suites. See `packages/klient/AGENTS.md`. +- `packages/tree-sitter-bash`: a pure-TypeScript bash parser (no runtime deps, no wasm); `parse(source, { timeoutMs, maxNodes })` runs under a deterministic budget and returns a discriminated `ParseResult` — callers must treat aborted/hasError trees as "cannot analyze" and degrade. Parser only, no safety judgments; see the package README's "Known differences" section. +- `packages/minidb`: the embedded JSON document store (`MiniDb`) behind kap-server's search index — snapshot + WAL persistence with an exclusive write lock, a larger-than-RAM full-text layer, and persistent index generations. See `packages/minidb/AGENTS.md`. + +## Environment Requirements + +- **Node.js**: `>=24.15.0` (from the root `package.json` `engines`; `.nvmrc` is `24.15.0`, used by nvm / fnm / mise to pick the minimum recommended version). +- **pnpm**: `10.33.0` (from the root `package.json` `packageManager`). +- `pnpm install` will fail when the Node version is not satisfied, because `.npmrc` sets `engine-strict=true`. + +## Monorepo Workspace Maintenance + +- `pnpm-workspace.yaml` is the source of truth for workspace membership, but `flake.nix` also contains **hardcoded** `workspacePaths` and `workspaceNames` lists. +- **Whenever you add or remove a workspace package, you MUST update both `pnpm-workspace.yaml` and `flake.nix` — for every package, including leaf / test / e2e packages that nothing depends on.** + - `pnpm-workspace.yaml` uses globs (`packages/*`, `apps/*`), so most packages land there automatically; `flake.nix` is fully manual and is where omissions happen. + - Missing a path in `flake.nix`'s `workspacePaths` will silently drop files from the Nix build's `src` fileset. + - Missing a name in `flake.nix`'s `workspaceNames` will break `pnpmConfigHook` because dependencies for that workspace will not be fetched. +- The automated "Check flake.nix workspace sync" (`scripts/check-nix-workspace.mjs`) only validates the transitive dependency **closure of `@pymodel/pythinker-code`**. A leaf package outside that closure (e.g. an e2e package nobody imports) slips through even when it is missing from `flake.nix`. A green check is therefore NOT proof that `flake.nix` is fully in sync — keep it updated by hand on every add/remove, do not rely on the check to catch omissions. + +## General Coding Rules + +- `packages/agent-core-v2`, `packages/kap-server`, and `packages/transcript` are comment-free zones: no line/block comments; the exceptions are JSDoc attached to exported symbols and load-bearing lint-suppression directives (`oxlint-disable` / `eslint-disable`), while other tooling directives (`@ts-expect-error`, …) stay banned. Enforced by `scripts/check-no-comments.mjs`, which runs as part of `pnpm lint`. +- For optional object properties, pass `undefined` directly instead of using conditional spread. + - YES: `{ user }` + - NO: `{ ...(user ? { user } : undefined) }` +- Optional object properties do not need to additionally allow `undefined` in the type. + - YES: `interface Options { user?: User }` + - NO: `interface Options { user?: User | undefined }` +- Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. +- Except for a package's `index.ts`, other `index.ts` files should prefer `export * from './module';`. +- Do not add too many new test files. Prefer adding tests to the existing test file of the corresponding component or module. +- When a test fails because of a user modification, default to fixing the test first; do not change the implementation to satisfy an old test unless the implementation truly has a bug. +- Do not sacrifice code quality for external compatibility unless the user explicitly asks for it. Breaking changes go through changesets and a `major` bump, gated by the rule below. + +## Experimental Features + +- Gate a not-yet-public feature behind an experimental flag. Flags are env-driven and default off: `PYTHINKER_CODE_EXPERIMENTAL_<NAME>` toggles one, `PYTHINKER_CODE_EXPERIMENTAL_FLAG` enables all. Release by flipping the entry's `default` to `true`. + - `packages/agent-core` (v1): add the flag to the central registry at `packages/agent-core/src/flags/registry.ts`, then check it with `flags.enabled('my-feature')`. + - `packages/agent-core-v2` and kap-server modules: there is no central catalog — declare the flag in the owning domain via `registerFlagDefinition` at import time (see `packages/agent-core-v2/docs/flag.md`), then check it with `IFlagService.enabled(id)`. Current search-index-separation flags: `persistence_minidb_readmodel` (session read model, default on) and `search_worker` (global search worker host, default on). + +## Where to Update Instructions + +- Hard rules that affect almost every task: update the root `AGENTS.md`. +- Rules that only affect a specific directory: update the nearest sub-directory `AGENTS.md`. +- Project-map entries stay at 1–2 sentences; deep package docs live in the package's own `AGENTS.md`. +- Keep instruction updates focused and supported by code facts. + +## Workflow Requirements + +- Prefer `rg` / `rg --files` when reading code. +- When designing changes, follow existing boundaries and local patterns first. +- In public text and test data, replace real internal identifiers with neutral placeholders such as `example.com`, `example.test`, and `YOUR_API_KEY`. Before opening a PR, ask a read-only agent to audit the diff for context-specific internal identifiers. +- When creating a PR, the PR title must follow Conventional Commit style, e.g. `chore: remove legacy format commands`. +- When an AI agent opens or updates a PR, fill in `.github/pull_request_template.md` — link the related issue or explain the problem, then describe what changed. Do not leave placeholder text or submit a generic summary of the diff. +- Do not submit vague AI-generated PR text. The human author must understand the change well enough to explain the code, edge cases, and why the approach fits this repository. +- After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules. +- When generating a changeset, **never** decide on a `major` bump on your own — stop, explain, and get explicit user confirmation first; default to `minor`, fall back to `patch`. See `.agents/skills/gen-changesets/SKILL.md`. +- Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`. +- Do not commit throwaway scratch or exploratory files. Never stage: + - Agent working notes or handoff/summary documents (e.g. `HANDOVER-*.md`, `HANDOFF-*.md`, `handoff.md`). + - Throwaway UI/UX prototypes or design mockups (e.g. `*-designs.html`, `*-mockup.html`, `*-demo(s).html`) at the repo root or under a `design/` folder. The only tracked `.html` files should be Vite `index.html` entrypoints. + Before committing or opening a PR, run `git status` and `git diff --staged --stat` and remove anything matching these patterns. Put scratch work under `.tmp/` (gitignored) instead of the repo root or the source tree. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 270b58ab..28da25df 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ We only merge PRs aligned with the roadmap. Drive-by refactors without context a This is a pnpm monorepo. The most relevant entry points are: - `apps/pythinker-code` — CLI / TUI -- `apps/dashboard` — session replay & debugging visualizer +- `apps/vis` — session replay & debugging visualizer - `packages/node-sdk` — public TypeScript SDK (`@pymodel/pythinker-code-sdk`) - `packages/agent-core`, `kosong`, `kaos`, `oauth`, `telemetry` — internal engine packages - `docs/` — VitePress bilingual docs site diff --git a/_typos.toml b/_typos.toml index 1089fc23..7f5dbd64 100644 --- a/_typos.toml +++ b/_typos.toml @@ -26,3 +26,11 @@ dows = "dows" # formatDows — days-of-week (cron) fo = "fo" # `/FO` flag of Windows schtasks pn = "pn" # "PNGs" tokenized as PN by the checker iterm = "iterm" # iTerm2 terminal app identifier +bject = "bject" # regex fragment matching z.object and zObject +nin = "nin" # Mongo-style $nin query operator +ba = "ba" # before/after design-system CSS class prefix +ot = "ot" # order-type binary codec variable +als = "als" # AsyncLocalStorage field abbreviation +oint = "oint" # LaTeX contour-integral command +hom = "hom" # LaTeX homomorphism command +multline = "multline" # LaTeX multiline equation environment diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json deleted file mode 100644 index bd73e4b3..00000000 --- a/apps/dashboard/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@pymodel/dashboard", - "version": "0.1.1", - "private": true, - "description": "Session dashboard and debugging tool for pythinker-code sessions", - "license": "MIT", - "type": "module", - "scripts": { - "build:deps": "pnpm --filter @pymodel/agent-core... build", - "predev": "pnpm run build:deps", - "dev": "node scripts/dev.mjs", - "build": "pnpm run build:deps && pnpm --filter @pymodel/dashboard-server build && pnpm --filter @pymodel/dashboard-web build && node scripts/copy-web-dist.mjs", - "prestart": "pnpm run build", - "start": "node server/dist/server.mjs" - }, - "devDependencies": { - "concurrently": "^9.2.4" - } -} diff --git a/apps/dashboard/server/package.json b/apps/dashboard/server/package.json deleted file mode 100644 index d2d8b5db..00000000 --- a/apps/dashboard/server/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@pymodel/dashboard-server", - "version": "0.1.1", - "private": true, - "license": "MIT", - "type": "module", - "imports": { - "#/*": [ - "./src/*.ts", - "./src/*/index.ts" - ] - }, - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./start": { - "types": "./src/start.ts", - "default": "./src/start.ts" - }, - "./package.json": { - "types": "./package.json", - "default": "./package.json" - } - }, - "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsdown", - "test": "vitest run", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@hono/node-server": "^2.0.5", - "@pymodel/agent-core": "workspace:^", - "@pymodel/kosong": "workspace:^", - "hono": "^4.13.0" - }, - "devDependencies": { - "tsx": "^4.23.5", - "vitest": "4.1.9" - } -} diff --git a/apps/dashboard/server/src/config.ts b/apps/dashboard/server/src/config.ts deleted file mode 100644 index c43445b4..00000000 --- a/apps/dashboard/server/src/config.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -/** Resolve PYTHINKER_CODE_HOME (env > ~/.pythinker-code). */ -export function resolvePythinkerCodeHome(): string { - const envHome = process.env['PYTHINKER_CODE_HOME']; - if (envHome !== undefined && envHome.length > 0) { - return envHome; - } - return join(homedir(), '.pythinker-code'); -} - -/** HTTP port for the dashboard API server. */ -export function resolvePort(): number { - const raw = process.env['PORT']; - if (raw !== undefined && raw.length > 0) { - const n = Number.parseInt(raw, 10); - if (Number.isFinite(n) && n > 0 && n < 65536) { - return n; - } - } - return 3001; -} - -/** HTTP host for the dashboard API server. Defaults to loopback. */ -export function resolveHost(): string { - const raw = process.env['DASHBOARD_HOST'] ?? process.env['HOST']; - const host = raw?.trim(); - return host !== undefined && host.length > 0 ? host : '127.0.0.1'; -} - -export function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase().replaceAll('[', '').replaceAll(']', ''); - return ( - normalized === 'localhost' || - normalized === '::1' || - normalized === '0:0:0:0:0:0:0:1' || - normalized.startsWith('127.') - ); -} - -/** Format a host for embedding in a URL authority. Bare IPv6 literals (which - * contain ':') must be bracketed, e.g. `::1` → `[::1]`, otherwise - * `http://::1:3001/` is an invalid URL. Already-bracketed literals, IPv4 - * addresses, and hostnames are returned unchanged. */ -export function hostForUrl(host: string): string { - if (host.includes(':') && !host.startsWith('[')) return `[${host}]`; - return host; -} - -export function resolveDashboardAuthToken(host: string = resolveHost()): string | undefined { - const raw = process.env['DASHBOARD_AUTH_TOKEN']; - const token = raw?.trim(); - if (token !== undefined && token.length > 0) return token; - if (!isLoopbackHost(host)) { - throw new Error( - `DASHBOARD_AUTH_TOKEN is required when binding dashboard-server outside loopback (host=${host})`, - ); - } - return undefined; -} - -export const PYTHINKER_CODE_HOME: string = resolvePythinkerCodeHome(); diff --git a/apps/dashboard/server/src/index.ts b/apps/dashboard/server/src/index.ts deleted file mode 100644 index 5b54e02f..00000000 --- a/apps/dashboard/server/src/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { PYTHINKER_CODE_HOME, resolveHost, resolveDashboardAuthToken } from './config'; -import { startDashboardServer } from './start'; -import { formatStartupBanner } from './startup-banner'; - -async function main(): Promise<void> { - const host = resolveHost(); - const authToken = resolveDashboardAuthToken(host); - const { port } = await startDashboardServer({ host, authToken }); - process.stdout.write( - formatStartupBanner({ authToken, host, pythinkerCodeHome: PYTHINKER_CODE_HOME, port }), - ); -} - -try { - await main(); -} catch (error: unknown) { - process.stderr.write( - `[dashboard-server] fatal: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, - ); - process.exit(1); -} diff --git a/apps/dashboard/server/src/lib/agent-record-types.ts b/apps/dashboard/server/src/lib/agent-record-types.ts deleted file mode 100644 index 1c1b3d9a..00000000 --- a/apps/dashboard/server/src/lib/agent-record-types.ts +++ /dev/null @@ -1,123 +0,0 @@ -// apps/dashboard/server/src/lib/agent-record-types.ts -// Single source of truth: everything below comes from agent-core directly. -// Do NOT add local interfaces that duplicate upstream shapes. - -// Local binding for the `AgentRecord` type used by dashboard-only DTOs below. -import type { AgentRecord } from '@pymodel/agent-core'; - -export type { - AgentRecord, - AgentRecordEvents, - AgentRecordOf, - AgentConfigUpdateData, - CompactionBeginData, - CompactionResult, - PermissionApprovalResultRecord, - PermissionMode, - UsageRecordScope, - ToolStoreUpdate, - LoopRecordedEvent, - ContextMessage, - PromptOrigin, -} from '@pymodel/agent-core'; -export { AGENT_WIRE_PROTOCOL_VERSION } from '@pymodel/agent-core'; -export type { Message, ContentPart, ToolCall, TokenUsage } from '@pymodel/kosong'; - -// ── dashboard-only DTOs ────────────────────────────────────────────────────────── - -export interface ApiError { - error: string; - code: - | 'NOT_FOUND' - | 'BAD_REQUEST' - | 'UNAUTHORIZED' - | 'READ_ERROR' - | 'PARSE_ERROR' - | 'DELETE_ERROR' - | 'INCOMPATIBLE_SESSION_STATE' - | 'INCOMPATIBLE_AGENT_WIRE'; -} - -export type SessionHealth = 'ok' | 'missing_main_wire' | 'incompatible_state' | 'incompatible_wire'; - -export class DashboardIncompatibilityError extends Error { - constructor( - readonly kind: 'state' | 'wire', - options?: ErrorOptions, - ) { - super(kind === 'state' ? 'session state is incompatible' : 'agent wire is incompatible', options); - this.name = 'DashboardIncompatibilityError'; - } -} - -export function dashboardIncompatibilityBody(error: unknown): ApiError | null { - if (!(error instanceof DashboardIncompatibilityError)) return null; - return error.kind === 'state' - ? { error: 'session state is incompatible', code: 'INCOMPATIBLE_SESSION_STATE' } - : { error: 'agent wire is incompatible', code: 'INCOMPATIBLE_AGENT_WIRE' }; -} - -export interface SessionSummary { - sessionId: string; - sessionDir: string; - workDir: string; - title: string | null; - lastPrompt: string | null; - isCustomTitle: boolean; - createdAt: number; - updatedAt: number; - agentCount: number; - mainAgentExists: boolean; - mainWireRecordCount: number; - wireProtocolVersion: string | null; - health: SessionHealth; -} - -export interface AgentInfo { - agentId: string; - type: 'main' | 'sub' | 'independent'; - parentAgentId: string | null; - wireExists: boolean; - wireRecordCount: number; - wireProtocolVersion: string | null; - dynamicWorkflowItem: string | null; -} - -export interface SessionDetail { - sessionId: string; - /** Canonical on-disk session directory. */ - sessionDir: string; - workDir: string; - state: unknown; // Pass through as-is; the frontend renders according to the real state.json shape - agents: AgentInfo[]; -} - -/** One line of `wire.jsonl`. `lineNo` is internal plumbing — used as a stable React key, for - * "jump to line" navigation, and for pairing events — and MUST NOT be - * rendered as part of the record body. The detail panel surfaces it via - * the row header, not inside the JSON view. */ -export interface WireEntry { - /** 1-indexed line number in the underlying `wire.jsonl` file. */ - lineNo: number; - /** The validated current-protocol record used by dashboard renderers. */ - data: AgentRecord; - /** The record exactly as written on disk, exposed only by the raw-record view. */ - raw: unknown; -} - -export interface WireResponse { - sessionId: string; - agentId: string; - protocolVersion: string; - metadata: { protocolVersion: string; createdAt: number }; - records: readonly WireEntry[]; -} - -export interface AgentNode extends AgentInfo { - children: AgentNode[]; -} - -export interface AgentTreeResponse { - sessionId: string; - tree: AgentNode[]; -} diff --git a/apps/dashboard/server/src/lib/context-projector.ts b/apps/dashboard/server/src/lib/context-projector.ts deleted file mode 100644 index 2bb66a61..00000000 --- a/apps/dashboard/server/src/lib/context-projector.ts +++ /dev/null @@ -1,618 +0,0 @@ -import type { - ContentPart, - ContextMessage, - PermissionMode, - AgentConfigUpdateData, - TokenUsage, - ToolCall, - WireEntry, -} from './agent-record-types'; - -export interface ProjectedMessage { - lineNo: number; - time?: number; - source: 'append_message' | 'compaction_summary' | 'undo' | 'clear'; - message: ContextMessage; - toolStepUuids: string[]; - /** Set only when source === 'undo'. */ - undo?: { count: number; removedMessageCount: number }; - /** Set only on the summary bubble of source === 'compaction_summary'. */ - compaction?: { compactedCount: number; tokensBefore: number; tokensAfter: number }; -} - -export interface UsageTotals { - byScope: { session: TokenUsage; turn: TokenUsage }; - byModel: Record<string, TokenUsage>; -} - -export interface ConfigSnapshot { - cwd?: string; - modelAlias?: string; - profileName?: string; - thinkingLevel?: string; - systemPrompt?: string; -} - -export interface GoalSnapshot { - goalId: string; - objective: string; - completionCriterion?: string; - status?: string; - actor?: string; - reason?: string; - tokensUsed?: number; - turnsUsed?: number; - wallClockMs?: number; -} - -export interface ContextProjection { - messages: ProjectedMessage[]; - usage: UsageTotals; - /** Absolute current context-window fill, mirroring agent-core - * ContextMemory._tokenCount. Updated from the latest step.end.usage, and - * also reset on the lifecycle events agent-core touches: context.clear → 0, - * context.apply_compaction → tokensAfter. Distinct from the cumulative - * `usage` totals. */ - contextTokens: number; - config: ConfigSnapshot; - permission: { mode: PermissionMode | null }; - planMode: { active: boolean; id?: string }; - goal: GoalSnapshot | null; - dynamicWorkflow: { active: boolean; trigger?: string }; -} - -const ZERO: TokenUsage = { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }; - -/** Build a conversation timeline + derived state from a sequence of - * wire entries. The reconstruction mirrors agent-core's own - * `appendLoopEvent` logic, so: - * - * - `context.append_message` records become messages as-is (the - * user / tool messages and any explicit assistant injections). - * - `step.begin` pushes a fresh assistant message; later - * `content.part` and `tool.call` events on the same step **mutate - * that same message** to grow its content / toolCalls. `step.end` - * just closes the step. - * - `tool.result` events emit an independent `role: 'tool'` message, - * matching how agent-core surfaces tool exchanges to the model. - * - * Without this loop-event reconstruction the timeline would only - * show user prompts — agent-core does not emit a synthetic - * `context.append_message` for assistant turns. - * - * `mode` selects between two views of the four destructive lifecycle - * events (compaction / undo / clear / micro-compaction): - * - * - `'model'` (default): faithfully mirrors what the model currently - * sees — compaction drops the compacted prefix, undo splices removed - * messages out, clear empties the list, micro-compaction blanks old - * tool results. All existing behaviour. - * - `'full'`: full reconstructed history for debugging — the same four - * events insert an INLINE MARKER but do NOT mutate/drop the message - * list, so messages compacted/undone/cleared away stay visible and - * micro-compacted tool results keep their original content. - * - * Everything else (append_message, loop events, goal/dynamic workflow/permission/ - * plan/config/usage/contextTokens derived state) is identical in both - * modes — `mode` only affects the `messages` array and which markers - * appear. */ -export function projectContext( - entries: ReadonlyArray<WireEntry>, - mode: 'model' | 'full' = 'model', -): ContextProjection { - let messages: ProjectedMessage[] = []; - const usage: UsageTotals = { - byScope: { session: { ...ZERO }, turn: { ...ZERO } }, - byModel: {}, - }; - const config: ConfigSnapshot = {}; - let permissionMode: PermissionMode | null = null; - let planActive = false; - let planId: string | undefined; - let contextTokens = 0; - let goal: GoalSnapshot | null = null; - let dynamicWorkflow: { active: boolean; trigger?: string } = { active: false }; - let microCutoff = 0; - // Maps step.uuid → the assistant ProjectedMessage that step is filling in. - // Cleared on context.clear / context.apply_compaction. - let openSteps = new Map<string, ProjectedMessage>(); - - for (const entry of entries) { - const rec = entry.data; - switch (rec.type) { - case 'context.append_message': - messages.push({ - lineNo: entry.lineNo, - time: rec.time, - source: 'append_message', - message: rec.message, - toolStepUuids: [], - }); - break; - case 'context.append_loop_event': { - const ev = rec.event; - if (ev.type === 'step.begin') { - const message: ContextMessage = { - role: 'assistant', - content: [], - toolCalls: [], - }; - const projected: ProjectedMessage = { - lineNo: entry.lineNo, - time: rec.time, - source: 'append_message', - message, - toolStepUuids: [ev.uuid], - }; - messages.push(projected); - openSteps.set(ev.uuid, projected); - } else if (ev.type === 'content.part') { - const projected = openSteps.get(ev.stepUuid); - if (projected !== undefined) { - (projected.message.content as ContentPart[]).push(ev.part); - } - } else if (ev.type === 'tool.call') { - const projected = openSteps.get(ev.stepUuid); - if (projected !== undefined) { - const args = - typeof ev.args === 'string' - ? ev.args - : ev.args === undefined - ? null - : JSON.stringify(ev.args); - (projected.message.toolCalls as ToolCall[]).push({ - type: 'function', - id: ev.toolCallId, - name: ev.name, - arguments: args, - }); - } - } else if (ev.type === 'step.end') { - // Absolute context-window fill, mirroring agent-core - // ContextMemory._tokenCount: the latest step.end usage REPLACES the - // snapshot (it is not cumulative — see Task P1.7 note on byScope). - if ('usage' in ev && ev.usage !== undefined) { - contextTokens = - ev.usage.inputCacheRead + - ev.usage.inputCacheCreation + - ev.usage.inputOther + - ev.usage.output; - } - openSteps.delete(ev.uuid); - } else if (ev.type === 'tool.result') { - // Mirror what the MODEL saw, not the raw output. agent-core's - // ContextMemory.appendLoopEvent (`tool.result` case) stores - // `createToolMessage(toolCallId, toolResultOutputForModel(result))`, - // which normalizes error / empty outputs with sentinel strings. Using - // `ev.result.output` directly would surface content the model never - // received for failed / empty tool calls. See - // `toolResultContentForModel` below. - const content = toolResultContentForModel(ev.result); - const toolMsg: ContextMessage = { - role: 'tool', - content, - toolCalls: [], - toolCallId: ev.toolCallId, - ...(ev.result.isError === true ? { isError: true } : {}), - }; - messages.push({ - lineNo: entry.lineNo, - time: rec.time, - source: 'append_message', - message: toolMsg, - toolStepUuids: [], - }); - } - break; - } - case 'context.clear': - if (mode === 'model') { - messages = []; - openSteps = new Map(); - // Mirror agent-core clear() → microCompaction.reset() (cutoff → 0): - // the message indices are wiped, so any prior cutoff is meaningless. - microCutoff = 0; - } else { - // Full history: keep all preceding messages and openSteps as-is, just - // append a synthetic 'clear' marker inline. The original tool results - // stay un-blanked, so the cutoff is not applied (the end-of-loop - // blanking pass is gated on model mode). - messages.push({ - lineNo: entry.lineNo, - time: rec.time, - source: 'clear', - // Synthetic marker: never rendered as a bubble (the web dispatches on - // `source === 'clear'`). `role: 'assistant'` keeps it out of any - // role-counting / tool-blanking path. - message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage, - toolStepUuids: [], - }); - } - // Mirror agent-core clear() → _tokenCount = 0: the context-window fill is - // wiped. Derived state, so it is mode-INDEPENDENT (applied for both modes). - contextTokens = 0; - break; - case 'context.apply_compaction': { - openSteps = new Map(); - // Mirror agent-core's actual `applyCompaction` behaviour - // (`packages/agent-core/src/agent/context/index.ts`): history becomes - // `[summaryBubble, ...history.slice(compactedCount)]`. The summary is - // an *assistant* message tagged `origin.kind = 'compaction_summary'` - // (using 'system' would skew role counts and any downstream diff - // against agent-core history). The post-compaction tail is preserved - // rather than dropped, so messages still in context stay visible. - const summaryBubble: ProjectedMessage = { - lineNo: entry.lineNo, - time: rec.time, - source: 'compaction_summary', - message: { - role: 'assistant', - content: [{ type: 'text', text: rec.summary }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - } as ContextMessage, - toolStepUuids: [], - compaction: { - compactedCount: rec.compactedCount, - tokensBefore: rec.tokensBefore, - tokensAfter: rec.tokensAfter, - }, - }; - if (mode === 'model') { - // Drop the first `rec.compactedCount` HISTORY entries (NOT array - // entries): agent-core's `compactedCount` indexes into `_history`, - // which never contains our synthetic 'undo'/'clear' markers. Walk the - // array counting only history entries (`isHistoryEntry`) until - // `compactedCount` are passed, then slice there — any UI-only markers - // in the dropped region go with it (correct: they precede the - // compaction). With no markers this is exactly `slice(compactedCount)`. - let sliceAt = messages.length; - let passed = 0; - for (let i = 0; i < messages.length; i++) { - if (passed >= rec.compactedCount) { - sliceAt = i; - break; - } - if (isHistoryEntry(messages[i]!)) passed++; - } - if (passed < rec.compactedCount) sliceAt = messages.length; - messages = [summaryBubble, ...messages.slice(sliceAt)]; - } else { - // Full history: keep ALL preceding messages, just append the summary - // marker inline so the compacted prefix stays visible. - messages.push(summaryBubble); - } - // Mirror agent-core applyCompaction() → microCompaction.reset() (cutoff - // → 0): the message list is rebuilt as [summary, ...tail], so the old - // index-based cutoff no longer points at the same messages. (In full - // mode the blanking pass does not run, so this is a no-op there.) - microCutoff = 0; - // Mirror agent-core applyCompaction() → _tokenCount = result.tokensAfter: - // the live context-window fill is now the post-compaction count. Derived - // state, so it is mode-INDEPENDENT. - contextTokens = rec.tokensAfter; - break; - } - case 'usage.record': { - // byScope keeps per-scope cumulative spend. This is NOT the live context-window - // fill — that is `contextTokens` (latest step.end.usage). The web TokenBar shows - // contextTokens; byScope/byModel are for the cumulative breakdown only. - const scope = (rec.usageScope ?? 'session') as 'session' | 'turn'; - addUsage(usage.byScope[scope], rec.usage); - if (!usage.byModel[rec.model]) usage.byModel[rec.model] = { ...ZERO }; - addUsage(usage.byModel[rec.model]!, rec.usage); - break; - } - case 'config.update': { - const upd = rec as AgentConfigUpdateData & { type: 'config.update' }; - if (upd.cwd !== undefined) config.cwd = upd.cwd; - if (upd.modelAlias !== undefined) config.modelAlias = upd.modelAlias; - if (upd.profileName !== undefined) config.profileName = upd.profileName; - if (upd.thinkingLevel !== undefined) config.thinkingLevel = upd.thinkingLevel; - if (upd.systemPrompt !== undefined) config.systemPrompt = upd.systemPrompt; - break; - } - case 'permission.set_mode': - permissionMode = rec.mode; - break; - case 'plan_mode.enter': - planActive = true; planId = rec.id; break; - case 'plan_mode.cancel': - case 'plan_mode.exit': - planActive = false; planId = undefined; break; - case 'context.undo': { - // Mirror agent-core `undo` (`agent/context/index.ts`): walk from the - // end, skip `origin.kind === 'injection'`, stop at - // `origin.kind === 'compaction_summary'`, remove others, counting real - // user prompts via `isRealUserPrompt` until `count` is reached. Then - // leave an undo marker. - // - // `computeUndoCutoff` is the single source of truth for that skip/stop - // walk (shared by both modes); only the actual removal is gated on - // `'model'` mode. - const { cutoff, removedMessageCount } = computeUndoCutoff(messages, rec.count); - if (mode === 'model') { - // Remove everything from `cutoff` onward EXCEPT injections, which the - // walk skips (they survive even when inside the undo window). Using - // the same `origin.kind === 'injection'` predicate keeps removal in - // lockstep with the counting walk above. - messages = messages.filter( - (pm, i) => i < cutoff || pm.message.origin?.kind === 'injection', - ); - openSteps = new Map(); - // Mirror agent-core undo() → microCompaction.reset(this._history.length): - // clamp the cutoff to the post-undo HISTORY-entry count so a later append - // does not get blanked by a now-too-large stale cutoff. Count only history - // entries (`isHistoryEntry`) — `messages.length` would include any surviving - // synthetic undo/clear marker, which agent-core's `_history.length` does - // NOT, so an array-length clamp could be too high by the marker count. - // (Clamp before pushing the undo marker, which is a non-tool pseudo-message - // and unaffected by blanking regardless.) With no markers, historyCount === - // messages.length, so this is a no-op then. - const historyCount = messages.reduce((n, pm) => (isHistoryEntry(pm) ? n + 1 : n), 0); - microCutoff = Math.min(microCutoff, historyCount); - } - // In 'full' mode: do NOT remove — keep the undone messages and openSteps - // as-is, only push the undo marker. `removedMessageCount` still reflects - // what WOULD have been removed. - messages.push({ - lineNo: entry.lineNo, - time: rec.time, - source: 'undo', - // Synthetic message: never rendered. The web dispatches on - // `source === 'undo'`; this only satisfies ProjectedMessage. - // `role: 'assistant'` is deliberate so this marker can never match the - // `role: 'tool'` micro-compaction blanking gate — keep it non-tool if - // you ever change the placeholder. - message: { role: 'assistant', content: [], toolCalls: [] } as ContextMessage, - toolStepUuids: [], - undo: { count: rec.count, removedMessageCount }, - }); - break; - } - case 'micro_compaction.apply': - // Track the latest cutoff; the actual content blanking is applied - // after the loop (mirrors agent-core MicroCompaction.compact, which - // runs over the full history at projection time). - microCutoff = rec.cutoff; - break; - case 'goal.create': - goal = { - goalId: rec.goalId, - objective: rec.objective, - completionCriterion: rec.completionCriterion, - }; - break; - case 'goal.update': - if (goal !== null) { - const prev: GoalSnapshot = goal; - goal = { - ...prev, - status: rec.status ?? prev.status, - actor: rec.actor ?? prev.actor, - reason: rec.reason ?? prev.reason, - tokensUsed: rec.tokensUsed ?? prev.tokensUsed, - turnsUsed: rec.turnsUsed ?? prev.turnsUsed, - wallClockMs: rec.wallClockMs ?? prev.wallClockMs, - }; - } - break; - case 'goal.clear': - goal = null; - break; - case 'dynamic_workflow_mode.enter': - dynamicWorkflow = { active: true, trigger: rec.trigger }; - break; - case 'dynamic_workflow_mode.exit': - dynamicWorkflow = { active: false }; - break; - // Kinds that don't affect the projected timeline / derived state: - case 'metadata': - case 'forked': - case 'turn.prompt': - case 'turn.steer': - case 'turn.cancel': - case 'permission.record_approval_result': - case 'full_compaction.begin': - case 'full_compaction.cancel': - case 'full_compaction.complete': - case 'tools.register_user_tool': - case 'tools.unregister_user_tool': - case 'tools.set_active_tools': - case 'tools.update_store': - break; - default: { - const _exhaustive: never = rec; - void _exhaustive; - break; - } - } - } - - // Micro-compaction blanking (mirrors agent-core MicroCompaction.compact): - // blank any message whose HISTORY index < cutoff that is a `role: 'tool'` - // result with a defined toolCallId and content large enough (≥ the - // min-content gate), replacing its content with the truncation marker. The - // cutoff is an agent-core `_history` index, which never includes our synthetic - // 'undo'/'clear' markers, so we count only history entries (`isHistoryEntry`) - // — array indices would be offset by any preceding marker. This rewrite is the - // model's-eye view, so it runs ONLY in 'model' mode — in 'full' mode the - // original tool results are shown un-blanked. - if (mode === 'model' && microCutoff > 0) { - let historyIndex = 0; - for (const pm of messages) { - if (!isHistoryEntry(pm)) continue; - if (historyIndex >= microCutoff) break; - historyIndex++; - const m = pm.message; - if ( - m.role === 'tool' && - m.toolCallId !== undefined && - estimateContentTokens(m.content) >= MICRO_MIN_CONTENT_TOKENS - ) { - pm.message = { ...m, content: [{ type: 'text', text: MICRO_TRUNCATED_MARKER }] }; - } - } - } - - return { - messages, - usage, - contextTokens, - config, - permission: { mode: permissionMode }, - planMode: { active: planActive, id: planId }, - goal, - dynamicWorkflow, - }; -} - -function addUsage(into: TokenUsage, src: TokenUsage): void { - (into as any).inputOther += src.inputOther; - (into as any).output += src.output; - (into as any).inputCacheRead += src.inputCacheRead; - (into as any).inputCacheCreation += src.inputCacheCreation; -} - -// ── Tool-result normalization (mirror of agent-core) ───────────────────────── -// These replicate agent-core's `toolResultOutputForModel` so dashboard's model-view -// shows the EXACT content the model received for a tool result. The constants -// and branch conditions are copied verbatim from -// `packages/agent-core/src/agent/context/index.ts` (lines 18-22, 350-377). Keep -// them byte-identical with that source — if agent-core changes the sentinels or -// branch logic, update here too. -const TOOL_ERROR_STATUS = '<system>ERROR: Tool execution failed.</system>'; -const TOOL_EMPTY_STATUS = '<system>Tool output is empty.</system>'; -const TOOL_EMPTY_ERROR_STATUS = - '<system>ERROR: Tool execution failed. Tool output is empty.</system>'; -const TOOL_OUTPUT_EMPTY_TEXT = 'Tool output is empty.'; - -/** Mirrors agent-core `isEmptyOutputText` - * (`packages/agent-core/src/agent/context/index.ts` ~line 375). */ -function isEmptyOutputText(output: string): boolean { - return output.length === 0 || output.trim() === TOOL_OUTPUT_EMPTY_TEXT; -} - -/** Mirrors agent-core `toolResultOutputForModel` - * (`packages/agent-core/src/agent/context/index.ts` ~line 350), then wraps the - * result into `ContentPart[]` exactly as `createToolMessage` does (a string - * output → a single `{ type: 'text', text }` part). The model saw this - * normalized content in BOTH model and full views (agent-core normalizes at - * append time, before any of the destructive lifecycle events), so the - * tool.result branch uses this output mode-independently. */ -function toolResultContentForModel(result: { - output: string | ContentPart[]; - isError?: boolean; -}): ContentPart[] { - const output = result.output; - if (typeof output === 'string') { - let normalized: string; - if (result.isError === true) { - if (output.length === 0) { - normalized = TOOL_EMPTY_ERROR_STATUS; - } else if (output.trimStart().startsWith('<system>ERROR:')) { - normalized = output; - } else { - normalized = `${TOOL_ERROR_STATUS}\n${output}`; - } - } else { - normalized = isEmptyOutputText(output) ? TOOL_EMPTY_STATUS : output; - } - // Match createToolMessage: a string output becomes a single text part. - return [{ type: 'text', text: normalized }]; - } - - if (output.length === 0) { - return [ - { - type: 'text', - text: result.isError === true ? TOOL_EMPTY_ERROR_STATUS : TOOL_EMPTY_STATUS, - }, - ]; - } - if (result.isError === true) { - return [{ type: 'text', text: TOOL_ERROR_STATUS }, ...output]; - } - return output; -} - -const MICRO_TRUNCATED_MARKER = '[Old tool result content cleared]'; -const MICRO_MIN_CONTENT_TOKENS = 100; - -/** Replicates agent-core's per-char token weighting exactly, over the same - * `text` + `think` parts its gate counts. agent-core - * (`packages/agent-core/src/utils/tokens.ts`) sums per-part estimates, each - * `estimateTokens(s) = Math.ceil(asciiCount / 4) + nonAsciiCount` (ASCII ~4 - * chars/token, every non-ASCII/CJK code point a full token); other part types - * contribute 0. Matching it ensures Chinese-heavy tool results blank at the - * same gate as the agent. */ -function estimateTokens(text: string): number { - let asciiCount = 0; - let nonAsciiCount = 0; - for (const char of text) { - if (char.codePointAt(0)! <= 127) { - asciiCount++; - } else { - nonAsciiCount++; - } - } - return Math.ceil(asciiCount / 4) + nonAsciiCount; -} - -function estimateContentTokens(content: readonly ContentPart[]): number { - let total = 0; - for (const p of content) { - if (p.type === 'text') total += estimateTokens(p.text); - else if (p.type === 'think') total += estimateTokens(p.think); - } - return total; -} - -/** True for messages that correspond to a real agent-core `_history` entry — - * i.e. `append_message` and `compaction_summary` (the summary IS in `_history`). - * The synthetic UI-only markers (`undo` / `clear`) are NOT in `_history`, so - * index-based operations that mirror agent-core (compaction slice, micro- - * compaction cutoff) must skip them to stay aligned with agent-core indices. */ -function isHistoryEntry(pm: ProjectedMessage): boolean { - return pm.source !== 'undo' && pm.source !== 'clear'; -} - -/** Mirrors agent-core `isRealUserPrompt` (`agent/context/index.ts`): a message - * counts toward an undo only if it is a genuine user prompt. */ -function isRealUserPrompt(message: ContextMessage): boolean { - if (message.role !== 'user') return false; - const origin = message.origin; - if (origin === undefined || origin.kind === 'user') return true; - if (origin.kind === 'skill_activation') return origin.trigger === 'user-slash'; - return false; -} - -/** Single source of truth for the `context.undo` backward walk, shared by both - * projection modes. Mirrors agent-core `undo` (`agent/context/index.ts`): walk - * from the end, skip `origin.kind === 'injection'` (those are KEPT even when - * they sit inside the undo window), stop at `origin.kind === 'compaction_summary'`, - * and count real user prompts via `isRealUserPrompt` until `count` is reached. - * - * Returns the `cutoff` (lowest index to remove from, inclusive) plus the - * `removedMessageCount` (number of non-skipped messages in the window). In - * `'model'` mode the caller removes everything from `cutoff` onward EXCEPT - * injections; in `'full'` mode only `removedMessageCount` is reported on the - * undo marker (no removal). Defining the skip/stop predicate exactly once here - * keeps the two modes from drifting. */ -function computeUndoCutoff( - messages: readonly ProjectedMessage[], - count: number, -): { cutoff: number; removedMessageCount: number } { - let removedUserCount = 0; - let removedMessageCount = 0; - let cutoff = messages.length; - for (let i = messages.length - 1; i >= 0; i--) { - const origin = messages[i]?.message.origin; - if (origin?.kind === 'injection') continue; // skip, keep - if (origin?.kind === 'compaction_summary') break; // stop - removedMessageCount++; - cutoff = i; - if (isRealUserPrompt(messages[i]!.message) && ++removedUserCount >= count) break; - } - return { cutoff, removedMessageCount }; -} diff --git a/apps/dashboard/server/src/lib/session-store.ts b/apps/dashboard/server/src/lib/session-store.ts deleted file mode 100644 index 9dcfcee0..00000000 --- a/apps/dashboard/server/src/lib/session-store.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { readdir, readFile, stat } from 'node:fs/promises'; -import { join, resolve, sep } from 'node:path'; - -import { parseSessionMetadata, type SessionMeta } from '@pymodel/agent-core'; - -import { - DashboardIncompatibilityError, - type AgentInfo, - type SessionDetail, - type SessionSummary, -} from './agent-record-types'; -import { compareAgentIds } from './agent-tree'; -import { readAgentWire } from './wire-reader'; - -const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/; -const AGENT_ID_RE = /^[A-Za-z0-9._-]+$/; - -export function isSafeAgentId(id: string): boolean { - return AGENT_ID_RE.test(id) && id !== '.' && id !== '..'; -} - -export async function listSessions(home: string): Promise<SessionSummary[]> { - const sessionsDir = join(home, 'sessions'); - const buckets = await readdir(sessionsDir, { withFileTypes: true }).catch(() => []); - const index = await readSessionIndex(home); - const out: SessionSummary[] = []; - for (const bucket of buckets) { - if (!bucket.isDirectory()) continue; - const bucketDir = join(sessionsDir, bucket.name); - const sessionDirs = await readdir(bucketDir, { withFileTypes: true }).catch(() => []); - for (const entry of sessionDirs) { - if (!entry.isDirectory() || !SESSION_ID_RE.test(entry.name)) continue; - const sessionDir = join(bucketDir, entry.name); - const workDir = index.get(entry.name)?.workDir ?? ''; - const summary = await tryReadSummary(sessionDir, entry.name, workDir); - if (summary !== null) out.push(summary); - } - } - out.sort((a, b) => b.updatedAt - a.updatedAt); - return out; -} - -export async function readSessionDetail(home: string, sessionId: string): Promise<SessionDetail | null> { - const sessionDir = await findSessionDir(home, sessionId); - if (sessionDir === null) return null; - const index = await readSessionIndex(home); - const workDir = index.get(sessionId)?.workDir ?? ''; - const state = await readState(sessionDir); - const agents = await inventoryAgents(sessionDir, state); - return { sessionId, sessionDir, workDir, state, agents }; -} - -async function tryReadSummary( - sessionDir: string, - sessionId: string, - workDir: string, -): Promise<SessionSummary | null> { - let state: SessionMeta; - try { - state = await readState(sessionDir); - } catch (error) { - if (error instanceof DashboardIncompatibilityError && error.kind === 'state') { - return incompatibleStateSummary(sessionDir, sessionId, workDir); - } - throw error; - } - try { - const agents = await inventoryAgents(sessionDir, state); - const main = agents.find((agent) => agent.agentId === 'main'); - return { - sessionId, - sessionDir, - workDir, - title: state.title, - lastPrompt: state.lastPrompt ?? null, - isCustomTitle: state.isCustomTitle, - createdAt: parseTs(state.createdAt), - updatedAt: parseTs(state.updatedAt), - agentCount: agents.length, - mainAgentExists: main !== undefined, - mainWireRecordCount: main?.wireRecordCount ?? 0, - wireProtocolVersion: main?.wireProtocolVersion ?? null, - health: main === undefined || !main.wireExists ? 'missing_main_wire' : 'ok', - }; - } catch (error) { - if (error instanceof DashboardIncompatibilityError && error.kind === 'wire') { - return incompatibleWireSummary(sessionDir, sessionId, workDir, state); - } - throw error; - } -} - -function incompatibleStateSummary( - sessionDir: string, - sessionId: string, - workDir: string, -): SessionSummary { - return { - sessionId, - sessionDir, - workDir, - title: null, - lastPrompt: null, - isCustomTitle: false, - createdAt: 0, - updatedAt: 0, - agentCount: 0, - mainAgentExists: false, - mainWireRecordCount: 0, - wireProtocolVersion: null, - health: 'incompatible_state', - }; -} - -function incompatibleWireSummary( - sessionDir: string, - sessionId: string, - workDir: string, - state: SessionMeta, -): SessionSummary { - return { - sessionId, - sessionDir, - workDir, - title: state.title, - lastPrompt: state.lastPrompt ?? null, - isCustomTitle: state.isCustomTitle, - createdAt: parseTs(state.createdAt), - updatedAt: parseTs(state.updatedAt), - agentCount: Object.keys(state.agents).length, - mainAgentExists: Object.hasOwn(state.agents, 'main'), - mainWireRecordCount: 0, - wireProtocolVersion: null, - health: 'incompatible_wire', - }; -} - -interface SessionIndexEntry { - sessionDir: string; - workDir: string; -} - -async function readSessionIndex(home: string): Promise<Map<string, SessionIndexEntry>> { - const out = new Map<string, SessionIndexEntry>(); - let raw: string; - try { - raw = await readFile(join(home, 'session_index.jsonl'), 'utf8'); - } catch { - return out; - } - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) continue; - try { - const entry = JSON.parse(line) as { sessionId?: string; sessionDir?: string; workDir?: string }; - if (typeof entry.sessionId === 'string' && typeof entry.sessionDir === 'string') { - out.set(entry.sessionId, { - sessionDir: entry.sessionDir, - workDir: typeof entry.workDir === 'string' ? entry.workDir : '', - }); - } - } catch { - // Ignore malformed index entries and fall back to directory scanning. - } - } - return out; -} - -async function inventoryAgents(sessionDir: string, state: SessionMeta): Promise<AgentInfo[]> { - const result: AgentInfo[] = []; - for (const [agentId, meta] of Object.entries(state.agents)) { - if (!isSafeAgentId(agentId)) { - throw new DashboardIncompatibilityError('state'); - } - const wirePath = join(sessionDir, 'agents', agentId, 'wire.jsonl'); - if (!(await pathExists(wirePath))) { - result.push({ - agentId, - type: meta.type, - parentAgentId: meta.parentAgentId, - wireExists: false, - wireRecordCount: 0, - wireProtocolVersion: null, - dynamicWorkflowItem: meta.dynamicWorkflowItem ?? null, - }); - continue; - } - const info = await scanWire(wirePath); - result.push({ - agentId, - type: meta.type, - parentAgentId: meta.parentAgentId, - wireExists: true, - wireRecordCount: info.count, - wireProtocolVersion: info.protocolVersion, - dynamicWorkflowItem: meta.dynamicWorkflowItem ?? null, - }); - } - return result.toSorted((a, b) => compareAgentIds(a.agentId, b.agentId)); -} - -async function readState(sessionDir: string): Promise<SessionMeta> { - try { - return parseSessionMetadata(JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8'))); - } catch (error) { - if (error instanceof DashboardIncompatibilityError) throw error; - throw new DashboardIncompatibilityError('state', { cause: error }); - } -} - -async function findSessionDir(home: string, sessionId: string): Promise<string | null> { - if (!SESSION_ID_RE.test(sessionId)) return null; - const sessionsRoot = resolve(join(home, 'sessions')); - const sessionsRootPrefix = sessionsRoot + sep; - try { - const indexLines = (await readFile(join(home, 'session_index.jsonl'), 'utf8')).split(/\r?\n/); - for (const line of indexLines) { - if (!line.trim()) continue; - const entry = JSON.parse(line) as { sessionId?: string; sessionDir?: string }; - if (entry.sessionId !== sessionId || typeof entry.sessionDir !== 'string') continue; - const candidate = resolve(entry.sessionDir); - if (!candidate.startsWith(sessionsRootPrefix)) continue; - if (candidate.split(sep).pop() !== sessionId) continue; - if (await pathExists(candidate)) return candidate; - } - } catch { - // No usable index; scan the session buckets below. - } - const buckets = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => []); - for (const bucket of buckets) { - if (!bucket.isDirectory()) continue; - const candidate = join(sessionsRoot, bucket.name, sessionId); - if (await pathExists(candidate)) return candidate; - } - return null; -} - -async function scanWire(path: string): Promise<{ count: number; protocolVersion: string }> { - const wire = await readAgentWire(path); - return { - count: wire.records.length + 1, - protocolVersion: wire.metadata.protocolVersion, - }; -} - -function parseTs(input: string): number { - const value = Date.parse(input); - return Number.isFinite(value) ? value : 0; -} - -async function pathExists(path: string): Promise<boolean> { - try { - await stat(path); - return true; - } catch { - return false; - } -} diff --git a/apps/dashboard/server/src/lib/wire-reader.ts b/apps/dashboard/server/src/lib/wire-reader.ts deleted file mode 100644 index 342dbdd0..00000000 --- a/apps/dashboard/server/src/lib/wire-reader.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { createReadStream } from 'node:fs'; -import { createInterface } from 'node:readline'; - -import { - AGENT_WIRE_PROTOCOL_VERSION, - assertAgentRecord, -} from '@pymodel/agent-core'; - -import { - DashboardIncompatibilityError, - type WireEntry, -} from './agent-record-types'; - -export interface WireReadResult { - metadata: { protocolVersion: string; createdAt: number }; - records: ReadonlyArray<WireEntry>; -} - -/** Read an exact-current agent wire file. */ -export async function readAgentWire(path: string): Promise<WireReadResult> { - try { - const stream = createReadStream(path, { encoding: 'utf8' }); - const rl = createInterface({ input: stream, crlfDelay: Infinity }); - let lineNo = 0; - let metadata: WireReadResult['metadata'] | null = null; - const records: WireEntry[] = []; - - for await (const line of rl) { - lineNo += 1; - if (line.trim().length === 0) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch (error) { - throw new Error(`Wire record is invalid JSON at line ${lineNo}`, { cause: error }); - } - assertAgentRecord(parsed); - if (metadata === null) { - if (parsed['type'] !== 'metadata') { - throw new Error(`Wire file missing metadata header at line ${lineNo}`); - } - const pv = parsed['protocol_version']; - const ca = parsed['created_at']; - if (pv !== AGENT_WIRE_PROTOCOL_VERSION || typeof ca !== 'number') { - throw new TypeError(`Wire metadata malformed at line ${lineNo}`); - } - metadata = { protocolVersion: pv, createdAt: ca }; - continue; - } - records.push({ - lineNo, - data: structuredClone(parsed), - raw: parsed, - }); - } - if (metadata === null) { - throw new Error('Wire file is empty (no metadata)'); - } - return { metadata, records }; - } catch (error) { - if (error instanceof DashboardIncompatibilityError) throw error; - throw new DashboardIncompatibilityError('wire', { cause: error }); - } -} diff --git a/apps/dashboard/server/src/routes/blobs.ts b/apps/dashboard/server/src/routes/blobs.ts deleted file mode 100644 index 3df44a7d..00000000 --- a/apps/dashboard/server/src/routes/blobs.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Hono } from 'hono'; -import { join } from 'node:path'; -import { readFile } from 'node:fs/promises'; - -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { isSafeAgentId, readSessionDetail } from '../lib/session-store'; -import { isSafeBlobHash } from '../lib/blob-resolver'; - -export function blobsRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/:id/blobs/:hash', async (c) => { - const id = c.req.param('id'); - const agentId = c.req.query('agent') ?? 'main'; - const hash = c.req.param('hash'); - if (!isSafeAgentId(agentId)) { - return c.json({ error: 'invalid agent id', code: 'BAD_REQUEST' }, 400); - } - if (!isSafeBlobHash(hash)) { - return c.json({ error: 'invalid blob hash', code: 'BAD_REQUEST' }, 400); - } - try { - const detail = await readSessionDetail(home, id); - if (!detail) { - return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - } - const agent = detail.agents.find((a) => a.agentId === agentId); - if (!agent) { - return c.json( - { error: `agent "${agentId}" not found`, code: 'NOT_FOUND' }, - 404, - ); - } - const blobPath = join(detail.sessionDir, 'agents', agentId, 'blobs', hash); - let content: Buffer; - try { - content = await readFile(blobPath); - } catch { - return c.json({ error: 'blob not found', code: 'NOT_FOUND' }, 404); - } - const mimeType = c.req.query('mime') ?? 'application/octet-stream'; - return new Response(content, { - headers: { 'content-type': mimeType }, - }); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - return c.json({ error: (error as Error).message, code: 'READ_ERROR' }, 500); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/routes/context.ts b/apps/dashboard/server/src/routes/context.ts deleted file mode 100644 index 4c2e4f91..00000000 --- a/apps/dashboard/server/src/routes/context.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Hono } from 'hono'; -import { join } from 'node:path'; - -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { isSafeAgentId, readSessionDetail } from '../lib/session-store'; -import { rehydrateWireEntries } from '../lib/blob-resolver'; -import { readAgentWire } from '../lib/wire-reader'; -import { projectContext } from '../lib/context-projector'; - -export function contextRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/:id/context', async (c) => { - const id = c.req.param('id'); - const agentId = c.req.query('agent') ?? 'main'; - if (!isSafeAgentId(agentId)) { - return c.json({ error: 'invalid agent id', code: 'BAD_REQUEST' }, 400); - } - try { - const detail = await readSessionDetail(home, id); - if (!detail) { - return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - } - const agent = detail.agents.find((a) => a.agentId === agentId); - if (!agent || !agent.wireExists) { - return c.json({ error: 'agent wire not found', code: 'NOT_FOUND' }, 404); - } - const wire = await readAgentWire( - join(detail.sessionDir, 'agents', agentId, 'wire.jsonl'), - ); - const baseUrl = new URL(c.req.url).origin; - rehydrateWireEntries(wire.records, id, agentId, baseUrl); - // `?history=full` reconstructs the FULL pre-compaction/undo/clear history - // for debugging; the default mirrors the model's-eye post-compaction view. - const mode = c.req.query('history') === 'full' ? 'full' : 'model'; - const proj = projectContext(wire.records, mode); - return c.json({ - sessionId: id, - agentId, - messages: proj.messages, - usage: proj.usage, - contextTokens: proj.contextTokens, - config: proj.config, - permission: proj.permission, - planMode: proj.planMode, - goal: proj.goal, - dynamicWorkflow: proj.dynamicWorkflow, - }); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - const msg = (error as Error).message; - return c.json({ error: msg, code: 'READ_ERROR' }, 500); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/routes/session-detail.ts b/apps/dashboard/server/src/routes/session-detail.ts deleted file mode 100644 index 1a019737..00000000 --- a/apps/dashboard/server/src/routes/session-detail.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Hono } from 'hono'; -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { readSessionDetail } from '../lib/session-store'; - -export function sessionDetailRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/:id', async (c) => { - const id = c.req.param('id'); - try { - const detail = await readSessionDetail(home, id); - if (!detail) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - return c.json(detail); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - return c.json({ error: (error as Error).message, code: 'READ_ERROR' }, 500); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/routes/sessions.ts b/apps/dashboard/server/src/routes/sessions.ts deleted file mode 100644 index 74aa5083..00000000 --- a/apps/dashboard/server/src/routes/sessions.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Hono } from 'hono'; -import { rm } from 'node:fs/promises'; -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { revealInOs } from '../lib/reveal'; -import { listSessions, readSessionDetail } from '../lib/session-store'; - -export function sessionsRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/', async (c) => { - const sessions = await listSessions(home); - return c.json({ sessions }); - }); - r.delete('/:id', async (c) => { - const id = c.req.param('id'); - const all = await listSessions(home); - const target = all.find((s) => s.sessionId === id); - if (!target) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - await rm(target.sessionDir, { recursive: true, force: true }); - return c.json({ sessionId: id, deleted: true }); - }); - // Open the session directory in the OS file manager. The folder is - // opened on the SERVER host — only meaningful when dashboard runs locally. - r.post('/:id/reveal', async (c) => { - const id = c.req.param('id'); - try { - const detail = await readSessionDetail(home, id); - if (!detail) return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - await revealInOs(detail.sessionDir); - return c.json({ sessionId: id, opened: detail.sessionDir }); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - return c.json( - { error: `failed to open: ${(error as Error).message}`, code: 'READ_ERROR' }, - 500, - ); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/routes/subagents.ts b/apps/dashboard/server/src/routes/subagents.ts deleted file mode 100644 index a4e90c70..00000000 --- a/apps/dashboard/server/src/routes/subagents.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Hono } from 'hono'; -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { readSessionDetail } from '../lib/session-store'; -import { buildAgentTree } from '../lib/agent-tree'; - -export function subagentsRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/:id/agents', async (c) => { - const id = c.req.param('id'); - try { - const detail = await readSessionDetail(home, id); - if (!detail) { - return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - } - return c.json({ sessionId: id, tree: buildAgentTree(detail.agents) }); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - return c.json({ error: (error as Error).message, code: 'READ_ERROR' }, 500); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/routes/wire.ts b/apps/dashboard/server/src/routes/wire.ts deleted file mode 100644 index fde45b3f..00000000 --- a/apps/dashboard/server/src/routes/wire.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Hono } from 'hono'; -import { join } from 'node:path'; - -import { PYTHINKER_CODE_HOME } from '../config'; -import { dashboardIncompatibilityBody } from '../lib/agent-record-types'; -import { isSafeAgentId, readSessionDetail } from '../lib/session-store'; -import { rehydrateWireEntries } from '../lib/blob-resolver'; -import { readAgentWire } from '../lib/wire-reader'; - -export function wireRoute(home: string = PYTHINKER_CODE_HOME): Hono { - const r = new Hono(); - r.get('/:id/wire', async (c) => { - const id = c.req.param('id'); - const agentId = c.req.query('agent') ?? 'main'; - if (!isSafeAgentId(agentId)) { - return c.json({ error: 'invalid agent id', code: 'BAD_REQUEST' }, 400); - } - try { - const detail = await readSessionDetail(home, id); - if (!detail) { - return c.json({ error: 'session not found', code: 'NOT_FOUND' }, 404); - } - const agent = detail.agents.find((a) => a.agentId === agentId); - if (!agent) { - return c.json({ error: `agent "${agentId}" not found`, code: 'NOT_FOUND' }, 404); - } - if (!agent.wireExists) { - return c.json({ error: 'wire missing', code: 'NOT_FOUND' }, 404); - } - const result = await readAgentWire( - join(detail.sessionDir, 'agents', agentId, 'wire.jsonl'), - ); - const baseUrl = new URL(c.req.url).origin; - rehydrateWireEntries(result.records, id, agentId, baseUrl); - return c.json({ - sessionId: id, - agentId, - protocolVersion: result.metadata.protocolVersion, - metadata: result.metadata, - records: result.records, - }); - } catch (error) { - const incompatibility = dashboardIncompatibilityBody(error); - if (incompatibility !== null) return c.json(incompatibility, 409); - const msg = (error as Error).message; - return c.json({ error: msg, code: 'READ_ERROR' }, 500); - } - }); - return r; -} diff --git a/apps/dashboard/server/src/start.ts b/apps/dashboard/server/src/start.ts deleted file mode 100644 index afe31284..00000000 --- a/apps/dashboard/server/src/start.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { serve } from '@hono/node-server'; - -import { createApp } from './app'; -import { hostForUrl, resolveHost, resolvePythinkerCodeHome, resolvePort, resolveDashboardAuthToken } from './config'; -import type { WebAsset } from './lib/web-asset'; - -export interface StartDashboardServerOptions { - /** Sessions home. Defaults to env PYTHINKER_CODE_HOME, else ~/.pythinker-code. */ - readonly homeDir?: string; - /** Port; 0 = auto-pick a free port. Defaults to env PORT, else 3001. */ - readonly port?: number; - readonly host?: string; - readonly authToken?: string; - readonly webAsset?: WebAsset; -} - -export interface StartedDashboardServer { - readonly port: number; - readonly host: string; - readonly url: string; - readonly close: () => Promise<void>; -} - -export async function startDashboardServer( - opts: StartDashboardServerOptions = {}, -): Promise<StartedDashboardServer> { - const host = opts.host ?? resolveHost(); - const authToken = opts.authToken ?? resolveDashboardAuthToken(host); - const homeDir = opts.homeDir ?? resolvePythinkerCodeHome(); - const app = await createApp({ authToken, homeDir, webAsset: opts.webAsset }); - const port = opts.port ?? resolvePort(); - - return new Promise<StartedDashboardServer>((resolveStarted, rejectStarted) => { - const server = serve({ fetch: app.fetch, hostname: host, port }, (info) => { - resolveStarted({ - port: info.port, - host, - url: `http://${hostForUrl(host)}:${info.port}/`, - close: () => - new Promise<void>((done, fail) => { - server.close((err?: Error) => (err ? fail(err) : done())); - }), - }); - }); - server.once('error', rejectStarted); - }); -} diff --git a/apps/dashboard/server/test/fixtures/sessions/sample-compaction/agents/main/wire.jsonl b/apps/dashboard/server/test/fixtures/sessions/sample-compaction/agents/main/wire.jsonl deleted file mode 100644 index 7b71a8e1..00000000 --- a/apps/dashboard/server/test/fixtures/sessions/sample-compaction/agents/main/wire.jsonl +++ /dev/null @@ -1,5 +0,0 @@ -{"type":"metadata","protocol_version":"2.0","created_at":1779256791085} -{"type":"config.update","cwd":"/tmp/work","profileName":"agent","systemPrompt":"You are Pythinker.","time":1779256791100} -{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"before compaction"}],"toolCalls":[]},"time":1779256800001} -{"type":"context.apply_compaction","summary":"compacted summary","compactedCount":1,"tokensBefore":100,"tokensAfter":30,"time":1779256800500} -{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"after compaction"}],"toolCalls":[]},"time":1779256801000} diff --git a/apps/dashboard/server/test/fixtures/sessions/sample-compaction/state.json b/apps/dashboard/server/test/fixtures/sessions/sample-compaction/state.json deleted file mode 100644 index 175f9c56..00000000 --- a/apps/dashboard/server/test/fixtures/sessions/sample-compaction/state.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "sessionFormatVersion": 2, - "createdAt": "2026-05-20T05:59:51.085Z", - "updatedAt": "2026-05-21T03:12:08.000Z", - "title": "fixture: compaction", - "isCustomTitle": false, - "lastPrompt": "after compaction", - "agents": { - "main": { - "type": "main", - "parentAgentId": null - } - }, - "custom": {} -} diff --git a/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/agent-0/wire.jsonl b/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/agent-0/wire.jsonl deleted file mode 100644 index f6c54a80..00000000 --- a/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/agent-0/wire.jsonl +++ /dev/null @@ -1,2 +0,0 @@ -{"type":"metadata","protocol_version":"2.0","created_at":1779256900000} -{"type":"config.update","cwd":"/tmp/work","profileName":"sub","systemPrompt":"You are a sub-agent.","time":1779256900001} diff --git a/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/main/wire.jsonl b/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/main/wire.jsonl deleted file mode 100644 index f578dfee..00000000 --- a/apps/dashboard/server/test/fixtures/sessions/sample-main/agents/main/wire.jsonl +++ /dev/null @@ -1,10 +0,0 @@ -{"type":"metadata","protocol_version":"2.0","created_at":1779256791085} -{"type":"config.update","cwd":"/tmp/work","profileName":"agent","systemPrompt":"You are Pythinker.","time":1779256791100} -{"type":"tools.set_active_tools","names":["Read","Write"],"time":1779256791101} -{"type":"permission.set_mode","mode":"manual","time":1779256791102} -{"type":"turn.prompt","input":[{"type":"text","text":"hi"}],"origin":{"kind":"user"},"time":1779256800000} -{"type":"context.append_message","message":{"role":"user","content":[{"type":"text","text":"hi"}],"toolCalls":[]},"time":1779256800001} -{"type":"context.append_loop_event","event":{"type":"step.begin","uuid":"s1","turnId":"t1","step":0},"time":1779256800100} -{"type":"context.append_loop_event","event":{"type":"content.part","uuid":"c1","turnId":"t1","step":0,"stepUuid":"s1","part":{"type":"text","text":"hello"}},"time":1779256800200} -{"type":"context.append_loop_event","event":{"type":"step.end","uuid":"s1","turnId":"t1","step":0,"usage":{"inputOther":10,"output":5,"inputCacheRead":0,"inputCacheCreation":0},"finishReason":"end_turn"},"time":1779256800300} -{"type":"usage.record","model":"pythinker-k2","usage":{"inputOther":10,"output":5,"inputCacheRead":0,"inputCacheCreation":0},"usageScope":"turn","time":1779256800302} diff --git a/apps/dashboard/server/test/fixtures/sessions/sample-main/state.json b/apps/dashboard/server/test/fixtures/sessions/sample-main/state.json deleted file mode 100644 index caece2a0..00000000 --- a/apps/dashboard/server/test/fixtures/sessions/sample-main/state.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "sessionFormatVersion": 2, - "createdAt": "2026-05-20T05:59:51.085Z", - "updatedAt": "2026-05-21T03:12:08.000Z", - "title": "fixture: hello world", - "isCustomTitle": false, - "lastPrompt": "say hi", - "agents": { - "main": { - "type": "main", - "parentAgentId": null - }, - "agent-0": { - "type": "sub", - "parentAgentId": "main" - } - }, - "custom": {} -} diff --git a/apps/dashboard/server/test/lib/session-store.test.ts b/apps/dashboard/server/test/lib/session-store.test.ts deleted file mode 100644 index 3d74eb59..00000000 --- a/apps/dashboard/server/test/lib/session-store.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -// apps/dashboard/server/test/lib/session-store.test.ts -import { describe, it, expect, afterEach } from 'vitest'; -import { buildSessionFixture } from '../fixtures/build'; -import { isSafeAgentId, listSessions, readSessionDetail } from '../../src/lib/session-store'; - -describe('session-store', () => { - let cleanup: (() => Promise<void>) | null = null; - afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); - - it('lists native session with correct timestamps and counts', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const sessions = await listSessions(home); - expect(sessions).toHaveLength(1); - const s = sessions[0]!; - expect(s.sessionId).toBe('session_fixture'); - expect(s.title).toBe('fixture: hello world'); - expect(s.lastPrompt).toBe('say hi'); - expect(s.agentCount).toBe(2); - expect(s.mainAgentExists).toBe(true); - expect(s.mainWireRecordCount).toBe(10); // 10 lines in main wire incl. metadata - expect(s.wireProtocolVersion).toBe('2.0'); - expect(s.health).toBe('ok'); - expect(s.workDir).toBe('/tmp/work'); - expect(s.createdAt).toBe(Date.parse('2026-05-20T05:59:51.085Z')); - expect(s.updatedAt).toBe(Date.parse('2026-05-21T03:12:08.000Z')); - }); - - it('marks an older wire as incompatible', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - const lines = (await readFile(wirePath, 'utf8')).split('\n'); - lines[0] = JSON.stringify({ type: 'metadata', protocol_version: '1.0', created_at: 1 }); - await writeFile(wirePath, lines.join('\n')); - const sessions = await listSessions(home); - expect(sessions[0]!.health).toBe('incompatible_wire'); - expect(sessions[0]!.wireProtocolVersion).toBeNull(); - }); - - it('marks a newer wire as incompatible', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - const lines = (await readFile(wirePath, 'utf8')).split('\n'); - lines[0] = JSON.stringify({ type: 'metadata', protocol_version: '2.2', created_at: 1 }); - await writeFile(wirePath, lines.join('\n')); - const sessions = await listSessions(home); - expect(sessions[0]!.health).toBe('incompatible_wire'); - expect(sessions[0]!.wireProtocolVersion).toBeNull(); - }); - - it('falls back to empty workDir when session is not in the index', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { rm } = await import('node:fs/promises'); - const { join } = await import('node:path'); - await rm(join(home, 'session_index.jsonl')); - const sessions = await listSessions(home); - expect(sessions).toHaveLength(1); - expect(sessions[0]!.workDir).toBe(''); - }); - - it('marks a session incompatible when its wire file cannot be scanned', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { rm, mkdir } = await import('node:fs/promises'); - const { join } = await import('node:path'); - // Replace the wire FILE with a directory of the same name, so the - // createReadStream below will reject with EISDIR. - const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - await rm(wirePath); - await mkdir(wirePath); - const sessions = await listSessions(home); - expect(sessions).toHaveLength(1); - expect(sessions[0]!.health).toBe('incompatible_wire'); - expect(sessions[0]!.mainWireRecordCount).toBe(0); - }); - - it('keeps a valid session with no main wire listable as missing_main_wire', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { rm } = await import('node:fs/promises'); - const { join } = await import('node:path'); - await rm(join(sessionDir, 'agents', 'main', 'wire.jsonl')); - - const sessions = await listSessions(home); - expect(sessions[0]!.health).toBe('missing_main_wire'); - const detail = await readSessionDetail(home, 'session_fixture'); - expect(detail!.agents.find((agent) => agent.agentId === 'main')!.wireExists).toBe(false); - }); - - it('keeps a valid session with no main agent listable as missing_main_wire', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const statePath = join(sessionDir, 'state.json'); - const state = JSON.parse(await readFile(statePath, 'utf8')) as { - agents: Record<string, { parentAgentId: string | null }>; - }; - delete state.agents['main']; - state.agents['agent-0']!.parentAgentId = null; - await writeFile(statePath, JSON.stringify(state)); - - const sessions = await listSessions(home); - expect(sessions[0]!.health).toBe('missing_main_wire'); - expect(sessions[0]!.mainAgentExists).toBe(false); - }); - - it('marks a session incompatible when the wire metadata header is malformed', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const wirePath = join(sessionDir, 'agents', 'main', 'wire.jsonl'); - // First line is not a `metadata` record — list health used to stay - // 'ok' while readAgentWire would fail on open. - await writeFile( - wirePath, - '{"type":"config.update","cwd":"/x","time":1}\n', - ); - const sessions = await listSessions(home); - expect(sessions).toHaveLength(1); - expect(sessions[0]!.health).toBe('incompatible_wire'); - }); - - it('rejects unsafe agent ids', () => { - expect(isSafeAgentId('main')).toBe(true); - expect(isSafeAgentId('agent-0')).toBe(true); - expect(isSafeAgentId('agent_0.v2')).toBe(true); - expect(isSafeAgentId('..')).toBe(false); - expect(isSafeAgentId('.')).toBe(false); - expect(isSafeAgentId('../foo')).toBe(false); - expect(isSafeAgentId('a/b')).toBe(false); - expect(isSafeAgentId('a\\b')).toBe(false); - expect(isSafeAgentId('')).toBe(false); - }); - - it('rejects unsafe agent ids in strict state metadata', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const statePath = join(sessionDir, 'state.json'); - const state = JSON.parse(await readFile(statePath, 'utf8')); - state.agents['../escape'] = { - type: 'sub', - parentAgentId: 'main', - }; - await writeFile(statePath, JSON.stringify(state)); - await expect(readSessionDetail(home, 'session_fixture')).rejects.toMatchObject({ kind: 'state' }); - }); - - it('rejects session_index entries that point outside PYTHINKER_CODE_HOME', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { writeFile, mkdir } = await import('node:fs/promises'); - const { join } = await import('node:path'); - // Poison the index: claim session_fixture lives at /tmp/elsewhere. - const elsewhere = '/tmp/dashboard-poison-test-' + Date.now(); - await mkdir(elsewhere, { recursive: true }); - await writeFile( - join(home, 'session_index.jsonl'), - JSON.stringify({ - sessionId: 'session_fixture', - sessionDir: elsewhere, - workDir: '/somewhere', - }) + '\n', - ); - // Detail must fall back to bucket scanning (legit path under home) - // rather than honour the poisoned index entry. - const d = await readSessionDetail(home, 'session_fixture'); - expect(d).not.toBeNull(); - expect(d!.sessionDir.startsWith(home)).toBe(true); - const { rm } = await import('node:fs/promises'); - await rm(elsewhere, { recursive: true, force: true }); - }); - - it('rejects an unreadable subagent wire instead of leaking the agent inventory', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - // Break the subagent wire after valid state metadata is loaded. - await writeFile( - join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'), - 'not even json\n', - ); - await expect(readSessionDetail(home, 'session_fixture')).rejects.toMatchObject({ kind: 'wire' }); - }); - - it('exposes the canonical session directory in detail responses', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const d = await readSessionDetail(home, 'session_fixture'); - expect(d!.sessionDir).toBe(sessionDir); - }); - - it('lists incompatible state without exposing detail or disk agents', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - await writeFile(join(sessionDir, 'state.json'), '{ this is not json'); - const summaries = await listSessions(home); - expect(summaries).toHaveLength(1); - expect(summaries[0]!.health).toBe('incompatible_state'); - await expect(readSessionDetail(home, 'session_fixture')).rejects.toMatchObject({ kind: 'state' }); - }); - - it('reads session detail with full agent inventory', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const d = await readSessionDetail(home, 'session_fixture'); - expect(d).not.toBeNull(); - expect(d!.workDir).toBe('/tmp/work'); - expect(d!.agents.map((a) => a.agentId).toSorted()).toEqual(['agent-0', 'main']); - const main = d!.agents.find((a) => a.agentId === 'main')!; - expect(main.type).toBe('main'); - expect(main.parentAgentId).toBeNull(); - expect(main.wireExists).toBe(true); - expect(main.wireRecordCount).toBe(10); - const sub = d!.agents.find((a) => a.agentId === 'agent-0')!; - expect(sub.parentAgentId).toBe('main'); - }); - - it('surfaces dynamicWorkflowItem from state metadata without the removed field', async () => { - const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const { readFile, writeFile } = await import('node:fs/promises'); - const { join } = await import('node:path'); - const statePath = join(sessionDir, 'state.json'); - const state = JSON.parse(await readFile(statePath, 'utf8')); - state.agents['agent-0'].dynamicWorkflowItem = 'task A'; - await writeFile(statePath, JSON.stringify(state)); - const d = await readSessionDetail(home, 'session_fixture'); - expect(d).not.toBeNull(); - const sub = d!.agents.find((a) => a.agentId === 'agent-0')!; - expect(sub.dynamicWorkflowItem).toBe('task A'); - expect(sub).not.toHaveProperty('swarmItem'); - // main has no dynamicWorkflowItem in state.json → null, not undefined. - const main = d!.agents.find((a) => a.agentId === 'main')!; - expect(main.dynamicWorkflowItem).toBeNull(); - }); -}); diff --git a/apps/dashboard/server/test/lib/start.test.ts b/apps/dashboard/server/test/lib/start.test.ts deleted file mode 100644 index 09f03f30..00000000 --- a/apps/dashboard/server/test/lib/start.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { gzipSync } from 'node:zlib'; -import { startDashboardServer } from '../../src/start'; - -let stop: (() => Promise<void>) | null = null; -afterEach(async () => { if (stop) await stop(); stop = null; }); - -describe('startDashboardServer', () => { - it('serves the embedded web asset and the API on an auto-picked port', async () => { - const html = '<!doctype html><title>dashboard'; - const server = await startDashboardServer({ - port: 0, // auto-pick - homeDir: '/tmp/does-not-exist-home', // no sessions; API still responds - webAsset: { gzipped: new Uint8Array(gzipSync(Buffer.from(html))) }, - }); - stop = server.close; - expect(server.port).toBeGreaterThan(0); - - const page = await fetch(`${server.url}`); - expect(page.status).toBe(200); - expect(page.headers.get('content-type')).toContain('text/html'); - expect(await page.text()).toContain('dashboard'); // fetch auto-inflates gzip - - const spa = await fetch(`${server.url}sessions/anything`); - expect(await spa.text()).toContain('dashboard'); // SPA fallback - - const api = await fetch(`${server.url}api/sessions`); - expect(api.status).toBe(200); // empty list for a missing home, not a crash - }); - - it('rejects instead of hanging when the port is already bound', async () => { - const first = await startDashboardServer({ port: 0, homeDir: '/tmp/does-not-exist-home' }); - stop = first.close; - const taken = first.port; - - // A second bind on the same port must REJECT (EADDRINUSE), not hang - // forever or escape as an uncaughtException. - await expect( - startDashboardServer({ port: taken, homeDir: '/tmp/does-not-exist-home' }), - ).rejects.toThrow(/EADDRINUSE/); - }); -}); diff --git a/apps/dashboard/server/test/lib/wire-reader.test.ts b/apps/dashboard/server/test/lib/wire-reader.test.ts deleted file mode 100644 index 8906c0ad..00000000 --- a/apps/dashboard/server/test/lib/wire-reader.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { buildSessionFixture } from '../fixtures/build'; -import { readAgentWire } from '../../src/lib/wire-reader'; - -describe('wire-reader', () => { - let cleanup: (() => Promise) | null = null; - afterEach(async () => { - if (cleanup) await cleanup(); - cleanup = null; - }); - - it('reads main agent wire and assigns line numbers', async () => { - const { sessionDir, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const result = await readAgentWire(join(sessionDir, 'agents', 'main', 'wire.jsonl')); - expect(result.metadata.protocolVersion).toBe('2.0'); - expect(result.records[0]!.lineNo).toBe(2); // metadata is line 1, first record is line 2 - expect(result.records.at(-1)!.lineNo).toBe(10); - expect(result.records.map((r) => r.data.type)).toEqual([ - 'config.update', - 'tools.set_active_tools', - 'permission.set_mode', - 'turn.prompt', - 'context.append_message', - 'context.append_loop_event', - 'context.append_loop_event', - 'context.append_loop_event', - 'usage.record', - ]); - expect(result).not.toHaveProperty('warnings'); - // No dashboard annotation should leak into the data/raw bodies. - for (const entry of result.records) { - expect(entry.data).not.toHaveProperty('_lineNo'); - expect(entry.raw as object).not.toHaveProperty('_lineNo'); - } - }); - - it('skips blank lines before the exact metadata header', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dashboard-blank-wire-header-')); - const path = join(dir, 'wire.jsonl'); - await writeFile( - path, - `\n \n${JSON.stringify({ type: 'metadata', protocol_version: '2.0', created_at: 1 })}\n${JSON.stringify({ type: 'config.update', cwd: '/tmp', time: 2 })}\n`, - ); - try { - const result = await readAgentWire(path); - expect(result.metadata).toEqual({ protocolVersion: '2.0', createdAt: 1 }); - expect(result.records).toHaveLength(1); - expect(result.records[0]!.lineNo).toBe(4); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('rejects a wire without an exact metadata header', async () => { - const dir = await mkdtemp(join(tmpdir(), 'dashboard-missing-wire-header-')); - const path = join(dir, 'wire.jsonl'); - await writeFile(path, `${JSON.stringify({ type: 'config.update', cwd: '/tmp', time: 1 })}\n`); - try { - await expect(readAgentWire(path)).rejects.toThrow('agent wire is incompatible'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it.each(['1.1', '2.1'])('rejects non-current wire protocol %s', async (protocolVersion) => { - const dir = await mkdtemp(join(tmpdir(), 'dashboard-incompatible-wire-')); - const path = join(dir, 'wire.jsonl'); - await writeFile( - path, - [ - JSON.stringify({ type: 'metadata', protocol_version: protocolVersion, created_at: 1 }), - JSON.stringify({ type: 'config.update', cwd: '/tmp', time: 2 }), - ].join('\n') + '\n', - ); - try { - await expect(readAgentWire(path)).rejects.toThrow('agent wire is incompatible'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it.each([ - ['malformed JSON', 'not json'], - ['record without a type', JSON.stringify({ record: 'missing type' })], - ['unknown record discriminant', JSON.stringify({ type: 'unknown.record' })], - ])('rejects a %s after the metadata header', async (_name, record) => { - const dir = await mkdtemp(join(tmpdir(), 'dashboard-invalid-wire-record-')); - const path = join(dir, 'wire.jsonl'); - await writeFile( - path, - `${JSON.stringify({ type: 'metadata', protocol_version: '2.0', created_at: 1 })}\n${record}\n`, - ); - try { - await expect(readAgentWire(path)).rejects.toThrow('agent wire is incompatible'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); -}); diff --git a/apps/dashboard/server/test/routes/context.test.ts b/apps/dashboard/server/test/routes/context.test.ts deleted file mode 100644 index d62786bf..00000000 --- a/apps/dashboard/server/test/routes/context.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { buildSessionFixture } from '../fixtures/build'; -import { contextRoute } from '../../src/routes/context'; - -describe('context route', () => { - let cleanup: (() => Promise) | null = null; - afterEach(async () => { if (cleanup) await cleanup(); cleanup = null; }); - - it('echoes the new projection fields without the removed swarm DTO', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - - const app = contextRoute(home); - const res = await app.request('/session_fixture/context?agent=main'); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - - // The route must pass these projection fields straight through (it used to - // cherry-pick only messages/usage/config/permission/planMode). - expect(body).toHaveProperty('contextTokens'); - expect(body).toHaveProperty('goal'); - expect(body).toHaveProperty('dynamicWorkflow'); - expect(body).not.toHaveProperty('swarm'); - - // The sample fixture's only step.end carries usage 10+5 → contextTokens=15, - // and has no goal / dynamic workflow records. - expect(body['contextTokens']).toBe(15); - expect(body['goal']).toBeNull(); - expect(body['dynamicWorkflow']).toEqual({ active: false }); - }); - - it('still echoes the existing fields', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - - const app = contextRoute(home); - const res = await app.request('/session_fixture/context?agent=main'); - expect(res.status).toBe(200); - const body = (await res.json()) as Record; - - expect(body['sessionId']).toBe('session_fixture'); - expect(body['agentId']).toBe('main'); - expect(body).toHaveProperty('messages'); - expect(body).toHaveProperty('usage'); - expect(body).toHaveProperty('config'); - expect(body).toHaveProperty('permission'); - expect(body).toHaveProperty('planMode'); - }); - - it('returns 404 for missing session', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const app = contextRoute(home); - const res = await app.request('/no-such-session/context?agent=main'); - expect(res.status).toBe(404); - expect(await res.json()).toMatchObject({ code: 'NOT_FOUND' }); - }); - - it('returns 400 for invalid agent id', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-main'); - cleanup = c; - const app = contextRoute(home); - const res = await app.request('/session_fixture/context?agent=../escape'); - expect(res.status).toBe(400); - expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' }); - }); - - it('?history=full returns the pre-compaction messages (full reconstructed history)', async () => { - const { home, cleanup: c } = await buildSessionFixture('sample-compaction'); - cleanup = c; - const app = contextRoute(home); - - // Default (model view): the pre-compaction message is dropped, leaving - // [summary, after-compaction]. - const modelRes = await app.request('/session_fixture/context?agent=main'); - expect(modelRes.status).toBe(200); - const modelBody = (await modelRes.json()) as { - messages: { source: string; message: { content: { type: string; text?: string }[] } }[]; - }; - expect(modelBody.messages.map((m) => m.source)).toEqual([ - 'compaction_summary', 'append_message', - ]); - - // Full history: the pre-compaction message is KEPT, then the summary marker, - // then the post-compaction tail. - const fullRes = await app.request('/session_fixture/context?agent=main&history=full'); - expect(fullRes.status).toBe(200); - const fullBody = (await fullRes.json()) as { - messages: { source: string; message: { content: { type: string; text?: string }[] } }[]; - }; - expect(fullBody.messages.map((m) => m.source)).toEqual([ - 'append_message', 'compaction_summary', 'append_message', - ]); - expect(fullBody.messages[0]!.message.content[0]).toMatchObject({ text: 'before compaction' }); - expect(fullBody.messages[2]!.message.content[0]).toMatchObject({ text: 'after compaction' }); - }); -}); diff --git a/apps/dashboard/server/test/routes/incompatibility.test.ts b/apps/dashboard/server/test/routes/incompatibility.test.ts deleted file mode 100644 index 1d195750..00000000 --- a/apps/dashboard/server/test/routes/incompatibility.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { readFile, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { createApp } from '../../src/app'; -import { buildSessionFixture } from '../fixtures/build'; - -const SESSION_ID = 'session_fixture'; -const READ_ROUTES = [ - `/api/sessions/${SESSION_ID}`, - `/api/sessions/${SESSION_ID}/context?agent=main`, - `/api/sessions/${SESSION_ID}/wire?agent=main`, - `/api/sessions/${SESSION_ID}/agents`, - `/api/sessions/${SESSION_ID}/blobs/${'a'.repeat(64)}?agent=main`, -] as const; - -describe('session incompatibility routes', () => { - let cleanup: (() => Promise) | null = null; - - afterEach(async () => { - if (cleanup !== null) await cleanup(); - cleanup = null; - }); - - it('returns state incompatibility from every session-scoped read route', async () => { - expect.hasAssertions(); - const fixture = await buildSessionFixture('sample-main'); - cleanup = fixture.cleanup; - const statePath = join(fixture.sessionDir, 'state.json'); - const state = JSON.parse(await readFile(statePath, 'utf8')) as Record; - state['sessionFormatVersion'] = 1; - await writeFile(statePath, JSON.stringify(state)); - - await expectIncompatibilityMatrix( - fixture.home, - 'incompatible_state', - { error: 'session state is incompatible', code: 'INCOMPATIBLE_SESSION_STATE' }, - ); - }); - - it('returns wire incompatibility from every session-scoped read route', async () => { - expect.hasAssertions(); - const fixture = await buildSessionFixture('sample-main'); - cleanup = fixture.cleanup; - const wirePath = join(fixture.sessionDir, 'agents', 'main', 'wire.jsonl'); - const lines = (await readFile(wirePath, 'utf8')).split('\n'); - lines[0] = JSON.stringify({ type: 'metadata', protocol_version: '1.1', created_at: 1 }); - await writeFile(wirePath, lines.join('\n')); - - await expectIncompatibilityMatrix( - fixture.home, - 'incompatible_wire', - { error: 'agent wire is incompatible', code: 'INCOMPATIBLE_AGENT_WIRE' }, - ); - }); -}); - -async function expectIncompatibilityMatrix( - homeDir: string, - health: 'incompatible_state' | 'incompatible_wire', - body: { error: string; code: string }, -): Promise { - const app = await createApp({ homeDir }); - const list = await app.request('/api/sessions'); - expect(list.status).toBe(200); - expect(await list.json()).toMatchObject({ sessions: [{ health }] }); - - for (const route of READ_ROUTES) { - const response = await app.request(route); - expect(response.status, route).toBe(409); - expect(await response.json()).toEqual(body); - } -} diff --git a/apps/dashboard/server/vitest.config.ts b/apps/dashboard/server/vitest.config.ts deleted file mode 100644 index df29de56..00000000 --- a/apps/dashboard/server/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - name: 'dashboard-server', - include: ['test/**/*.test.ts'], - }, -}); diff --git a/apps/dashboard/web/index.html b/apps/dashboard/web/index.html deleted file mode 100644 index bf0f558d..00000000 --- a/apps/dashboard/web/index.html +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - pythinker dashboard - - - - - - - -

- - - diff --git a/apps/dashboard/web/package.json b/apps/dashboard/web/package.json deleted file mode 100644 index 700e04f9..00000000 --- a/apps/dashboard/web/package.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "@pymodel/dashboard-web", - "version": "0.1.1", - "private": true, - "license": "MIT", - "type": "module", - "imports": { - "#/*": { - "types": [ - "./src/*.ts", - "./src/*.tsx", - "./src/*/index.ts", - "./src/*/index.tsx" - ], - "default": "./src/*" - } - }, - "scripts": { - "dev": "vite", - "build": "vite build", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@tanstack/react-query": "^5.101.4", - "@tanstack/react-virtual": "^3.14.9", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router": "^8.3.0" - }, - "devDependencies": { - "@tailwindcss/vite": "^4.3.3", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^4.7.0", - "tailwindcss": "^4.3.3", - "typescript": "6.0.3", - "vite": "^6.4.3", - "vite-plugin-singlefile": "^2.3.3" - } -} diff --git a/apps/dashboard/web/src/App.tsx b/apps/dashboard/web/src/App.tsx deleted file mode 100644 index b42d783f..00000000 --- a/apps/dashboard/web/src/App.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Route, Routes } from 'react-router'; -import { AppShell } from './components/layout/AppShell'; -import { SessionListPage } from './pages/SessionListPage'; -import { SessionDetailPage } from './pages/SessionDetailPage'; -import { SubagentDetailPage } from './pages/SubagentDetailPage'; - -export function App() { - return ( - - - } /> - } /> - } - /> - - - ); -} diff --git a/apps/dashboard/web/src/api.ts b/apps/dashboard/web/src/api.ts deleted file mode 100644 index 35a80b44..00000000 --- a/apps/dashboard/web/src/api.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { - SessionSummary, - SessionDetail, - DeleteSessionResponse, - WireResponse, - ContextResponse, - AgentTreeResponse, - ApiError, -} from './types'; - -const TOKEN_STORAGE_KEY = 'pythinker-dashboard-auth-token'; - -export class DashboardApiError extends Error { - readonly status: number; - readonly code?: ApiError['code']; - - constructor(message: string, status: number, code?: ApiError['code']) { - super(message); - this.name = 'DashboardApiError'; - this.status = status; - this.code = code; - } -} - -function readTokenParam(raw: string): string | null { - const trimmed = raw.replace(/^[#?]/, ''); - if (trimmed.length === 0) return null; - const params = new URLSearchParams(trimmed); - return params.get('token') ?? params.get('vis_token'); -} - -function deleteTokenParams(params: URLSearchParams): boolean { - const hadToken = params.has('token') || params.has('vis_token'); - params.delete('token'); - params.delete('vis_token'); - return hadToken; -} - -function scrubTokenFromUrl(): void { - const url = new URL(window.location.href); - const changedSearch = deleteTokenParams(url.searchParams); - const hash = url.hash.replace(/^#/, ''); - let changedHash = false; - if (hash.length > 0) { - const hashParams = new URLSearchParams(hash); - changedHash = deleteTokenParams(hashParams); - if (changedHash) { - const nextHash = hashParams.toString(); - url.hash = nextHash.length > 0 ? nextHash : ''; - } - } - if (changedSearch || changedHash) { - window.history.replaceState(null, '', url.toString()); - } -} - -function authToken(): string | null { - if (typeof window === 'undefined') return null; - const fromHash = readTokenParam(window.location.hash); - const fromSearch = readTokenParam(window.location.search); - const token = fromHash ?? fromSearch; - if (token !== null && token.length > 0) { - window.localStorage.setItem(TOKEN_STORAGE_KEY, token); - scrubTokenFromUrl(); - return token; - } - return window.localStorage.getItem(TOKEN_STORAGE_KEY); -} - -async function request(path: string, method: 'GET' | 'POST' | 'DELETE'): Promise { - const headers: Record = { accept: 'application/json' }; - const token = authToken(); - if (token !== null && token.length > 0) { - headers['authorization'] = `Bearer ${token}`; - } - const res = await fetch(path, { method, headers }); - if (!res.ok) { - let err: ApiError | null = null; - try { - err = (await res.json()) as ApiError; - } catch { - /* ignore */ - } - throw new DashboardApiError( - err?.error ?? `HTTP ${res.status} ${res.statusText}`, - res.status, - err?.code, - ); - } - return (await res.json()) as T; -} - -function get(path: string): Promise { - return request(path, 'GET'); -} - -function post(path: string): Promise { - return request(path, 'POST'); -} - -function del(path: string): Promise { - return request(path, 'DELETE'); -} - -const enc = encodeURIComponent; - -interface SessionsListResponse { - sessions: SessionSummary[]; -} - -export const api = { - listSessions: async (): Promise => { - const r = await get('/api/sessions'); - return r.sessions; - }, - - getSession: (id: string) => get(`/api/sessions/${enc(id)}`), - - getWire: (id: string, agentId: string) => - get(`/api/sessions/${enc(id)}/wire?agent=${enc(agentId)}`), - - getContext: (id: string, agentId: string, mode?: 'model' | 'full') => - get( - `/api/sessions/${enc(id)}/context?agent=${enc(agentId)}` + - (mode === 'full' ? '&history=full' : ''), - ), - - getAgentTree: (id: string) => - get(`/api/sessions/${enc(id)}/agents`), - - deleteSession: (id: string) => del(`/api/sessions/${enc(id)}`), - - /** Open the session's on-disk folder in the OS file manager. Side - * effect runs on the server, so this only makes sense for local - * development against a loopback dashboard-server. */ - revealSession: (id: string) => - post<{ sessionId: string; opened: string }>(`/api/sessions/${enc(id)}/reveal`), -}; diff --git a/apps/dashboard/web/src/components/sessions/SessionFilter.tsx b/apps/dashboard/web/src/components/sessions/SessionFilter.tsx deleted file mode 100644 index 4ddc14b6..00000000 --- a/apps/dashboard/web/src/components/sessions/SessionFilter.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { SessionSortKey, HealthFilter } from './SessionRail'; - -interface SessionFilterProps { - search: string; - onSearchChange: (v: string) => void; - sortKey: SessionSortKey; - onSortChange: (v: SessionSortKey) => void; - healthFilter: HealthFilter; - onHealthChange: (v: HealthFilter) => void; - totalCount: number; - filteredCount: number; -} - -const SORT_OPTIONS: { value: SessionSortKey; label: string }[] = [ - { value: 'recent', label: 'recent' }, - { value: 'oldest', label: 'oldest' }, - { value: 'most_records', label: 'most records' }, - { value: 'most_subagents', label: 'most subagents' }, -]; - -const HEALTH_OPTIONS: { value: HealthFilter; label: string }[] = [ - { value: 'all', label: 'any' }, - { value: 'ok', label: 'ok' }, - { value: 'incompatible_state', label: 'incompatible state' }, - { value: 'incompatible_wire', label: 'incompatible wire' }, - { value: 'missing_main_wire', label: 'no main wire' }, -]; - -export function SessionFilter({ - search, - onSearchChange, - sortKey, - onSortChange, - healthFilter, - onHealthChange, - totalCount, - filteredCount, -}: SessionFilterProps) { - return ( -
-
- { onSearchChange(e.target.value); }} - placeholder="search id / title / workspace" - className="w-full border border-border bg-surface-0 px-2 py-1 font-mono text-[12px] text-fg-0 placeholder:text-fg-3 focus:border-border-strong focus:outline-none" - /> -
-
- - -
-
- - {filteredCount} / {totalCount} - -
-
- ); -} diff --git a/apps/dashboard/web/src/components/state/StateTab.tsx b/apps/dashboard/web/src/components/state/StateTab.tsx deleted file mode 100644 index bf4ed2b5..00000000 --- a/apps/dashboard/web/src/components/state/StateTab.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useMemo } from 'react'; - -import { formatAbsoluteTime, formatRelativeTime } from '../../util/time'; -import { CopyButton } from '../shared/CopyButton'; -import { JsonViewer } from '../shared/JsonViewer'; -import { Pill } from '../shared/Pill'; - -interface StateTabProps { - state: unknown; -} - -interface StateJsonShape { - title?: string; - isCustomTitle?: boolean; - lastPrompt?: string; - forkedFrom?: string; - createdAt?: string; - updatedAt?: string; - agents?: Record; - custom?: Record; -} - -/** State tab — renders the raw `state.json` blob from session detail. - * At the top, a handful of highlight cards surface the most-asked fields - * (title / lastPrompt / created / updated / agent count). Below that, the - * full JSON is shown via the shared JsonViewer so any custom fields the - * upstream writer adds remain readable without code changes. */ -export function StateTab({ state }: StateTabProps) { - const s = useMemo(() => { - return (state ?? {}) as StateJsonShape; - }, [state]); - - const createdMs = parseIso(s.createdAt); - const updatedMs = parseIso(s.updatedAt); - const agentIds = s.agents !== undefined ? Object.keys(s.agents) : []; - return ( -
-
-
- state.json -
- -
- - {/* Highlight cards */} -
- - {s.title !== undefined && s.title !== '' ? ( - "{s.title}" - ) : ( - (none) - )} - {s.isCustomTitle === true ? ( - - custom - - ) : null} - - - - {s.forkedFrom !== undefined && s.forkedFrom !== '' ? ( - - {s.forkedFrom} - - ) : ( - (none) - )} - - - - - - - - - - - - {s.lastPrompt !== undefined && s.lastPrompt !== '' ? ( - - {s.lastPrompt} - - ) : ( - (none) - )} - - - - {agentIds.length === 0 ? ( - (none) - ) : ( - - {agentIds.map((id) => ( - - {id} - - ))} - - )} - -
- - {/* Custom blob */} -
-

- custom -

-
- {s.custom === undefined || Object.keys(s.custom).length === 0 ? ( - (empty) - ) : ( - - )} -
-
- - {/* Raw JSON */} -
-

- raw state.json -

-
- -
-
-
- ); -} - -function Card({ label, children }: { label: string; children: import('react').ReactNode }) { - return ( -
-
- {label} -
-
{children}
-
- ); -} - -function TsValue({ ms, raw }: { ms: number | null; raw: string | undefined }) { - if (ms === null) { - return raw !== undefined && raw !== '' ? ( - {raw} - ) : ( - (none) - ); - } - return ( - - - {formatAbsoluteTime(ms)} - - - ({formatRelativeTime(ms)}) - - - ); -} - -function parseIso(input: string | undefined): number | null { - if (input === undefined || input === '') return null; - const n = Date.parse(input); - return Number.isFinite(n) ? n : null; -} diff --git a/apps/dashboard/web/src/components/wire/WireRowDetail.tsx b/apps/dashboard/web/src/components/wire/WireRowDetail.tsx deleted file mode 100644 index 4cdfc821..00000000 --- a/apps/dashboard/web/src/components/wire/WireRowDetail.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from 'react'; - -import type { AgentRecord, WireEntry } from '../../types'; -import { CopyButton } from '../shared/CopyButton'; -import { JsonViewer } from '../shared/JsonViewer'; -import { GenericDetail } from './parts'; -import { rendererFor } from './renderers'; - -interface WireRowDetailProps { - entry: WireEntry; - /** Scroll to + expand a given line. */ - onJumpTo?: (lineNo: number) => void; -} - -type JsonView = 'none' | 'raw'; - -export function WireRowDetail({ entry }: WireRowDetailProps) { - const [view, setView] = useState('none'); - - return ( -
- {renderFriendly(entry.data)} -
- - -
- {view !== 'none' ? ( -
-
- as written on disk -
- -
- ) : null} -
- ); -} - -/** Render the expanded detail for a wire record. Thin dispatch to the per-kind - * registry's `detail`; kinds without one fall back to a structured JSON dump. */ -function renderFriendly(record: AgentRecord) { - const renderer = rendererFor(record.type); - if (renderer?.detail !== undefined) return renderer.detail(record); - return ; -} diff --git a/apps/dashboard/web/src/components/wire/renderers.tsx b/apps/dashboard/web/src/components/wire/renderers.tsx deleted file mode 100644 index 055f599c..00000000 --- a/apps/dashboard/web/src/components/wire/renderers.tsx +++ /dev/null @@ -1,606 +0,0 @@ -// The single wire-renderer registry. Co-locates tone + label + headline + -// detail for every record kind. Because `WIRE_RENDERERS` is typed as a mapped -// type over the FULL `RecordType` union, TypeScript REQUIRES an entry for each -// kind: adding a kind upstream in agent-core fails -// `pnpm --filter @pymodel/dashboard-web typecheck` here until a renderer is -// added. This is the anti-rot guarantee that keeps dashboard from silently falling -// behind the wire protocol. - -import type { ReactNode } from 'react'; - -import type { AgentRecord, AgentRecordOf } from '../../types'; -import type { PillTone } from '../shared/Pill'; -import { Pill } from '../shared/Pill'; -import { - Dim, - type HeadlineRender, - LoopEventDetail, - MessageDetail, - Mono, - ContentPartView, - FieldRow, - firstText, - truncate, - loopEventSummary, -} from './parts'; -import { SizePreview } from '../shared/SizePreview'; -import { JsonViewer } from '../shared/JsonViewer'; - -export type RecordType = AgentRecord['type']; - -export interface WireRenderer { - tone: PillTone; - /** Compact badge label. */ - label: string; - /** One-line collapsed summary. */ - headline: (r: AgentRecordOf) => HeadlineRender; - /** Expanded detail. Omit to fall back to a full structured JSON dump. */ - detail?: (r: AgentRecordOf) => ReactNode; -} - -/** A registry entry for every record kind. The value type is a mapped type - * over the full `RecordType` union, so TypeScript forces an entry per kind. */ -type RendererMap = { [K in RecordType]: WireRenderer }; - -export const WIRE_RENDERERS: RendererMap = { - metadata: { - tone: 'meta', - label: 'meta', - headline: (r) => ({ - main: ( - - protocol v{r.protocol_version} - · - created {new Date(r.created_at).toLocaleString()} - - ), - }), - }, - - forked: { - tone: 'lifecycle', - label: 'fork', - headline: () => ({ main: session forked }), - }, - - 'config.update': { - tone: 'config', - label: 'config', - headline: (r) => { - const parts: string[] = []; - if (r.profileName !== undefined) parts.push(`profile=${r.profileName}`); - if (r.modelAlias !== undefined) parts.push(`model=${r.modelAlias}`); - if (r.cwd !== undefined) parts.push(`cwd=${r.cwd}`); - if (r.thinkingLevel !== undefined) parts.push(`thinking=${r.thinkingLevel}`); - if (r.systemPrompt !== undefined) parts.push(`system(${r.systemPrompt.length}b)`); - return { - main: ( - - {parts.length === 0 ? (no fields) : parts.join(' · ')} - - ), - }; - }, - }, - - 'turn.prompt': { - tone: 'turn', - label: 'prompt', - headline: (r) => { - const text = firstText(r.input); - return { - main: ( - - - {r.origin.kind} - - → {truncate(text, 80)} - - ), - }; - }, - detail: (r) => ( -
-
- - - -
-
-
- input ({r.input.length} part{r.input.length === 1 ? '' : 's'}) -
-
- {r.input.map((part, i) => ( - - ))} -
-
-
- ), - }, - - 'turn.steer': { - tone: 'turn', - label: 'steer', - headline: (r) => { - const text = firstText(r.input); - return { - main: ( - - - {r.origin.kind} - - → {truncate(text, 80)} - - ), - }; - }, - detail: (r) => ( -
-
- - - -
-
-
- input ({r.input.length} part{r.input.length === 1 ? '' : 's'}) -
-
- {r.input.map((part, i) => ( - - ))} -
-
-
- ), - }, - - 'turn.cancel': { - tone: 'warning', - label: 'cancel', - headline: (r) => ({ - main: {r.turnId !== undefined ? `turn ${r.turnId}` : '(latest)'}, - }), - }, - - 'context.append_message': { - tone: 'assistant', - label: 'message', - headline: (r) => { - const m = r.message; - const tc = m.toolCalls.length > 0 ? `${m.toolCalls.length} tool_call(s)` : ''; - return { - main: ( - - - {m.role} - - ({m.content.length} part{m.content.length === 1 ? '' : 's'}) - {tc ? · {tc} : null} - {m.origin?.kind ? · origin={m.origin.kind} : null} - - ), - right: m.isError === true ? ( - - error - - ) : undefined, - }; - }, - detail: (r) => , - }, - - 'context.append_loop_event': { - tone: 'meta', - label: 'loop', - headline: (r) => ({ - main: ( - - {r.event.type} - {loopEventSummary(r.event)} - - ), - }), - detail: (r) => , - }, - - 'context.clear': { - tone: 'warning', - label: 'clear', - headline: () => ({ main: context cleared }), - }, - - 'context.apply_compaction': { - tone: 'compaction', - label: 'compacted', - headline: (r) => ({ - main: ( - - - compacted - - - summary {r.summary.length}b · {r.tokensBefore}→{r.tokensAfter} tok · {r.compactedCount}{' '} - msgs - - - ), - }), - detail: (r) => ( -
- - -
{r.summary}
-
-
- - {r.compactedCount} - - - {r.tokensBefore} - - - {r.tokensAfter} - -
- ), - }, - - 'context.undo': { - tone: 'warning', - label: 'undo', - headline: (r) => ({ - main: ( - - - undo - - - {r.count} prompt{r.count === 1 ? '' : 's'} - - - ), - }), - }, - - 'tools.register_user_tool': { - tone: 'tools', - label: 'tool+', - headline: (r) => ({ - main: ( - - + {r.name} - - ), - }), - }, - - 'tools.unregister_user_tool': { - tone: 'tools', - label: 'tool-', - headline: (r) => ({ - main: ( - - - {r.name} - - ), - }), - }, - - 'tools.set_active_tools': { - tone: 'tools', - label: 'tools', - headline: (r) => { - const head = r.names.slice(0, 3).join(', '); - const rest = r.names.length > 3 ? ` +${r.names.length - 3} more` : ''; - return { - main: ( - - {head} - {rest} - - ), - right: {r.names.length} tools, - }; - }, - }, - - 'tools.update_store': { - tone: 'meta', - label: 'store', - headline: (r) => { - const valuePreview = - typeof r.value === 'object' && r.value !== null - ? '(object)' - : truncate(String(r.value), 60); - return { - main: ( - - {r.key} - = {valuePreview} - - ), - }; - }, - }, - - 'permission.set_mode': { - tone: 'approval', - label: 'perm', - headline: (r) => ({ - main: ( - - mode → - - {r.mode} - - - ), - }), - }, - - 'permission.record_approval_result': { - tone: 'approval', - label: 'approval', - headline: (r) => { - const tone = - r.result.decision === 'approved' - ? 'success' - : r.result.decision === 'rejected' - ? 'error' - : 'neutral'; - return { - main: ( - - - {r.toolName}#{r.toolCallId.slice(-8)} - - - {r.result.decision} - - {r.result.scope ? ({r.result.scope}) : null} - - ), - }; - }, - detail: (r) => ( -
- - {r.toolName} - - - {r.toolCallId} - - - {r.action} - - - {r.turnId} - - - {r.result.decision} - - {r.result.scope !== undefined ? ( - - {r.result.scope} - - ) : null} - {r.sessionApprovalRule !== undefined ? ( - - {r.sessionApprovalRule} - - ) : null} - {r.result.selectedLabel !== undefined ? ( - - {r.result.selectedLabel} - - ) : null} - {r.result.feedback !== undefined ? ( - -
{r.result.feedback}
-
- ) : null} -
- ), - }, - - 'usage.record': { - tone: 'meta', - label: 'usage', - headline: (r) => ({ - main: ( - - {r.model} - - in {r.usage.inputOther} / out {r.usage.output} / cache r{r.usage.inputCacheRead} w - {r.usage.inputCacheCreation} - - - ), - right: r.usageScope ? ( - - {r.usageScope} - - ) : undefined, - }), - }, - - 'full_compaction.begin': { - tone: 'compaction', - label: 'compact↻', - headline: (r) => ({ - main: ( - - - {r.source} - - {r.instruction ? ( - "{truncate(r.instruction, 40)}" - ) : null} - - ), - }), - }, - - 'full_compaction.cancel': { - tone: 'warning', - label: 'compact×', - headline: () => ({ main: cancelled }), - }, - - // `full_compaction.complete` has an EMPTY payload (`{}`). The previous code - // read `r.summary` / `r.compactedCount` / `r.tokensBefore` / `r.tokensAfter`, - // none of which exist on this record — a runtime crash. Those fields belong - // to `context.apply_compaction` (its own entry above). This is a static, - // payload-free renderer; the generic JSON dump shows type + time only. - 'full_compaction.complete': { - tone: 'success', - label: 'compact✓', - headline: () => ({ main: compaction complete }), - }, - - 'micro_compaction.apply': { - tone: 'compaction', - label: 'µcompact', - headline: (r) => ({ - main: ( - - - micro - - cutoff {r.cutoff} - - ), - }), - }, - - 'plan_mode.enter': { - tone: 'lifecycle', - label: 'plan↻', - headline: (r) => ({ - main: ( - - - enter - - {r.id} - - ), - }), - }, - - 'plan_mode.cancel': { - tone: 'warning', - label: 'plan×', - headline: (r) => ({ - main: ( - - - cancel - - {r.id ?? '(latest)'} - - ), - }), - }, - - 'plan_mode.exit': { - tone: 'success', - label: 'plan✓', - headline: (r) => ({ - main: ( - - - exit - - {r.id ?? '(latest)'} - - ), - }), - }, - - 'dynamic_workflow_mode.enter': { - tone: 'subagent', - label: 'workflow↻', - headline: (r) => ({ - main: ( - - - enter - - {r.trigger} - - ), - }), - }, - - 'dynamic_workflow_mode.exit': { - tone: 'subagent', - label: 'workflow✓', - headline: () => ({ main: dynamic workflow exited }), - }, - - 'goal.create': { - tone: 'lifecycle', - label: 'goal+', - headline: (r) => ({ - main: ( - - - goal - - {r.objective} - - ), - }), - }, - - 'goal.update': { - tone: 'lifecycle', - label: 'goal', - headline: (r) => { - const parts: string[] = []; - if (r.status !== undefined) parts.push(`status=${r.status}`); - if (r.actor !== undefined) parts.push(`by=${r.actor}`); - if (r.turnsUsed !== undefined) parts.push(`turns=${r.turnsUsed}`); - if (r.tokensUsed !== undefined) parts.push(`tok=${r.tokensUsed}`); - return { - main: ( - - {parts.length === 0 ? (no change) : parts.join(' · ')} - - ), - }; - }, - }, - - 'goal.clear': { - tone: 'warning', - label: 'goal×', - headline: () => ({ main: goal cleared }), - }, -}; - -/** Look up a renderer by runtime type. - * - * The `as unknown as` widening is the one place we sidestep TypeScript's - * correlated-union limitation: each entry's `headline`/`detail` is narrowed to - * its own kind, but at dispatch time we only have the union, so we widen the - * value to `WireRenderer` (callable with any `AgentRecord`). Safe - * because we only ever call it with the matching record. */ -export function rendererFor(type: string): WireRenderer | undefined { - return (WIRE_RENDERERS as unknown as Record>)[type]; -} diff --git a/apps/dashboard/web/src/lib/issues.ts b/apps/dashboard/web/src/lib/issues.ts deleted file mode 100644 index 6b6aad25..00000000 --- a/apps/dashboard/web/src/lib/issues.ts +++ /dev/null @@ -1,172 +0,0 @@ -// Aggregate every "something went wrong" signal from a wire timeline -// into a flat list consumable by the Issues drawer. Pure — no React. -// -// Detection rules for the new agent-core wire protocol: -// - tool.call without paired tool.result (orphan tool.call) -// - tool.result without preceding tool.call (orphan tool.result) -// - step.begin without paired step.end (incomplete step) -// - full_compaction.begin without complete/cancel (incomplete compaction) -// - plan_mode.enter without exit/cancel (still in plan mode) -// - permission.record_approval_result with decision='rejected' (info) - -import type { WireEntry } from '../types'; - -export type IssueSeverity = 'error' | 'warning' | 'info'; - -export type IssueKind = - | 'orphan_tool_call' - | 'missing_tool_result' - | 'incomplete_step' - | 'incomplete_compaction' - | 'active_plan_mode' - | 'rejected_approval'; - -export interface Issue { - severity: IssueSeverity; - kind: IssueKind; - /** Line number of the offending record. */ - lineNo: number | null; - /** Short summary shown on a single line. */ - summary: string; - /** Optional second line / tooltip detail. */ - detail?: string; -} - -const SEVERITY_ORDER: Record = { - error: 0, - warning: 1, - info: 2, -}; - -/** Scan records and produce an ordered issue list, sorted by severity then line number. */ -export function computeIssues(entries: readonly WireEntry[]): Issue[] { - const out: Issue[] = []; - - // Track in-flight tool calls keyed by toolCallId, step begins by uuid, - // compaction begin lineNo, and plan mode enter id. - const toolCallById = new Map(); - const stepBeginByUuid = new Map(); - let lastCompactionBegin: { lineNo: number; source: string } | null = null; - let lastPlanEnter: { lineNo: number; id: string } | null = null; - - for (const entry of entries) { - const r = entry.data; - const lineNo = entry.lineNo; - switch (r.type) { - case 'context.append_loop_event': { - const ev = r.event; - if (ev.type === 'tool.call') { - // New in-flight tool call. - toolCallById.set(ev.toolCallId, { lineNo, name: ev.name }); - } else if (ev.type === 'tool.result') { - const open = toolCallById.get(ev.toolCallId); - if (open !== undefined) { - toolCallById.delete(ev.toolCallId); - } else { - out.push({ - severity: 'warning', - kind: 'missing_tool_result', - lineNo, - summary: `orphan tool.result for #${ev.toolCallId.slice(-8)}`, - detail: 'no preceding tool.call seen', - }); - } - } else if (ev.type === 'step.begin') { - stepBeginByUuid.set(ev.uuid, { - lineNo, - step: ev.step, - turnId: ev.turnId, - }); - } else if (ev.type === 'step.end') { - stepBeginByUuid.delete(ev.uuid); - } - break; - } - - case 'full_compaction.begin': - lastCompactionBegin = { lineNo, source: r.source }; - break; - case 'full_compaction.complete': - case 'full_compaction.cancel': - lastCompactionBegin = null; - break; - - case 'plan_mode.enter': - lastPlanEnter = { lineNo, id: r.id }; - break; - case 'plan_mode.cancel': - case 'plan_mode.exit': - lastPlanEnter = null; - break; - - case 'permission.record_approval_result': - if (r.result.decision === 'rejected') { - out.push({ - severity: 'info', - kind: 'rejected_approval', - lineNo, - summary: `${r.toolName}#${r.toolCallId.slice(-8)} rejected`, - detail: r.result.feedback, - }); - } - break; - - default: - break; - } - } - - // Drain unmatched in-flight entries. - for (const [id, info] of toolCallById) { - out.push({ - severity: 'warning', - kind: 'orphan_tool_call', - lineNo: info.lineNo, - summary: `${info.name}#${id.slice(-8)} has no tool.result`, - detail: 'tool.call recorded but no matching tool.result found', - }); - } - for (const [uuid, info] of stepBeginByUuid) { - out.push({ - severity: 'warning', - kind: 'incomplete_step', - lineNo: info.lineNo, - summary: `step ${info.step} (turn ${info.turnId}) has no step.end`, - detail: `uuid ${uuid.slice(-8)}`, - }); - } - if (lastCompactionBegin !== null) { - out.push({ - severity: 'warning', - kind: 'incomplete_compaction', - lineNo: lastCompactionBegin.lineNo, - summary: `${lastCompactionBegin.source} compaction never completed`, - }); - } - if (lastPlanEnter !== null) { - out.push({ - severity: 'info', - kind: 'active_plan_mode', - lineNo: lastPlanEnter.lineNo, - summary: `plan mode still active: ${lastPlanEnter.id}`, - }); - } - - out.sort((a, b) => { - const d = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]; - if (d !== 0) return d; - const sa = a.lineNo ?? Number.POSITIVE_INFINITY; - const sb = b.lineNo ?? Number.POSITIVE_INFINITY; - return sa - sb; - }); - - return out; -} - -/** Top-level summary tone used for the toolbar pill — "worst wins". */ -export function topSeverity(issues: readonly Issue[]): IssueSeverity | null { - if (issues.length === 0) return null; - for (const i of issues) if (i.severity === 'error') return 'error'; - for (const i of issues) if (i.severity === 'warning') return 'warning'; - return 'info'; -} diff --git a/apps/dashboard/web/src/main.tsx b/apps/dashboard/web/src/main.tsx deleted file mode 100644 index 3705512e..00000000 --- a/apps/dashboard/web/src/main.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { BrowserRouter } from 'react-router'; -import { App } from './App'; -import './theme.css'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: Infinity, - gcTime: 30 * 60 * 1000, - retry: false, - refetchOnWindowFocus: false, - }, - }, -}); - -const rootEl = document.querySelector('#root'); -if (!rootEl) throw new Error('#root not found'); - -createRoot(rootEl).render( - - - - - - - , -); diff --git a/apps/dashboard/web/src/pages/SessionDetailPage.tsx b/apps/dashboard/web/src/pages/SessionDetailPage.tsx deleted file mode 100644 index 4688457c..00000000 --- a/apps/dashboard/web/src/pages/SessionDetailPage.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useState } from 'react'; -import { useParams } from 'react-router'; - -import { api } from '../api'; -import { CopyButton } from '../components/shared/CopyButton'; -import { TabBar, useActiveTab } from '../components/layout/TabBar'; -import { ContextTab } from '../components/context/ContextTab'; -import { StateTab } from '../components/state/StateTab'; -import { SubagentsTab } from '../components/subagents/SubagentsTab'; -import { WireTab } from '../components/wire/WireTab'; -import { useSession } from '../hooks/useSession'; -import { formatAbsoluteTime, formatRelativeTime } from '../util/time'; - -type TabId = 'wire' | 'context' | 'agents' | 'state'; - -export function SessionDetailPage() { - const { sessionId } = useParams<{ sessionId: string }>(); - const active = useActiveTab('wire') as TabId; - const { data: session, isLoading, error } = useSession(sessionId); - - if (!sessionId) return
(no session id)
; - if (isLoading) { - return
loading session…
; - } - if (error) { - return ( -
- {error.message} -
- ); - } - if (!session) return null; - - const state = (session.state ?? null) as { - title?: string; - lastPrompt?: string; - updatedAt?: string; - } | null; - - const mainAgent = session.agents.find((a) => a.agentId === 'main') ?? null; - const subagentCount = session.agents.filter((a) => a.agentId !== 'main').length; - const wireRecords = mainAgent?.wireRecordCount ?? null; - - return ( -
- {/* Header */} -
-
- {session.sessionId} - - {state?.title ? ( - "{state.title}" - ) : null} - - - - -
-
- {state?.updatedAt ? ( - - updated {formatRelativeTime(Date.parse(state.updatedAt))} ·{' '} - {formatAbsoluteTime(Date.parse(state.updatedAt))} - - ) : null} - {session.workDir ? ( - - · {session.workDir} - - ) : null} -
-
- {session.sessionDir} -
- {state?.lastPrompt ? ( -
- prompt · {state.lastPrompt} -
- ) : null} -
- - - -
- {active === 'wire' ? : null} - {active === 'context' ? : null} - {active === 'agents' ? : null} - {active === 'state' ? : null} -
-
- ); -} - -function RevealButton({ sessionId }: { sessionId: string }) { - const [state, setState] = useState<'idle' | 'opening' | 'err'>('idle'); - const [errMsg, setErrMsg] = useState(null); - return ( - - ); -} diff --git a/apps/dashboard/web/src/types.ts b/apps/dashboard/web/src/types.ts deleted file mode 100644 index e87b898b..00000000 --- a/apps/dashboard/web/src/types.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Client-side types — re-export server DTOs (type-only cross-package import). -// The server's `agent-record-types.ts` is the single source of truth for -// all session / agent / wire shapes. - -export type { - SessionSummary, - SessionDetail, - AgentInfo, - AgentNode, - AgentTreeResponse, - SessionHealth, - WireResponse, - WireEntry, - ApiError, - AgentRecord, - AgentRecordOf, - ContextMessage, - PromptOrigin, - TokenUsage, - PermissionMode, - LoopRecordedEvent, - ContentPart, - Message, - ToolCall, -} from '../../server/src/lib/agent-record-types'; - -export type { - ProjectedMessage, - UsageTotals, - ConfigSnapshot, - ContextProjection, - GoalSnapshot, -} from '../../server/src/lib/context-projector'; - -export interface DeleteSessionResponse { - sessionId: string; - deleted: true; -} - -/** - * Shape returned by `GET /api/sessions/:id/context?agent=`. - * - * Mirrors `ContextProjection` from context-projector, plus the `sessionId` - * and `agentId` echoed by the route. - */ -export interface ContextResponse { - sessionId: string; - agentId: string; - messages: import('../../server/src/lib/context-projector').ProjectedMessage[]; - usage: import('../../server/src/lib/context-projector').UsageTotals; - contextTokens: number; - config: import('../../server/src/lib/context-projector').ConfigSnapshot; - permission: { mode: import('../../server/src/lib/agent-record-types').PermissionMode | null }; - planMode: { active: boolean; id?: string }; - goal: import('../../server/src/lib/context-projector').GoalSnapshot | null; - dynamicWorkflow: { active: boolean; trigger?: string }; -} diff --git a/apps/dashboard/web/src/util/time.ts b/apps/dashboard/web/src/util/time.ts deleted file mode 100644 index 27246cbd..00000000 --- a/apps/dashboard/web/src/util/time.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Format an epoch-ms timestamp as a short relative string ("2m ago", "3h ago"). */ -export function formatRelativeTime(epochMs: number): string { - if (!epochMs || !Number.isFinite(epochMs)) return '—'; - const diff = Date.now() - epochMs; - if (diff < 0) return 'just now'; - const s = Math.floor(diff / 1000); - if (s < 60) return `${s}s ago`; - const m = Math.floor(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; - const d = Math.floor(h / 24); - if (d < 30) return `${d}d ago`; - const mo = Math.floor(d / 30); - if (mo < 12) return `${mo}mo ago`; - return `${Math.floor(mo / 12)}y ago`; -} - -/** Format an epoch-ms timestamp as ISO-ish local time (YYYY-MM-DD HH:MM:SS). */ -export function formatAbsoluteTime(epochMs: number): string { - if (!epochMs || !Number.isFinite(epochMs)) return '—'; - const d = new Date(epochMs); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - -/** Format an epoch-ms timestamp as HH:MM:SS (wall clock). */ -export function formatWallClock(epochMs: number): string { - if (!epochMs || !Number.isFinite(epochMs)) return '--:--:--'; - const d = new Date(epochMs); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} diff --git a/apps/dashboard/web/test/api.test.ts b/apps/dashboard/web/test/api.test.ts deleted file mode 100644 index 24485a67..00000000 --- a/apps/dashboard/web/test/api.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { api } from '../src/api'; - -describe('dashboard web api auth token handling', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('scrubs token parameters from the browser URL after persisting the token', async () => { - const setItem = vi.fn(); - const getItem = vi.fn(); - const replaceState = vi.fn(); - const location = new URL('http://localhost:3001/?foo=bar&token=secret#token=secret&tab=wire'); - - vi.stubGlobal('window', { - history: { replaceState }, - localStorage: { getItem, setItem }, - location, - }); - const fetchMock = vi.fn( - async () => - new Response('[]', { - headers: { 'content-type': 'application/json' }, - status: 200, - }), - ); - vi.stubGlobal('fetch', fetchMock); - - await api.listSessions(); - - expect(setItem).toHaveBeenCalledWith('pythinker-dashboard-auth-token', 'secret'); - expect(fetchMock).toHaveBeenCalledWith('/api/sessions', { - headers: { accept: 'application/json', authorization: 'Bearer secret' }, - method: 'GET', - }); - expect(replaceState).toHaveBeenCalledWith(null, '', 'http://localhost:3001/?foo=bar#tab=wire'); - }); - - it('retains the HTTP status and structured code for incompatibility responses', async () => { - vi.stubGlobal('window', { - history: { replaceState: vi.fn() }, - localStorage: { getItem: vi.fn(() => null), setItem: vi.fn() }, - location: new URL('http://localhost:3001/'), - }); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response( - JSON.stringify({ - error: 'session state is incompatible', - code: 'INCOMPATIBLE_SESSION_STATE', - }), - { headers: { 'content-type': 'application/json' }, status: 409 }, - ), - ), - ); - - await expect(api.getSession('session_fixture')).rejects.toMatchObject({ - message: 'session state is incompatible', - status: 409, - code: 'INCOMPATIBLE_SESSION_STATE', - }); - }); -}); diff --git a/apps/dashboard/web/vite.config.ts b/apps/dashboard/web/vite.config.ts deleted file mode 100644 index a00d5be3..00000000 --- a/apps/dashboard/web/vite.config.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import { viteSingleFile } from 'vite-plugin-singlefile'; - -const apiPort = Number(process.env.PORT) || 5174; -const webPort = Number(process.env.WEB_PORT) || 5173; - -// When set, build a single self-contained index.html (JS+CSS inlined) into -// `dist-single/` so it can be embedded into the pythinker CLI. The normal `dist/` -// build is unaffected. -const singlefile = process.env.DASHBOARD_SINGLEFILE === '1'; - -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), - ...(singlefile - ? [ - viteSingleFile({ - useRecommendedBuildConfig: true, - deleteInlinedFiles: true, - }), - ] - : []), - ], - server: { - port: webPort, - strictPort: false, - proxy: { - '/api': { - target: `http://localhost:${apiPort}`, - changeOrigin: true, - }, - }, - }, - build: { - outDir: singlefile ? 'dist-single' : 'dist', - emptyOutDir: true, - target: 'es2022', - }, -}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3439123..422681b2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -222,7 +222,8 @@ async function createMainWindow(): Promise { return { action: 'deny' } }) const rendererUrl = new URL(origin) - rendererUrl.searchParams.set('pythinker-desktop-platform', process.platform) + rendererUrl.searchParams.set('pythinker_desktop', '1') + rendererUrl.searchParams.set('platform', process.platform) await window.loadURL(rendererUrl.href) if (!lifecycle?.isQuitting) window.show() return window diff --git a/apps/desktop/tests/window-appearance.spec.ts b/apps/desktop/tests/window-appearance.spec.ts index 9bf85237..e5475373 100644 --- a/apps/desktop/tests/window-appearance.spec.ts +++ b/apps/desktop/tests/window-appearance.spec.ts @@ -1,5 +1,7 @@ // Static check: no Windows host exists in CI or locally, so this test guards the // window configuration rather than the rendered result. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { windowAppearanceOptions } from '../src/window-options' @@ -43,4 +45,12 @@ describe('desktop window appearance configuration', () => { backgroundColor: '#161616', }) }) + + it('marks the renderer URL as desktop and includes the platform', () => { + const main = readFileSync(join(import.meta.dirname, '../src/main.ts'), 'utf8') + + expect(main).toContain("rendererUrl.searchParams.set('pythinker_desktop', '1')") + expect(main).toContain("rendererUrl.searchParams.set('platform', process.platform)") + expect(main).not.toContain("rendererUrl.searchParams.set('pythinker-desktop-platform'") + }) }) diff --git a/apps/pythinker-code/.gitignore b/apps/pythinker-code/.gitignore index 298cd522..c4989c5b 100644 --- a/apps/pythinker-code/.gitignore +++ b/apps/pythinker-code/.gitignore @@ -6,3 +6,6 @@ agents/ # next to it keeps `#/generated/vis-web-asset` type-resolvable on a fresh # clone (before any build has produced the `.ts`). src/generated/vis-web-asset.ts + +# Copied from packages/pi-tui/native at build time by scripts/copy-native-assets.mjs +/native/ diff --git a/apps/pythinker-code/AGENTS.md b/apps/pythinker-code/AGENTS.md index ba9cfe3e..11512635 100644 --- a/apps/pythinker-code/AGENTS.md +++ b/apps/pythinker-code/AGENTS.md @@ -17,18 +17,18 @@ Main directories: - `src/tui/`: the interactive terminal UI. - `src/tui/pythinker-tui.ts`: the `PythinkerTUI` coordinator — wires state, layout, editor, session, SDK events, and dialogs together, and dispatches slash-command handlers. Heavy logic is delegated to `controllers/`, not accumulated here. - `src/tui/tui-state.ts`: `TUIState`, `createTUIState`, `createInitialAppState` — the single global UI-state shape. -- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`, `mouse-controller` (SGR mouse: wheel scroll + select-to-copy in the fixed layout). +- `src/tui/controllers/`: independently-testable responsibilities — `session-event-handler` (SDK event routing), `streaming-ui` (streaming render), `session-replay` (resume/replay), `tasks-browser`, `editor-keyboard`, `auth-flow`. - `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation. - `src/tui/components/`: pi-tui components, organized by UI type. - `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on. -- `src/tui/components/chrome/`: persistent UI chrome — footer, todo panel, welcome, loader, device code, plus the fixed-layout root (`viewport-layout.ts`) and transcript scroll window (`transcript-viewport.ts`) used when `tui.toml` `layout = "fixed"`. +- `src/tui/components/chrome/`: persistent UI chrome — footer, todo panel, welcome, loader, device code. - `src/tui/components/dialogs/`: selectors, approval panels, question popups, and settings popups that temporarily replace the editor. - `src/tui/components/editor/`: the custom input box and the file mention provider. - `src/tui/components/media/`: image, diff, code highlight, and other media displays. - `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on. - `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane. - `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI. -- `src/tui/theme/`: themes, color tokens, terminal-background detection, and the Pythinker markdown/editor theme (`pythinker-theme.ts`). +- `src/tui/theme/`: themes, color tokens, style helpers, terminal-background detection, and the pi-tui markdown theme. - `src/tui/utils/`: TUI-only utility functions. - `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on. @@ -65,6 +65,7 @@ The theme apply/switch mechanics live in the `write-tui` skill. The following ru ## General Coding Requirements +- The startup path before the workspace trust gate (`PythinkerTUI.start()` -> `maybeRunWorkspaceTrustPrompt()`) must not spawn child processes by bare command name — on Windows, cmd.exe / CreateProcess resolve them from the current directory first, so a binary planted in an untrusted workspace would run before the user confirms trust. When an external command is unavoidable, resolve it with `resolveCommandPath` from `src/utils/process/resolve-command.ts`, which returns an absolute PATH hit and refuses matches inside the cwd. - For optional object properties, pass `undefined` directly — do not use conditional spread. - Optional object properties do not need to additionally allow `undefined` in the type. - Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. diff --git a/apps/pythinker-code/CHANGELOG.md b/apps/pythinker-code/CHANGELOG.md index 29cacc1a..fc6333d5 100644 --- a/apps/pythinker-code/CHANGELOG.md +++ b/apps/pythinker-code/CHANGELOG.md @@ -1,709 +1,1545 @@ # @pymodel/pythinker-code -## 0.21.2 +## 0.36.1 ### Patch Changes -- [#126](https://github.com/PyModel/pythinker-code/pull/126) [`a447a2c`](https://github.com/PyModel/pythinker-code/commit/a447a2c47451e7f2b65e49e94304f7f7bbd44096) - Fix desktop update prompts so one action downloads, closes, installs, and restarts the app. +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The timestamp under assistant replies now shows the message time instead of the work duration. -## 0.21.1 +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restyle the slash command and @ file mention menus: matched fragments are bold-highlighted in the slash menu, and long lists in both menus get a scroll fade and a draggable floating scrollbar. -### Patch Changes +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The background Bash panel now supports filtering by status, and clicking a task shows its command and output on the right. -- [#123](https://github.com/PyModel/pythinker-code/pull/123) [`46a9cd1`](https://github.com/PyModel/pythinker-code/commit/46a9cd1a77e638e192303161f7975a28b41cc334) - Sign and notarize the macOS desktop build in the release pipeline, and fail a tagged release outright when the signing credentials are missing instead of quietly shipping an unsigned app. +- [#2865](https://github.com/PyModel/pythinker-code/pull/2865) [`53909d9`](https://github.com/PyModel/pythinker-code/commit/53909d91e3ca570d4b565ba1abd00f027ca78d6b) Thanks [@weivwang](https://github.com/weivwang)! - Cache content-hashed Pythinker Web assets across reloads while keeping the app entry point revalidated. -## 0.21.0 +- [#2916](https://github.com/PyModel/pythinker-code/pull/2916) [`7475c2e`](https://github.com/PyModel/pythinker-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797) Thanks [@Grapedge](https://github.com/Grapedge)! - Cancel an in-flight /init run together with the turn instead of letting it run to completion. + +- [#2911](https://github.com/PyModel/pythinker-code/pull/2911) [`249d8fa`](https://github.com/PyModel/pythinker-code/commit/249d8faa3447427665185a900926d048213d2ac7) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix sessions hanging on the second approval prompt and tool call results being dropped or mixed up in history when using a self-hosted OpenAI-compatible endpoint that renumbers tool call ids on every response. + +- [#2917](https://github.com/PyModel/pythinker-code/pull/2917) [`6cf315b`](https://github.com/PyModel/pythinker-code/commit/6cf315b7bdea8a04cfaeba1bb8931c1730853aec) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix bare URLs in chat output absorbing the CJK characters that follow them, which made the link unclickable or open a broken address. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the slash command panel staying open after switching sessions or when the composer loses focus. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Replace the composer mode menu with mutually exclusive plan/goal pills on the left of the input area (arm via /plan or /goal, exit with ×); DynamicWorkflow becomes a separate toolbar toggle. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restyle the work status pills above the composer with a borderless rounded look. + +- [#2910](https://github.com/PyModel/pythinker-code/pull/2910) [`eb72aeb`](https://github.com/PyModel/pythinker-code/commit/eb72aebeeb972b2fcc238d5650dd991a5580f96b) Thanks [@sailist](https://github.com/sailist)! - Remove the 64 MiB limit on web session exports, so large sessions no longer fail with a file-too-large error when downloaded from the web UI. + +- [#2884](https://github.com/PyModel/pythinker-code/pull/2884) [`1811bd4`](https://github.com/PyModel/pythinker-code/commit/1811bd4baf5b75ba076e2a24825f9c4f82c13341) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix startup banner text wrapping on narrow terminals. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `$` content inside inline code spans being misrendered as inline math. + +- [#2899](https://github.com/PyModel/pythinker-code/pull/2899) [`102984a`](https://github.com/PyModel/pythinker-code/commit/102984aa660d752ba8dd7d1aba155575f32affe2) Thanks [@oocz](https://github.com/oocz)! - Fix MCP OAuth cancellation leaving an in-flight authorization waiting for its callback timeout. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the thinking-effort flyout being unreachable when selecting the last model in the subagent model list. + +- [#2876](https://github.com/PyModel/pythinker-code/pull/2876) [`5912d4c`](https://github.com/PyModel/pythinker-code/commit/5912d4c7d19d68975e85b007976b1bef59edae5c) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix repeated file-watcher errors on Windows when the workspace is a drive root (such as `E:\`) or a UNC network share. + +- [#2916](https://github.com/PyModel/pythinker-code/pull/2916) [`7475c2e`](https://github.com/PyModel/pythinker-code/commit/7475c2e2e3dd86ac0b8a8d51d4f1d233ed7df797) Thanks [@Grapedge](https://github.com/Grapedge)! - Show a clear error when forking a session while its turn is running, instead of copying a partially written turn. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix forking sessions with very long histories always failing with a timeout. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Selecting /goal from the slash menu now immediately arms a removable goal pill in the composer; typing and sending creates the goal without requiring the goal text after the command. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restyle the goal panel: the goal text and elapsed time move to the header, and actions become icon buttons. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix CJK text immediately after a bare URL being swallowed into the link, which made the link unopenable. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Adjust when plan mode takes effect: enabling it now arms a removable plan pill in the composer and only activates when the message is sent, matching goal mode behavior. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add a plan viewer panel: click a plan entry in the work bar to see the full plan, review results, and feedback. + +- [#2863](https://github.com/PyModel/pythinker-code/pull/2863) [`245e3d5`](https://github.com/PyModel/pythinker-code/commit/245e3d56a6de45e74d55449ef26cd65304a3250a) Thanks [@LouisDM](https://github.com/LouisDM)! - Prevent background task output from disrupting terminal pane borders. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show a clear top-center confirmation toast after exporting a session, and a clearer error message when the export fails because the session is too large. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the session list PR badge not refreshing after a PR is created from within a session. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restyle the session list PR badge as a small tag with a background. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Unify session status display in the sidebar and stabilize session list ordering. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Slash commands now support fuzzy search: find commands by description text, pinyin, or pinyin initials. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Rework the subagent panel into a card grid layout with status filtering, showing in-progress and recently finished tasks by default. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix server request timeouts being misreported as "cannot connect to the Pythinker server". + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restyle the todo panel as frosted cards and add a current-progress completion count. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Increase the font size and row height of the user menu and the plan usage flyout. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Rename the user menu's "Upgrade" entry to "Upgrade membership" and label the plan usage percentage as used. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add experimental automatic session title generation, with on-demand regeneration from the session list. + +- [#2922](https://github.com/PyModel/pythinker-code/pull/2922) [`cd48995`](https://github.com/PyModel/pythinker-code/commit/cd489955bb42c4a8055de9545d0b699b93bba98a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add a "sort by recent activity" option to the workspace-grouped sidebar view (switched in the view options menu); newly added workspaces now sort to the top. + +## 0.36.0 ### Minor Changes -- [#100](https://github.com/PyModel/pythinker-code/pull/100) [`cba1341`](https://github.com/PyModel/pythinker-code/commit/cba13413dd3606f218cc6f1dca59a75641371c85) - Add server endpoints that list installed plugins, enable or disable one, and list subagent profiles, and let a named skill be turned off so it is hidden from the model, the slash menu and the API. +- [#2830](https://github.com/PyModel/pythinker-code/pull/2830) [`ec84a6f`](https://github.com/PyModel/pythinker-code/commit/ec84a6f9a3eb35e1118f8a327f7a11b3978a899c) Thanks [@liruifengv](https://github.com/liruifengv)! - Add an experimental fullscreen TUI mode. Set the `PYTHINKER_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. + +- [#2700](https://github.com/PyModel/pythinker-code/pull/2700) [`c9bfe8b`](https://github.com/PyModel/pythinker-code/commit/c9bfe8b2c8314ba4ef8806fb3b92ac654c1d1860) Thanks [@7Sageer](https://github.com/7Sageer)! - Add a configurable model pool for spawned subagents behind the `secondary-model` experiment (`PYTHINKER_CODE_EXPERIMENTAL_SECONDARY_MODEL=1`, or the master flag): with the experiment on, the `/secondary-model` command or the `[secondary_model]` section in config.toml sets a default model or a small named pool that the main agent picks from per spawn. A lone legacy `model` key in the same section keeps working as the fallback default. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Report each model's real capabilities in the catalog. Until now `capabilities` carried only what a user had typed into their config file by hand, so for almost every model it was empty. It is now derived from the model itself when the config says nothing, while an explicit list in the config still wins. A provider whose capabilities are genuinely unknown keeps omitting the field rather than claiming the model can do nothing. +### Patch Changes + +- [#2830](https://github.com/PyModel/pythinker-code/pull/2830) [`ec84a6f`](https://github.com/PyModel/pythinker-code/commit/ec84a6f9a3eb35e1118f8a327f7a11b3978a899c) Thanks [@liruifengv](https://github.com/liruifengv)! - Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Prompt for desktop updates with a toast that offers install or skip, centre the settings button in the sidebar footer, and start a new session when the sidebar brand is clicked. +- [#2855](https://github.com/PyModel/pythinker-code/pull/2855) [`30f56a2`](https://github.com/PyModel/pythinker-code/commit/30f56a2d2da332cbf0c36a13cbe01aac5d319c7b) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix Ctrl+C being ignored during automatic retries of failed API requests. -- [#100](https://github.com/PyModel/pythinker-code/pull/100) [`cba1341`](https://github.com/PyModel/pythinker-code/commit/cba13413dd3606f218cc6f1dca59a75641371c85) - Open the web settings inside the app shell instead of over it, and add pages for plugins, skills, subagents, connectors, hooks and usage statistics. +- [#2819](https://github.com/PyModel/pythinker-code/pull/2819) [`fe3cdae`](https://github.com/PyModel/pythinker-code/commit/fe3cdae5f8ab40be71b65eff32319eb94a53c17d) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix sessions failing with a provider 400 error on every follow-up request after a turn is interrupted while the model is still thinking, on strict OpenAI-compatible providers. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Make `agent_config.tools` and `agent_config.mcp_servers` reach the running agent. A session profile update now persists the selection, merges each field independently so supplying one half does not clear the other, resumes an inactive session before the mutation, and applies the result through a single `setActiveTools` call. MCP server names are turned into tool patterns with the shared naming helper, so a server whose name needs sanitizing still matches its tools. +- [#2847](https://github.com/PyModel/pythinker-code/pull/2847) [`3b0936d`](https://github.com/PyModel/pythinker-code/commit/3b0936d8e025c5a944759c40593d5f21bfb3e621) Thanks [@sailist](https://github.com/sailist)! - Fix plain Markdown files (such as CHANGELOG.md) in an installed plugin's root directory being misidentified as skills when the plugin relies on the root SKILL.md fallback. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add a capability menu to the composer. It picks which tools and MCP servers the current session may use, lists the session's skills, and turns plugins on or off. Each group states how far its change reaches, because the three differ: tool and MCP changes apply to this session at once, skills are read-only here, and plugin changes are global to the daemon. Selected tools and servers appear as chips beside the composer controls. +- [#2843](https://github.com/PyModel/pythinker-code/pull/2843) [`c212ae9`](https://github.com/PyModel/pythinker-code/commit/c212ae9715371c0d7939c15e664acbe0d7cf7fc3) Thanks [@sailist](https://github.com/sailist)! - Show project MCP launch targets in the workspace trust prompt, default to declining trust, and resolve fd and stty binaries to absolute paths so untrusted workspaces cannot plant bare-name executables before confirmation. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add OpenAI Codex sign-in to the web and desktop app. The provider dialog now offers "Sign in with ChatGPT" next to the API-key form: the server runs the OAuth exchange, writes the credentials, and reports only which model it selected. When port 1455 is taken, the dialog asks for the redirect URL instead. + `@pymodel/pythinker-code-sdk` contract change: `WorkspaceTrustInfo.gatedMcpServers` now carries structured `WorkspaceTrustMcpServerInfo` records (`name`, `transport`, and `command`/`args`/`cwd` or `url`) instead of plain strings, so SDK consumers rendering a trust prompt can show the full launch target. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add starter suggestions to the empty conversation screen in the web UI. Each suggestion fills the composer for editing and does not send the message. +- [#2856](https://github.com/PyModel/pythinker-code/pull/2856) [`504e629`](https://github.com/PyModel/pythinker-code/commit/504e6292ede448367d1341751f9f98b24cc2994f) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Refresh active MCP connections after OAuth credentials are added or reset. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add, edit, and remove your own MCP servers from the connectors page in the web UI; a new or edited server starts with your next session. +## 0.35.0 -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add a Retry action to the last assistant reply and a copy button to user messages in the web UI. Retry asks for confirmation, then sends the original prompt again. +### Minor Changes -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Point the web provider calls at routes that exist. Adding a provider now writes through `POST /config`, refreshing reads `GET /providers/{id}`, and a new `DELETE /providers/{provider_id}` route removes a provider together with the model aliases that referenced it. +- [#2816](https://github.com/PyModel/pythinker-code/pull/2816) [`ad12ad8`](https://github.com/PyModel/pythinker-code/commit/ad12ad8a140d24051d93ec98a4a6921ab33723ff) Thanks [@liruifengv](https://github.com/liruifengv)! - Show the live work progress of background subagents in the `/tasks` panel. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Move the web tool picker out of the composer menu into a Tools page in settings, where the full list fits. Every tool stays on until you turn one off, and the selection still applies to the current session only. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Image and video tool results now open in a fullscreen preview on click, with zoom support for images. ### Patch Changes -- [#121](https://github.com/PyModel/pythinker-code/pull/121) [`cb2ecdc`](https://github.com/PyModel/pythinker-code/commit/cb2ecdcdadb874ea30156db237461ca8f79c076e) - Say why the desktop app cannot start when another Pythinker server is already running. It now names the process, port and start time and offers Retry or Quit, in place of an exit code that explained nothing. Stopping the other server stays the user's choice. +- [#2731](https://github.com/PyModel/pythinker-code/pull/2731) [`437a1b8`](https://github.com/PyModel/pythinker-code/commit/437a1b8ba1b7e0f6662bdadc669564fdc58c3f5a) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Detect MCP servers that require OAuth without needing `auth: "oauth"` in the config. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Repaint the desktop chrome. The sidebar footer now carries a pill button, so Settings and the way back out of it match New Session and stay visible. The transcript reserves room for the floating work chips instead of letting them sit on the last line. Windows gets round window controls on the trailing edge, in place of the native caption buttons that could not be styled. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce UI stutter while AI responses stream in long sessions. -- [#121](https://github.com/PyModel/pythinker-code/pull/121) [`cb2ecdc`](https://github.com/PyModel/pythinker-code/commit/cb2ecdcdadb874ea30156db237461ca8f79c076e) - Sign, notarize and staple the macOS disk image, so a downloaded desktop build no longer opens with a Gatekeeper warning, and keep the update metadata in step with the finished file. The install window also gets a deliberate icon layout in place of the stock one. +- [#2699](https://github.com/PyModel/pythinker-code/pull/2699) [`c0b61c6`](https://github.com/PyModel/pythinker-code/commit/c0b61c6e558521fd003de786cad150a3aeb01667) Thanks [@sailist](https://github.com/sailist)! - Fix the token counts reported after compaction reading far below the real context size; they now match the numbers shown while the session runs. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Keep provider and model ids exactly as you type them, open the ChatGPT sign-in window reliably, stop a second sign-in attempt from holding the callback port, hide provider errors behind a safe message, and finish writing the event journals during shutdown. +- [#2810](https://github.com/PyModel/pythinker-code/pull/2810) [`64abebc`](https://github.com/PyModel/pythinker-code/commit/64abebc95a13b066fefc4f96b062824ea5ec996b) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Fix multi-select "Other" options so they can be deselected after being committed. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Keep provider and model ids exactly as written when a config patch is saved, so an id containing an underscore still resolves. +- [#2701](https://github.com/PyModel/pythinker-code/pull/2701) [`7cd6476`](https://github.com/PyModel/pythinker-code/commit/7cd64766c8eeff30f3de4bd6467870555d9440db) Thanks [@sailist](https://github.com/sailist)! - Fix multi-second freezes at startup or while idle when a large search index loads, replays, or rebuilds. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Stop the session snapshot request from timing out on busy sessions. Each recorded event no longer pays a fresh file open and close, the watermark is read without waiting for pending writes, and the session list is scanned in parallel, so opening or refreshing a session stays fast even with a long history. This was most visible on Windows, where the per-event file cost is highest. +- [#2814](https://github.com/PyModel/pythinker-code/pull/2814) [`158c81d`](https://github.com/PyModel/pythinker-code/commit/158c81d7055587d582ca424f9b913426fca42559) Thanks [@huangzheng2016](https://github.com/huangzheng2016)! - Show a clear error message on Windows when Git for Windows is not installed, instead of exiting silently. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Show the sign-in state, the provider, and the model as separate fields in the web settings account section, and label the button for what it opens. +- [#2838](https://github.com/PyModel/pythinker-code/pull/2838) [`e5be391`](https://github.com/PyModel/pythinker-code/commit/e5be39164b1b47d0b721aad49c41fdf4ec61a7c5) Thanks [@sailist](https://github.com/sailist)! - Close a Windows binary-planting gap in the footer git status: the git and gh commands used for the branch/dirty badge are now resolved to an absolute PATH location, so an executable planted in an untrusted workspace can no longer run before the workspace trust prompt. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Fix web capability and retry controls: the capability panel takes keyboard focus when it opens, Retry stays reachable with Tab, rapid capability toggles reach the daemon in order, and retrying a prompt keeps its attachments. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add hover tooltips to icon-only buttons. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Widen the chat reading column to 928px and restyle the composer card: a 24px radius, a translucent blurred surface, a border that strengthens on hover and focus, and an input that grows to 384px before it scrolls. The toolbar controls are 30px circles with a divider after the attachment button. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add breathing room around the fullscreen image preview so images no longer touch the screen edges. -- [#122](https://github.com/PyModel/pythinker-code/pull/122) [`5f51b83`](https://github.com/PyModel/pythinker-code/commit/5f51b83531fdc3a39ae1e5c9d9adba2cb24eb658) - Lay the MCP server form out in even rows instead of a ragged grid, and stop the provider manager header and footer from squaring off the dialog corners. +- [#2740](https://github.com/PyModel/pythinker-code/pull/2740) [`01c74e9`](https://github.com/PyModel/pythinker-code/commit/01c74e9372fcbbbe99614e859b53b505ed1664a8) Thanks [@oocz](https://github.com/oocz)! - Fix subagent tool changes in one session leaking into builtin profiles in later sessions. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Clean up the web composer capability control: the selected tools no longer render as chips in the toolbar, the button reads "Connectors", and the menu panel stays inside the window when its content loads. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Unify the fullscreen image and video previews with a shared circular close button and the same background overlay. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Show model capabilities as badges in the model picker instead of a comma-separated string, and bring its rows and search field onto the app's row metrics, sized from `--ui-font-size` so the font-size setting still scales them. A model that reasons adaptively is now distinguishable from one that exposes an explicit thinking capability, and an unrecognised capability still renders rather than being dropped. +- [#2826](https://github.com/PyModel/pythinker-code/pull/2826) [`3c9e3b2`](https://github.com/PyModel/pythinker-code/commit/3c9e3b297cf5286c761159c1b4d642c478fd394d) Thanks [@liruifengv](https://github.com/liruifengv)! - Page the /sessions picker list so it opens fast with large session counts. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Make the web settings surface use the app design tokens: token corner radii, a theme-aware switch shadow, and control sizes that grow with the UI font size. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Widen the sidebar's minimum draggable width. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Quieten the sidebar session rows. Hover becomes a translucent wash instead of a solid fill, the selected row becomes a faint tint instead of a solid accent, and the radius and sizing match the shared menu row, so the row scales with the UI font-size setting. The same change is applied to the per-theme overrides, so all three themes agree. +- [#2723](https://github.com/PyModel/pythinker-code/pull/2723) [`e702817`](https://github.com/PyModel/pythinker-code/commit/e7028171244789aff58f93da80d477ce3afc939a) Thanks [@sailist](https://github.com/sailist)! - Fix a spurious "Failed to steer" error when sending a message while a goal run is between turns. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Recover the web and desktop app when a session snapshot request fails. It is now retried with a growing delay instead of leaving the todo list and the sub-agent list frozen until a reload, and a failed task refresh reports itself rather than failing in silence. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce memory and CPU usage when the app stays open for a long time. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Add four shared UI primitives to the web app: `Popover`, `MenuRow`, `SwitchToggle` and `Chip`. `Popover` holds the anchored-menu positioning that each menu used to write for itself, including the flip above the trigger and the viewport clamp. `MenuRow` carries the standard list row, sized from `--ui-font-size` so the font-size setting still scales it. All four style themselves only from theme tokens, and a guard test fails on any colour literal. +- [#2825](https://github.com/PyModel/pythinker-code/pull/2825) [`df8ce73`](https://github.com/PyModel/pythinker-code/commit/df8ce73e45e3c473cb58e69311c1213e327f0c01) Thanks [@liruifengv](https://github.com/liruifengv)! - Show retry progress in the loading indicator when a model request fails and is retried, with the attempt count and a detail line for the provider error. -- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`0871533`](https://github.com/PyModel/pythinker-code/commit/08715330b820800414345991f9dc616aa15ca824) - Open the browser on Windows through `rundll32` instead of `cmd /c start`. `cmd` cut every URL at the first `&`, so OAuth logins reached the provider with only the first query parameter and failed with an invalid authorize request. +- [#2840](https://github.com/PyModel/pythinker-code/pull/2840) [`68ce3c7`](https://github.com/PyModel/pythinker-code/commit/68ce3c7a0ccffe32b51d4fdb57cdeced3931ddcc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Reduce memory usage and stutter during long sessions. -## 0.20.0 +- [#2837](https://github.com/PyModel/pythinker-code/pull/2837) [`101c4d1`](https://github.com/PyModel/pythinker-code/commit/101c4d199746bf2ed4f26375b65a6fcb6cba2a60) Thanks [@sailist](https://github.com/sailist)! - Remove the Agent and AgentDynamicWorkflow tools from the built-in coder subagent profile, so coder subagents no longer delegate further by default. Custom profiles that list these tools explicitly can still opt in. + +- [#2695](https://github.com/PyModel/pythinker-code/pull/2695) [`71ff2a0`](https://github.com/PyModel/pythinker-code/commit/71ff2a0fffb2ebf399194436ef2d4b599c9988ad) Thanks [@sailist](https://github.com/sailist)! - Fix a Windows security risk where commands launched before the workspace trust prompt could run a malicious executable placed in the current folder. + +- [#2813](https://github.com/PyModel/pythinker-code/pull/2813) [`619564d`](https://github.com/PyModel/pythinker-code/commit/619564dcf9ee10a3cfbf7ecbc764c6b9b63fc91b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix the web UI repeatedly losing its realtime connection every ~30 seconds when the server runs behind a reverse proxy or gateway with an idle connection timeout; the server now sends a WebSocket heartbeat and only closes connections that stop responding entirely. + +- [#2842](https://github.com/PyModel/pythinker-code/pull/2842) [`e476c5a`](https://github.com/PyModel/pythinker-code/commit/e476c5a8bbe68fb0b6eb0096aa1efcb893b1a8fc) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Modern Web Guidance plugin to the bundled plugin marketplace. Run /plugins and select Modern Web Guidance to install it. + +- Thanks [@Leakless](https://github.com/Leakless) and [@winmin](https://github.com/winmin) for reporting the Windows binary-planting issues fixed in this release. + +## 0.34.0 ### Minor Changes -- [#97](https://github.com/PyModel/pythinker-code/pull/97) [`7dd68cb`](https://github.com/PyModel/pythinker-code/commit/7dd68cba572576616dfca1730e79c5e650006508) - Remove the legacy pythinker-cli migration: the `pythinker migrate` subcommand, the first-launch - migration prompt, and the `[imported]` session badge. +- [#2646](https://github.com/PyModel/pythinker-code/pull/2646) [`3c75a27`](https://github.com/PyModel/pythinker-code/commit/3c75a27da66e522ae670ec8ce9093ea71d091d27) Thanks [@liruifengv](https://github.com/liruifengv)! - Show a cache-expiry reminder when resuming a long-idle session or submitting after a long idle stretch. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: When a model request fails and interrupts a conversation, a persistent failure card now stays in the session with one-click resume. + +- [#2652](https://github.com/PyModel/pythinker-code/pull/2652) [`68ba740`](https://github.com/PyModel/pythinker-code/commit/68ba740ebfb3e32ad9abdb8607f48d4387cf6f69) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add Windows support for the built-in Pythinker Computer Use capability and show the underlying error when capability setup fails. Install it from `/plugins` on Windows x64. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add a flat view to the sidebar session list. ### Patch Changes -- [#97](https://github.com/PyModel/pythinker-code/pull/97) [`7dd68cb`](https://github.com/PyModel/pythinker-code/commit/7dd68cba572576616dfca1730e79c5e650006508) - Make the workspace header, session timestamps, and the settings row legible in dark mode on the translucent desktop sidebar. +- [#2648](https://github.com/PyModel/pythinker-code/pull/2648) [`d1ded01`](https://github.com/PyModel/pythinker-code/commit/d1ded01b7c50c9847440f4645fe13f588becdc66) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Restore how the last turn ended (completed, cancelled, or failed) when a session is resumed after a server restart, so clients can still surface a previously failed turn instead of the session looking silently stopped. -- [#96](https://github.com/PyModel/pythinker-code/pull/96) [`71c51b8`](https://github.com/PyModel/pythinker-code/commit/71c51b87879afe13309683c69d5ab8eb7669d72d) - Keep the web model quick-switch menu inside the viewport when the composer sits near the top of the window. +- [#2666](https://github.com/PyModel/pythinker-code/pull/2666) [`335588e`](https://github.com/PyModel/pythinker-code/commit/335588e2594a61a767ce258b34b4049a32b18fe5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Session listings keep how the last turn ended (completed, cancelled, or failed) across server restarts, so clients can mark previously failed sessions before they are opened. -## 0.19.1 +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the model picker overflowing the screen when many models are available, leaving the bottom options unreachable. -### Patch Changes +- [#2639](https://github.com/PyModel/pythinker-code/pull/2639) [`8588121`](https://github.com/PyModel/pythinker-code/commit/858812193a267fcb9382e351137f892646ef79aa) Thanks [@7Sageer](https://github.com/7Sageer)! - /feedback now works for any signed-in user regardless of the active model; signed-out users are shown the sign-up page and GitHub Issues links instead. -- [#85](https://github.com/PyModel/pythinker-code/pull/85) [`fa82753`](https://github.com/PyModel/pythinker-code/commit/fa82753e675eb95d4b0028755eb2626afb0a6e8f) - Questions no longer expire after 60 seconds, expired questions are not reported as user dismissals, answers retain question text and option labels, and Escape no longer dismisses a question. +- [#2675](https://github.com/PyModel/pythinker-code/pull/2675) [`34c4181`](https://github.com/PyModel/pythinker-code/commit/34c418143759a9e80cdda97e95e609c5a993916d) Thanks [@sailist](https://github.com/sailist)! - Fix pythinker -p exiting right after the main turn instead of waiting for background tasks and subagents to finish. -- [#82](https://github.com/PyModel/pythinker-code/pull/82) [`1cd8682`](https://github.com/PyModel/pythinker-code/commit/1cd868296da9507cbb28768f71b2611f5ad8a813) - Add a Windows download button to the site and point both desktop download buttons directly at the published installer assets. +- [#2694](https://github.com/PyModel/pythinker-code/pull/2694) [`02c026d`](https://github.com/PyModel/pythinker-code/commit/02c026d4871a14cd5e7b4b0e0ec71ba815f643df) Thanks [@sailist](https://github.com/sailist)! - Keep live sessions stable when an MCP server is removed from the workspace config or uninstalled with its plugin: its tools stay registered in open sessions but calls fail with a removal notice, and the MCP panel shows the removed status. Servers added mid-session — by a plugin install or a config edit — are not registered in open sessions; they take effect in new sessions or after `/new` or `/reload`. -- [#82](https://github.com/PyModel/pythinker-code/pull/82) [`1cd8682`](https://github.com/PyModel/pythinker-code/commit/1cd868296da9507cbb28768f71b2611f5ad8a813) - Reserve the Windows title-bar area so the window controls no longer overlap the chat header, paint the Windows sidebar solid, and change the VS Code extension display name to `Pythinker` because the previous name is reserved on the Marketplace. +- [#2647](https://github.com/PyModel/pythinker-code/pull/2647) [`7bd3fd9`](https://github.com/PyModel/pythinker-code/commit/7bd3fd9f6e6c10f88d33b85760631ad6212b5f58) Thanks [@sailist](https://github.com/sailist)! - Read UTF-16 LE/BE text files (with or without a BOM) by transcoding them to UTF-8 instead of refusing them as binary; the web UI file viewer displays them as text as well. -## 0.19.0 +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: The sidebar error marker now only appears when the last turn failed; manually cancelled sessions are no longer flagged. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix attachments being silently dropped when sent together with a skill command. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix a manually chosen thinking level being reset to the model default when the first message of a new session is a skill command. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix session renaming during IME composition — Enter no longer submits mid-composition and Esc no longer exits the editor while composing. + +- [#2686](https://github.com/PyModel/pythinker-code/pull/2686) [`ef61084`](https://github.com/PyModel/pythinker-code/commit/ef610840098a57819d62d407f33256e14b512c77) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Use a compatible PowerShell for Windows Pythinker Computer Use installation, provide actionable recovery for locked plugin files, and keep its marketplace name consistent after installation. + +- [#2679](https://github.com/PyModel/pythinker-code/pull/2679) [`7b2784b`](https://github.com/PyModel/pythinker-code/commit/7b2784b9b7bf4749058da48923ecbbc8019eb7af) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Subagent tasks now show the model and thinking level they use. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix text selection while renaming a session or workspace — dragging no longer moves the whole list item. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the chevron direction on the "show less" button of the changed-files summary card. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: During automatic retries after a failed model request, the working status now shows retry progress (attempt N of M) instead of looking unresponsive. + +- [#2677](https://github.com/PyModel/pythinker-code/pull/2677) [`713bf1a`](https://github.com/PyModel/pythinker-code/commit/713bf1a5a2b388e4c5f9d3f471a728b8edbf5811) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix resumed sessions rendering background task completion notifications as raw protocol text instead of a task status card. + +- [#2692](https://github.com/PyModel/pythinker-code/pull/2692) [`03aa66c`](https://github.com/PyModel/pythinker-code/commit/03aa66ca0cca5880dc3a4a89e4f46d09acbe47ae) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show browser extension links and activation steps after installing Pythinker WebBridge. + +- [#2645](https://github.com/PyModel/pythinker-code/pull/2645) [`2b89373`](https://github.com/PyModel/pythinker-code/commit/2b893733f9853dc0aaeb775d9670d277db8e0381) Thanks [@sailist](https://github.com/sailist)! - Fix the web UI opening the Documents folder instead of the requested file on Windows when the file path contains spaces. + +- [#2697](https://github.com/PyModel/pythinker-code/pull/2697) [`e6e4ba2`](https://github.com/PyModel/pythinker-code/commit/e6e4ba2357cc659ebd0fd44c9492b498adc33d0e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the background-tasks and todos pills being pushed to the top of the window when the plan approval dialog expands. + +## 0.33.0 ### Minor Changes -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Match the desktop app's sidebar, collapse animation, empty-state visuals, and typography to the desktop design. +- [#2407](https://github.com/PyModel/pythinker-code/pull/2407) [`0abcd00`](https://github.com/PyModel/pythinker-code/commit/0abcd00f7fd3e3cbf087509ffef1c54a6f8d396d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add Pythinker Computer Use and Pythinker WebBridge as built-in official marketplace entries in the v2 CLI. Installing from `/plugins` sets up the latest managed runtime and plugin together, reports incomplete manual steps, and supports retrying interrupted setup. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Add a Desktop app section to web settings with automatic updates on by default, a manual update check, and a restart-to-update action. +- [#2627](https://github.com/PyModel/pythinker-code/pull/2627) [`f881cdd`](https://github.com/PyModel/pythinker-code/commit/f881cdd97073475c43272ec5734bbc39290dd399) Thanks [@sailist](https://github.com/sailist)! - Run the CLI surfaces (interactive TUI, `pythinker -p`, `pythinker acp`, `pythinker export`, `pythinker provider`) on the agent-core-v2 engine by default. Set `PYTHINKER_CODE_LEGACY_FLAG=1` to fall back to the legacy engine. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Refresh the web UI accent color and show the animated mascot on workflow cards, the activity spinner, and the empty state. +- [#2565](https://github.com/PyModel/pythinker-code/pull/2565) [`54c04bf`](https://github.com/PyModel/pythinker-code/commit/54c04bf03ddbeb46d02b2edb460ea091ae194509) Thanks [@7Sageer](https://github.com/7Sageer)! - `/fork` no longer switches to the forked session: the current session stays active and its background tasks keep running. Find the fork in `/sessions`. + +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - Ask whether to trust the current folder on startup. + +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add and manage custom providers in settings. + +- [#2599](https://github.com/PyModel/pythinker-code/pull/2599) [`541ddd2`](https://github.com/PyModel/pythinker-code/commit/541ddd2d898c4880a312874b1c539f85888bf0c1) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Overhaul the UI/UX and fix known issues. ### Patch Changes -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Fix sessions failing to load with an invalid event journal error after questions or approvals were resolved. +- [#2601](https://github.com/PyModel/pythinker-code/pull/2601) [`75fe068`](https://github.com/PyModel/pythinker-code/commit/75fe068a01261ff6b34f176530b338ec6a24918e) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix built-in capability availability and installed status in `/plugins`, preserve legacy WebBridge skills as backups during updates, and prevent Computer Use updates from duplicating or disconnecting MCP servers. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Run on Node 20 and newer by only re-executing for FFI support on Node 26.4+. +- [#2635](https://github.com/PyModel/pythinker-code/pull/2635) [`2b3e9a9`](https://github.com/PyModel/pythinker-code/commit/2b3e9a9f7910b0bb8050380068fa122c2c2cee91) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Rename the partner plugin marketplace tab to Curated and clarify that it contains third-party plugins from Pythinker partners. -- [#77](https://github.com/PyModel/pythinker-code/pull/77) [`26f3d18`](https://github.com/PyModel/pythinker-code/commit/26f3d18fe2a3ececb61c0fcb38605528c222c61e) - Keep releases visible in the update channel when a CDN rebuild request is temporarily lost. +- [#2614](https://github.com/PyModel/pythinker-code/pull/2614) [`8db7d42`](https://github.com/PyModel/pythinker-code/commit/8db7d42f23472a692eb389a0e0e5a3e18aa1b94d) Thanks [@RealKai42](https://github.com/RealKai42)! - Add /bug as an alias for the /feedback slash command. Type /bug to submit feedback. -- [#78](https://github.com/PyModel/pythinker-code/pull/78) [`86a4f9a`](https://github.com/PyModel/pythinker-code/commit/86a4f9abe9f51f4e81408a47e2f17d1af0c9cfbe) - Change the VS Code extension Marketplace ID to `pymodel.pythinker`. Existing users must install the extension again under the new ID because Microsoft permanently retired the previous ID. +- [#2586](https://github.com/PyModel/pythinker-code/pull/2586) [`278b6af`](https://github.com/PyModel/pythinker-code/commit/278b6af19d8708ec0f3eb2696d62a8d8209d497d) Thanks [@7Sageer](https://github.com/7Sageer)! - Ensure the first request waits for MCP startup to finish while the interface still opens immediately. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Skip invalid sessions during listing instead of failing the whole list. +- [#2620](https://github.com/PyModel/pythinker-code/pull/2620) [`2ee6e43`](https://github.com/PyModel/pythinker-code/commit/2ee6e431240a4a31034e0a403011dd6b2bfef9df) Thanks [@xpzouying](https://github.com/xpzouying)! - Fixed MCP OAuth re-authorization always failing with "Invalid redirect URI": the OAuth callback listener binds a random port per flow, but the dynamic client registration recorded the first flow's port, so every later interactive authorization was rejected at the authorization endpoint. A stale registration is now dropped automatically and the flow re-registers with the current callback URI. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Highlight the update notice in the terminal status bar with the warning color. +- [#2596](https://github.com/PyModel/pythinker-code/pull/2596) [`c32e661`](https://github.com/PyModel/pythinker-code/commit/c32e661faa931df9fdc72e63230f3ebebc00dce5) Thanks [@xpzouying](https://github.com/xpzouying)! - MCP tool results now surface the spec-defined `structuredContent` field and `_meta` server metadata to the model as a serialized `` block, instead of silently dropping them. Servers that return their machine-readable contract in these fields work the same as on other MCP hosts. -- [#77](https://github.com/PyModel/pythinker-code/pull/77) [`26f3d18`](https://github.com/PyModel/pythinker-code/commit/26f3d18fe2a3ececb61c0fcb38605528c222c61e) - Use a scoped GitHub App token for Homebrew tap updates. +- [#2612](https://github.com/PyModel/pythinker-code/pull/2612) [`e357028`](https://github.com/PyModel/pythinker-code/commit/e3570280bde775a153ee04388393506da7ac4cc1) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix all tool calls failing with spawn EBADF on macOS when a skill folder contains a very large file tree. -- [#80](https://github.com/PyModel/pythinker-code/pull/80) [`17818ea`](https://github.com/PyModel/pythinker-code/commit/17818ea6006cc3d4176ab7ede048163a457a99f9) - Fix duplicated streamed transcript copies and lost paragraph breaks in the web UI. +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - Start the interactive TUI without creating a session. -## 0.18.0 +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Show the signed-in account and plan usage. + +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Set an emoji for the session title. + +- [#2630](https://github.com/PyModel/pythinker-code/pull/2630) [`3bd098b`](https://github.com/PyModel/pythinker-code/commit/3bd098b80643c99eabdc602b767dbc53fc47cedd) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Pin sessions to the top of the sidebar. + +## 0.32.0 ### Minor Changes -- [#75](https://github.com/PyModel/pythinker-code/pull/75) [`2b2f438`](https://github.com/PyModel/pythinker-code/commit/2b2f438acbdb1d5367b39c5e479fd72f6c46dec2) - Add a session advisor that reviews work in the background, with the /advisor command to show and control it. +- [#2558](https://github.com/PyModel/pythinker-code/pull/2558) [`75395f6`](https://github.com/PyModel/pythinker-code/commit/75395f6abb17f83f30d16b51f4e060a639f43622) Thanks [@sailist](https://github.com/sailist)! - Add the TurnStarted, UserPromptQueued, TaskStarted, and SessionHeartbeat hook events, enrich hook payloads with the session title and client type, include the model and profile in SessionStart, and report SessionEnd as archive when a session is archived instead of exited. Configure the new events under [[hooks]] in config.toml. ### Patch Changes -- [#75](https://github.com/PyModel/pythinker-code/pull/75) [`2b2f438`](https://github.com/PyModel/pythinker-code/commit/2b2f438acbdb1d5367b39c5e479fd72f6c46dec2) - Stream in-progress thinking in the activity pane and move it into the transcript when complete. +- [#2416](https://github.com/PyModel/pythinker-code/pull/2416) [`eaab2b6`](https://github.com/PyModel/pythinker-code/commit/eaab2b6f28c0b958edf8ab5ae5e78a4c0426af26) Thanks [@mangeshraut712](https://github.com/mangeshraut712)! - Fall back to the built-in models.dev catalog snapshot when the public catalog is unreachable, so Known third-party provider import still works offline or in blocked networks. -- [#75](https://github.com/PyModel/pythinker-code/pull/75) [`2b2f438`](https://github.com/PyModel/pythinker-code/commit/2b2f438acbdb1d5367b39c5e479fd72f6c46dec2) - Show running dynamic workflow rows with the same braille spinner glyphs as other loaders. +- [#2083](https://github.com/PyModel/pythinker-code/pull/2083) [`bfa0080`](https://github.com/PyModel/pythinker-code/commit/bfa00807c975fdc5b84dda32d47b16b09e8d42c1) Thanks [@StaR4y](https://github.com/StaR4y)! - web: Fix dark-mode monochrome controls and align the chat composer corner radius with the design system. -## 0.17.1 +- [#2559](https://github.com/PyModel/pythinker-code/pull/2559) [`dfc55a5`](https://github.com/PyModel/pythinker-code/commit/dfc55a5c977dbff657e1da74ff5c2b9d488807be) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Render the "/login" already-logged-in confirmation in the success color instead of dim text, so the "Already logged in. Model configuration refreshed." message is clearly visible. + +- [#2572](https://github.com/PyModel/pythinker-code/pull/2572) [`6ba75a1`](https://github.com/PyModel/pythinker-code/commit/6ba75a173b595904bc70d0d7161de2f9b964c961) Thanks [@sailist](https://github.com/sailist)! - Rename the `[loop_control] max_retries_per_step` config key to `max_attempts_per_step` and `max_steps_per_run` to `max_steps_per_turn`: on the v2 engine the old keys no longer take effect and a startup warning prompts the rename in `config.toml`. The `PYTHINKER_LOOP_MAX_RETRIES_PER_STEP` env var is likewise deprecated in favor of `PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP` but keeps working with a warning. + +- [#2585](https://github.com/PyModel/pythinker-code/pull/2585) [`c396873`](https://github.com/PyModel/pythinker-code/commit/c39687318c64bf8a305a10bf9ca86ef6ef2c6656) Thanks [@sailist](https://github.com/sailist)! - Fix submitting answers to interactive question prompts being rejected when the model provider returns tool call IDs containing colons (some OpenAI-compatible gateways). + +- [#2562](https://github.com/PyModel/pythinker-code/pull/2562) [`071b6a5`](https://github.com/PyModel/pythinker-code/commit/071b6a50d9c2ce9c4b45dc4d58dac1101b8c4f52) Thanks [@sailist](https://github.com/sailist)! - Serve v1 message history from the server layer and drop the engine-side legacy message adapter; the /api/v1 message contract is unchanged. + +- [#2562](https://github.com/PyModel/pythinker-code/pull/2562) [`071b6a5`](https://github.com/PyModel/pythinker-code/commit/071b6a50d9c2ce9c4b45dc4d58dac1101b8c4f52) Thanks [@sailist](https://github.com/sailist)! - Assemble the session snapshot endpoint from the engine's services for both cold and live sessions, and remove the PYTHINKER_SNAPSHOT_READER, PYTHINKER_SNAPSHOT_TIMEOUT_MS, and PYTHINKER_SNAPSHOT_CACHE_LIMIT environment knobs. + +- [#2563](https://github.com/PyModel/pythinker-code/pull/2563) [`2118544`](https://github.com/PyModel/pythinker-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - Fix the context window limit showing as 0 in session status updates when no model is bound yet or the configured model no longer resolves; the limit now falls back to the default model or is omitted when unknown. + +- [#2563](https://github.com/PyModel/pythinker-code/pull/2563) [`2118544`](https://github.com/PyModel/pythinker-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - The `[token_counting]` strategy now only selects the reported context size: `estimated` keeps provider-reported usage out of the context-size display, and `measured` no longer gets stuck retrying an oversized compaction request until it fails. + +- [#2563](https://github.com/PyModel/pythinker-code/pull/2563) [`2118544`](https://github.com/PyModel/pythinker-code/commit/21185447fe0f04dbe342bebb6c6d0b364fd43daa) Thanks [@sailist](https://github.com/sailist)! - Add a `[token_counting]` config section to choose how context token counts are derived: `measured+estimated` (default), `measured` (provider usage only), or `estimated` (heuristic only, for providers without usage reporting). Set `strategy` under `[token_counting]` in config.toml (or `PYTHINKER_TOKEN_COUNTING_STRATEGY`) to switch. + +## 0.31.1 ### Patch Changes -- [#72](https://github.com/PyModel/pythinker-code/pull/72) [`61ce08e`](https://github.com/PyModel/pythinker-code/commit/61ce08ea82654ffb03fa4a0c2854aa5ce290090c) - Publish the VS Code extension under the pymodel Marketplace publisher and Open VSX namespace. +- [#2410](https://github.com/PyModel/pythinker-code/pull/2410) [`f1a3475`](https://github.com/PyModel/pythinker-code/commit/f1a3475ad5d6540447496701aa75fd4b035ecb28) Thanks [@sailist](https://github.com/sailist)! - Fix sporadic "model is not configured" errors when starting pythinker web, caused by the background provider-model refresh transiently clearing the model catalog while the first session was being created. -## 0.17.0 +- [#2400](https://github.com/PyModel/pythinker-code/pull/2400) [`1f3f5da`](https://github.com/PyModel/pythinker-code/commit/1f3f5dadaaa4a1d705cc98aee1dbbef13680502c) Thanks [@7Sageer](https://github.com/7Sageer)! - Preserve the assistant's partial output when a turn is interrupted with Esc, and remind the model that the previous turn was deliberately interrupted. + +- [#2415](https://github.com/PyModel/pythinker-code/pull/2415) [`5c0ec29`](https://github.com/PyModel/pythinker-code/commit/5c0ec2938ac3a01624b6503e5e5df80c9b08f46a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Enable Monaco-based highlighting for code blocks, and fix line numbers overlapping or drifting out of alignment in fallback-rendered code blocks. + +- [#2442](https://github.com/PyModel/pythinker-code/pull/2442) [`bb2919e`](https://github.com/PyModel/pythinker-code/commit/bb2919eb818a6cb51c71cbabf1bac9020131bce7) Thanks [@liruifengv](https://github.com/liruifengv)! - Reduce frequent full-screen redraws in the TUI. + +- [#2125](https://github.com/PyModel/pythinker-code/pull/2125) [`e111c87`](https://github.com/PyModel/pythinker-code/commit/e111c878fd5cd07994e125b9e4e07e4069f01be1) Thanks [@bowenliang123](https://github.com/bowenliang123)! - web: Order permission modes from safest to most permissive across settings surfaces, and fix the swapped yolo/auto risk colors in the status panel and mobile settings. + +- [#2459](https://github.com/PyModel/pythinker-code/pull/2459) [`326e1fb`](https://github.com/PyModel/pythinker-code/commit/326e1fb6ce59fbf2d6c7646e6d587759565814fd) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix chat code blocks rendering in the proportional UI font at the wrong size after the markdown renderer upgrade, and align the loading fallback with the highlighted block so the upgrade no longer shifts layout. + +- [#2437](https://github.com/PyModel/pythinker-code/pull/2437) [`ed7a4cc`](https://github.com/PyModel/pythinker-code/commit/ed7a4cc095e1619e4dbb6c2c77c89a52e312b085) Thanks [@sailist](https://github.com/sailist)! - web: Make the @ file mention work in a new-session draft, before the first prompt creates the session. + +- [#2437](https://github.com/PyModel/pythinker-code/pull/2437) [`ed7a4cc`](https://github.com/PyModel/pythinker-code/commit/ed7a4cc095e1619e4dbb6c2c77c89a52e312b085) Thanks [@sailist](https://github.com/sailist)! - web: Fix new sessions showing the thinking level (e.g. Max) while the first message actually ran with thinking off. + +## 0.31.0 ### Minor Changes -- [#70](https://github.com/PyModel/pythinker-code/pull/70) [`8506ded`](https://github.com/PyModel/pythinker-code/commit/8506dedac95b4917e1fafaccf6d5d313338d8d13) - Publish the CLI under the @pymodel npm scope; install with `npm install -g @pymodel/pythinker-code`. The old @pythoughts scope is deprecated and no longer receives releases. +- [#2365](https://github.com/PyModel/pythinker-code/pull/2365) [`fa2c5ce`](https://github.com/PyModel/pythinker-code/commit/fa2c5ce18b70577fa3ada4eb8bdd4993891994ce) Thanks [@7Sageer](https://github.com/7Sageer)! - Add support for plugin-contributed custom agents, discovered automatically and available for sub-agent delegation. Ship an `agents/` directory in the plugin (or declare `agents` paths in the plugin manifest) to provide them. + +- [#2314](https://github.com/PyModel/pythinker-code/pull/2314) [`02d77b2`](https://github.com/PyModel/pythinker-code/commit/02d77b20d941873563f14890e049ffe40cec76e4) Thanks [@7Sageer](https://github.com/7Sageer)! - Allow enabled plugins to contribute agent system-prompt instructions through `systemPrompt` or `systemPromptPath` in `pythinker.plugin.json`, effective on both agent engines (the TUI, `pythinker -p`, and `pythinker web`). + +- [#2232](https://github.com/PyModel/pythinker-code/pull/2232) [`efac96c`](https://github.com/PyModel/pythinker-code/commit/efac96c8a95a3c3ca4e1ae9bce38082498a02b2e) Thanks [@7Sageer](https://github.com/7Sageer)! - Support Markdown-defined custom agents on agent-core. + +- [#2232](https://github.com/PyModel/pythinker-code/pull/2232) [`efac96c`](https://github.com/PyModel/pythinker-code/commit/efac96c8a95a3c3ca4e1ae9bce38082498a02b2e) Thanks [@7Sageer](https://github.com/7Sageer)! - Add the /secondary_model slash command to configure the secondary model used by subagents. ### Patch Changes -- [#71](https://github.com/PyModel/pythinker-code/pull/71) [`9d46551`](https://github.com/PyModel/pythinker-code/commit/9d465516d63694957b7e6ada1986858d8d3b19e0) - Keep the current thinking effort when switching models in the model picker instead of silently saving the new model's lowest level as the default, and repair a stale thinking mode in the config when saving an effort. +- [#2382](https://github.com/PyModel/pythinker-code/pull/2382) [`40172c7`](https://github.com/PyModel/pythinker-code/commit/40172c7ca96ca981b043b793588dd32e898979fa) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix request headers not being passed correctly on some requests. -- [#67](https://github.com/PyModel/pythinker-code/pull/67) [`5cb218f`](https://github.com/PyModel/pythinker-code/commit/5cb218f9c35f2943da6d23c2073b637c6077ced4) - Keep the thinking effort chosen with Ctrl-T/Shift-Tab as the default across restarts. +- [#2379](https://github.com/PyModel/pythinker-code/pull/2379) [`691ec46`](https://github.com/PyModel/pythinker-code/commit/691ec4679ea19d6be8ac18f359088384ed3e446d) Thanks [@RealKai42](https://github.com/RealKai42)! - Remove the blocking `block`/`timeout` wait from the TaskOutput tool so checking a background task can no longer stall the conversation; it now always returns an immediate snapshot, and completion still arrives via automatic notification. -- [#68](https://github.com/PyModel/pythinker-code/pull/68) [`b69205f`](https://github.com/PyModel/pythinker-code/commit/b69205f5b298d819ae576a2a25df6ae029c27303) - Show only the animated thinking indicator while the model thinks; the streamed thinking text no longer appears in the transcript unless expanded with Ctrl+O. +- [#2395](https://github.com/PyModel/pythinker-code/pull/2395) [`d10b1c1`](https://github.com/PyModel/pythinker-code/commit/d10b1c130813dbd6ee8c8599a6a98feb36aea67f) Thanks [@sailist](https://github.com/sailist)! - Fix sessions missing from the session picker when their cached metadata predates the archived flag. -## 0.16.0 +## 0.30.0 ### Minor Changes -- [#59](https://github.com/PyModel/pythinker-code/pull/59) [`6999b68`](https://github.com/PyModel/pythinker-code/commit/6999b685ff63fb275a179be61f47e8b5cb727ba5) - Add an opt-in advisor: a second model reviews the conversation after a completed user turn unless another review is already running, and its notes appear as an `` block in the agent's next turn; enable with `[advisor] enabled = true` plus an advisor model (the `advisor` model role or `[advisor] model`), and it runs only when the advisor shares the session model's provider. +- [#2255](https://github.com/PyModel/pythinker-code/pull/2255) [`67dd031`](https://github.com/PyModel/pythinker-code/commit/67dd03149f36be91a0c081e70d8a2d721b0f1c64) Thanks [@he-yufeng](https://github.com/he-yufeng)! - Add a customizable footer status line, configured via `[status_line]` in `tui.toml`. + +### Patch Changes + +- [#2313](https://github.com/PyModel/pythinker-code/pull/2313) [`de0ba9d`](https://github.com/PyModel/pythinker-code/commit/de0ba9d0654273ff6b028a7a561983ebee4e723e) Thanks [@starquakee](https://github.com/starquakee)! - Stop the turn after repeated invalid tool calls instead of retrying indefinitely. + +- [#2147](https://github.com/PyModel/pythinker-code/pull/2147) [`29783e4`](https://github.com/PyModel/pythinker-code/commit/29783e471afcf7975852e496907646458264d2e6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show a quota note after installing official plugins that bill against plan quota (such as Pythinker Datasource). -- [#56](https://github.com/PyModel/pythinker-code/pull/56) [`b71f094`](https://github.com/PyModel/pythinker-code/commit/b71f09460825feb63bdf1dbb029c12a34e140598) - Add model roles: lock a model alias to the small, implementer, or advisor slot with `/model `, list assignments with `/model roles`, and reference roles as `@small`, `@implementer`, or `@advisor` wherever a subagent model can be set; an assigned implementer role becomes the default model for subagents. +- [#2147](https://github.com/PyModel/pythinker-code/pull/2147) [`29783e4`](https://github.com/PyModel/pythinker-code/commit/29783e471afcf7975852e496907646458264d2e6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show a notice when an official plugin used in the session has an update available. Run /plugins to install it. -- [#57](https://github.com/PyModel/pythinker-code/pull/57) [`99c427c`](https://github.com/PyModel/pythinker-code/commit/99c427ce686a2c7dca183cb60235f8ecc3fd393f) - Show what the agent is doing in the working indicator: eligible tool calls whose input schema accepts the injected field now carry a short model-written intent, streamed live into the spinner label (for example "check failing test…") instead of a rotating placeholder; disable with `PYTHINKER_CODE_EXPERIMENTAL_TOOL_INTENT=0`. +- [#1857](https://github.com/PyModel/pythinker-code/pull/1857) [`cdbd33c`](https://github.com/PyModel/pythinker-code/commit/cdbd33c13c7f5cd4c49ec112ee4313b3938a7752) Thanks [@vinlee19](https://github.com/vinlee19)! - Fail fast when account quota or balance is exhausted instead of silently retrying for ~3 minutes. -- [#58](https://github.com/PyModel/pythinker-code/pull/58) [`065bf2e`](https://github.com/PyModel/pythinker-code/commit/065bf2e9b90cfe9f5ec103132996baae73daaf26) - Redesign core TUI surfaces: tool cards get state-tinted backgrounds with three new theme tokens, a status bar with a per-session accent color appears between the input box and footer, and the prompt box uses a neutral border while permission mode appears in the status bar. The working-label shimmer uses a calmer constant-velocity sweep with alternating mission-control highlights. +- [#2294](https://github.com/PyModel/pythinker-code/pull/2294) [`425cfdf`](https://github.com/PyModel/pythinker-code/commit/425cfdf53f0fd3b01527f5fba87acff68f49f368) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix garbled line numbers in code blocks. + +- [#2312](https://github.com/PyModel/pythinker-code/pull/2312) [`d03a488`](https://github.com/PyModel/pythinker-code/commit/d03a4886fdf7c35014c10079a3d417aeb0447d9a) Thanks [@sailist](https://github.com/sailist)! - Remove the 50 MB size limit on file uploads to the built-in server. + +## 0.29.2 ### Patch Changes -- [#62](https://github.com/PyModel/pythinker-code/pull/62) [`7fc36fd`](https://github.com/PyModel/pythinker-code/commit/7fc36fdc4c5694812fce65c57151cd14c182b8fc) - Repair invalid escape sequences and unescaped quotes in model-written tool arguments instead of failing the tool call. +- [#2192](https://github.com/PyModel/pythinker-code/pull/2192) [`7799bd7`](https://github.com/PyModel/pythinker-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Hold per-agent runtime state of the experimental engine in the agent-scope state container, so it is observable in one place and disposed with the agent; state snapshots collapse class instances to name markers so resource graphs cannot exhaust memory during export. -## 0.15.0 +- [#2119](https://github.com/PyModel/pythinker-code/pull/2119) [`f06eb5c`](https://github.com/PyModel/pythinker-code/commit/f06eb5c60e0a4e51162d1854dda1db41892b457c) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Allow hosts to defer registered user-tool schemas until needed. Set `disclosure: "deferred"` when registering a tool. -### Minor Changes +- [#2192](https://github.com/PyModel/pythinker-code/pull/2192) [`7799bd7`](https://github.com/PyModel/pythinker-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Instantiate every registered service eagerly at scope creation on the experimental engine, following the dependency graph automatically, and drop the hand-maintained lists that resolved side-effect services one by one at startup. -- [#54](https://github.com/PyModel/pythinker-code/pull/54) [`1f45a5f`](https://github.com/PyModel/pythinker-code/commit/1f45a5fefbdf4d5d8006f82613d51e24adb2e413) - Show indeterminate lifecycle progress for Dynamic Workflow rows in the TUI, and report schema-error outcomes as failed. +- [#2120](https://github.com/PyModel/pythinker-code/pull/2120) [`0d00a07`](https://github.com/PyModel/pythinker-code/commit/0d00a07c02e334ca904077b2ea8c56cf58b44586) Thanks [@yicun](https://github.com/yicun)! - web: Fix copying selected chat text over plain HTTP from replacing the clipboard with an event placeholder. -## 0.14.0 +- [#2210](https://github.com/PyModel/pythinker-code/pull/2210) [`0cef160`](https://github.com/PyModel/pythinker-code/commit/0cef160c4b900a3d78212cd5da4b80d335ea0b6f) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal pursuit being interrupted when a goal turn reaches the per-turn step limit (`loop_control.max_steps_per_turn`); the limit now splits goal work into more continuation turns instead of pausing the goal. + +- [#2153](https://github.com/PyModel/pythinker-code/pull/2153) [`c497af6`](https://github.com/PyModel/pythinker-code/commit/c497af60e6cd20aab05e590f98a28fb15dd3491d) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix messages sent while a goal is running being rejected with a "Cannot launch a new turn while another turn is active" error; they are now steered into the active goal turn instead of being dropped. + +- [#2192](https://github.com/PyModel/pythinker-code/pull/2192) [`7799bd7`](https://github.com/PyModel/pythinker-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Hold per-session runtime state of the experimental engine in the session-scope state container, so it is observable in one place and disposed with the session. + +- [#2055](https://github.com/PyModel/pythinker-code/pull/2055) [`d40d0d3`](https://github.com/PyModel/pythinker-code/commit/d40d0d305d2866cb5ab8696e559e0813b5f92201) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. + +## 0.29.1 + +### Patch Changes + +- [#2065](https://github.com/PyModel/pythinker-code/pull/2065) [`527d485`](https://github.com/PyModel/pythinker-code/commit/527d485d9296fe20f473a4a578d9e6a499c20cd9) Thanks [@7Sageer](https://github.com/7Sageer)! - Add global default MCP server timeouts in `config.toml` and env vars. + +- [#2104](https://github.com/PyModel/pythinker-code/pull/2104) [`66f611a`](https://github.com/PyModel/pythinker-code/commit/66f611aae99887ad2076aa3482a0df5e415d3511) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix loss of thinking content with OpenAI-compatible endpoints that return reasoning under a different field name (e.g. newer vLLM); the reasoning field is now detected per endpoint and echoed back on follow-up requests. + +- [#2089](https://github.com/PyModel/pythinker-code/pull/2089) [`ca38b7e`](https://github.com/PyModel/pythinker-code/commit/ca38b7ed864ad5fa2b2e3c8b96d8a7b10a734445) Thanks [@liruifengv](https://github.com/liruifengv)! - Remove the toolbar tip that suggested trying the "superpowers" plugin. + +- [#2064](https://github.com/PyModel/pythinker-code/pull/2064) [`7b62ed5`](https://github.com/PyModel/pythinker-code/commit/7b62ed5b2c2709719f360c01a2f513dee34ae179) Thanks [@7Sageer](https://github.com/7Sageer)! - Add experimental secondary-model bindings for newly spawned subagents, including per-agent model preferences and subagent-only model overrides. + +- [#2096](https://github.com/PyModel/pythinker-code/pull/2096) [`5fdbdb4`](https://github.com/PyModel/pythinker-code/commit/5fdbdb4a22b86ae6f7ba7c775741689aaaf215f0) Thanks [@7Sageer](https://github.com/7Sageer)! - Add environment variables to configure the web search and web fetch services without OAuth login. + +## 0.29.0 ### Minor Changes -- [#51](https://github.com/PyModel/pythinker-code/pull/51) [`c8cdcc7`](https://github.com/PyModel/pythinker-code/commit/c8cdcc78528f3fd8dedf9111ec0c91f3242e3012) - `/workflow save` accepts `--personal` to save into the home skills directory, resolves the repository root when saving from a subdirectory so the saved skill is discoverable, and persists the workflow size guideline into the saved skill. +- [#1992](https://github.com/PyModel/pythinker-code/pull/1992) [`a8f1ca3`](https://github.com/PyModel/pythinker-code/commit/a8f1ca3f1016a3e84986f297367e833bc731ac39) Thanks [@RealKai42](https://github.com/RealKai42)! - Support selecting a thinking effort level from ACP clients: the thinking picker now lists the current model's declared levels (for example off / low / medium / high) instead of only an on/off toggle. Use the thinking selector in your ACP client (e.g. Zed) to pick a level; the legacy on/off values keep working. + +- [#1735](https://github.com/PyModel/pythinker-code/pull/1735) [`ce0e3ce`](https://github.com/PyModel/pythinker-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Let custom agent files restrict which sub-agent types they may delegate to (v2 engine only). + +- [#1735](https://github.com/PyModel/pythinker-code/pull/1735) [`ce0e3ce`](https://github.com/PyModel/pythinker-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Support custom agents defined as Markdown files with frontmatter, usable as the main agent or a sub-agent (v2 engine only). + +- [#1735](https://github.com/PyModel/pythinker-code/pull/1735) [`ce0e3ce`](https://github.com/PyModel/pythinker-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Add global tool gating to constrain which tools agents may use, with a per-session override (v2 engine only). + +- [#2012](https://github.com/PyModel/pythinker-code/pull/2012) [`d67a200`](https://github.com/PyModel/pythinker-code/commit/d67a2003abf2d8d802dcf24f806e0a811724b83e) Thanks [@sailist](https://github.com/sailist)! - Add a GET /api/v1/fs:content server endpoint that serves any file on the host by absolute path as raw content with Content-Type, ETag, and Range support. + +- [#1999](https://github.com/PyModel/pythinker-code/pull/1999) [`4c763f6`](https://github.com/PyModel/pythinker-code/commit/4c763f6763acb67a73d133f7450d092e71d63692) Thanks [@RealKai42](https://github.com/RealKai42)! - Videos attached to a prompt — pasted in the TUI or uploaded in the web UI — now reach the model together with the prompt, with no extra tool round trip, and stay playable in the chat after a reload. + +- [#1735](https://github.com/PyModel/pythinker-code/pull/1735) [`ce0e3ce`](https://github.com/PyModel/pythinker-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Support overriding the default main-agent system prompt with a user-level file for every session (v2 engine only). ### Patch Changes -- [#46](https://github.com/PyModel/pythinker-code/pull/46) [`bceff21`](https://github.com/PyModel/pythinker-code/commit/bceff2191cd196f30cd59a297ad29b642073030d) - Fix `pythinker doctor` crashing on native installs, and report the last recorded update outcome. +- [#1997](https://github.com/PyModel/pythinker-code/pull/1997) [`74da87a`](https://github.com/PyModel/pythinker-code/commit/74da87a457c2964694a844dd22a4925f5113b167) Thanks [@sailist](https://github.com/sailist)! - Add agent.created and agent.disposed events to the server session event stream, and expose each agent's disposal time in the transcript API. -- [#49](https://github.com/PyModel/pythinker-code/pull/49) [`f35061e`](https://github.com/PyModel/pythinker-code/commit/f35061e39de539476d3897c6acf4966991669576) - Model permission deny rules now also apply to subagent model overrides coming from agent profiles and from resume or retry, not only to models named in tool arguments; a denied override falls back to the parent agent's model. +- [#2030](https://github.com/PyModel/pythinker-code/pull/2030) [`ec88d35`](https://github.com/PyModel/pythinker-code/commit/ec88d352e8f4dc5e8ffd1212f016138458f69893) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix catalog-imported Claude models being wrongly locked into always-on thinking, and stop offering a misleading thinking Off option for models that cannot truly disable reasoning (such as Gemini 3). Also normalizes configured thinking effort values and unifies context-usage reporting. -- [#46](https://github.com/PyModel/pythinker-code/pull/46) [`bceff21`](https://github.com/PyModel/pythinker-code/commit/bceff2191cd196f30cd59a297ad29b642073030d) - Stop reporting an update as installed when the executable did not change; the version is checked after the installer finishes and a mismatch is recorded as a failure with the reason. +- [#2015](https://github.com/PyModel/pythinker-code/pull/2015) [`b5efba7`](https://github.com/PyModel/pythinker-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50) Thanks [@RealKai42](https://github.com/RealKai42)! - Import many more providers from the models.dev catalog: vendor SDKs like xai and openrouter now import instead of being refused (with a "guessed" note), deprecated and alpha models are filtered out, per-model gateway protocol and endpoint overrides are honored, and context limits are correct (input limit for compaction, total window for completion). Imports lacking a usable endpoint now ask for one via `--base-url` or a prompt. -- [#46](https://github.com/PyModel/pythinker-code/pull/46) [`bceff21`](https://github.com/PyModel/pythinker-code/commit/bceff2191cd196f30cd59a297ad29b642073030d) - Show download progress under the prompt while a Windows update installs, instead of nothing until it finishes. +- [#1993](https://github.com/PyModel/pythinker-code/pull/1993) [`37eda4e`](https://github.com/PyModel/pythinker-code/commit/37eda4e59aebc8ecafa91be3f43f971ed63963a3) Thanks [@RealKai42](https://github.com/RealKai42)! - Add environment variable overrides for agent loop and background task limits. Set PYTHINKER_LOOP_MAX_STEPS_PER_TURN, PYTHINKER_LOOP_MAX_RETRIES_PER_STEP, or PYTHINKER_CODE_BACKGROUND_MAX_RUNNING_TASKS to take priority over the [loop_control] and [background] config. -- [#46](https://github.com/PyModel/pythinker-code/pull/46) [`bceff21`](https://github.com/PyModel/pythinker-code/commit/bceff2191cd196f30cd59a297ad29b642073030d) - Fix automatic updates on Windows for npm, pnpm, and yarn installs, which failed to start at all. +- [#1993](https://github.com/PyModel/pythinker-code/pull/1993) [`37eda4e`](https://github.com/PyModel/pythinker-code/commit/37eda4e59aebc8ecafa91be3f43f971ed63963a3) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix config environment overrides (such as PYTHINKER_IMAGE_MAX_EDGE_PX or PYTHINKER_SUBAGENT_TIMEOUT_MS) being persisted into config.toml by config API writes while the env var is set, and keeping the old value after the env var is changed to an invalid value or removed. -- [#50](https://github.com/PyModel/pythinker-code/pull/50) [`38e3504`](https://github.com/PyModel/pythinker-code/commit/38e35047069dd4f9a22e3160e34861b6550b6b56) - Record the origin of the prompt that entered Dynamic Workflow mode, so a fan-out started by a scheduled job or hook is attributable in the session records. +- [#2050](https://github.com/PyModel/pythinker-code/pull/2050) [`8250e59`](https://github.com/PyModel/pythinker-code/commit/8250e590f3ed5990c233ef5a2c7666468f0bcb05) Thanks [@sailist](https://github.com/sailist)! - Remove references to the non-existent `pythinker resume` command from the scheduled-task tool descriptions. -- [#49](https://github.com/PyModel/pythinker-code/pull/49) [`f35061e`](https://github.com/PyModel/pythinker-code/commit/f35061e39de539476d3897c6acf4966991669576) - Subagent lifecycle events now carry the workflow name on start, completion and failure, and suspension events carry both the workflow run id and name, so clients can correlate every event without caching the spawn event. +- [#1970](https://github.com/PyModel/pythinker-code/pull/1970) [`6dd4fd3`](https://github.com/PyModel/pythinker-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Fix cancelled model requests being wrapped as retryable provider errors, so interrupting a request no longer triggers silent retries. -## 0.13.1 +- [#1970](https://github.com/PyModel/pythinker-code/pull/1970) [`6dd4fd3`](https://github.com/PyModel/pythinker-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Send the session prompt cache key to OpenAI and OpenAI Responses providers, restoring provider-side prompt cache affinity that previously only reached Pythinker and Anthropic. + +- [#1999](https://github.com/PyModel/pythinker-code/pull/1999) [`4c763f6`](https://github.com/PyModel/pythinker-code/commit/4c763f6763acb67a73d133f7450d092e71d63692) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix ReadMediaFile failing on videos when the provider has no file upload channel — such videos now fall back to inline delivery. + +- [#1968](https://github.com/PyModel/pythinker-code/pull/1968) [`71bcfba`](https://github.com/PyModel/pythinker-code/commit/71bcfba54a6836f4b6d4e26babde67576b293a64) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix sessions getting stuck on every turn with a provider "message must not be empty" error after a content-filtered response. + +- [#2022](https://github.com/PyModel/pythinker-code/pull/2022) [`154e082`](https://github.com/PyModel/pythinker-code/commit/154e0824880c8573433e4ec7ada083744dbfe9f9) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show transparent images over a checkerboard canvas so white and black content stays visible in both light and dark themes. + +- [#1990](https://github.com/PyModel/pythinker-code/pull/1990) [`115b096`](https://github.com/PyModel/pythinker-code/commit/115b0968cefede7fac1494c6f0154ea5545a89da) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix goal mode continuation prompts leaking into the transcript when resuming a session. + +- [#1970](https://github.com/PyModel/pythinker-code/pull/1970) [`6dd4fd3`](https://github.com/PyModel/pythinker-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Rework the model wire layer in the experimental v2 engine into a small set of protocol bases plus declarative provider trait definitions, so adding a provider no longer means copying adapter code, and per-turn request intent (cache key, thinking effort, sampling) flows as request parameters instead of cloned model objects. The never-functional `[platforms]` config section and the `provider.platformId` field are removed; credential resolution is now a two-layer model → provider lookup. + +- [#1976](https://github.com/PyModel/pythinker-code/pull/1976) [`e458323`](https://github.com/PyModel/pythinker-code/commit/e45832398d0d9cad98dbad1cbf1e5b103a20aace) Thanks [@liruifengv](https://github.com/liruifengv)! - Improve TUI performance and resume speed for long-running sessions. + +- [#1991](https://github.com/PyModel/pythinker-code/pull/1991) [`92576e4`](https://github.com/PyModel/pythinker-code/commit/92576e4d850ada51a24e72fe76a83cc512df922a) Thanks [@7Sageer](https://github.com/7Sageer)! - Reconnect a dropped MCP server connection automatically when one of its tools is called, and retry the call once. + +- [#1970](https://github.com/PyModel/pythinker-code/pull/1970) [`6dd4fd3`](https://github.com/PyModel/pythinker-code/commit/6dd4fd33688b37904d5302436fc2daaf09d66c7d) Thanks [@sailist](https://github.com/sailist)! - Add read-only model resolution inspection and a live connectivity probe to the server's RPC surface, reporting per-field value provenance (config, override, builtin, env, synthesized) for internal debugging tools. + +- [#2015](https://github.com/PyModel/pythinker-code/pull/2015) [`b5efba7`](https://github.com/PyModel/pythinker-code/commit/b5efba7abcaf4041f81ec520097a61e6546e8c50) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix thinking levels being offered for models that do not support them (e.g. phantom levels on Kimi K3): levels now come from each model's declared capabilities. Models that cannot disable reasoning (e.g. gpt-5) no longer offer an Off option, and turning thinking Off on models that support it (e.g. xai grok) now truly disables reasoning. + +- [#1735](https://github.com/PyModel/pythinker-code/pull/1735) [`ce0e3ce`](https://github.com/PyModel/pythinker-code/commit/ce0e3ceb04223bdaad8e8931bad46eff561055b6) Thanks [@7Sageer](https://github.com/7Sageer)! - Warn when a tool allow/deny list entry can never match any tool, for example a misspelled name (v2 engine only). + +- [#2005](https://github.com/PyModel/pythinker-code/pull/2005) [`a3699dd`](https://github.com/PyModel/pythinker-code/commit/a3699dd6aa7b41efd3129a117007d195282379fd) Thanks [@7Sageer](https://github.com/7Sageer)! - Add an `active` flag to each tool in the server's tool listing API. + +- [#1995](https://github.com/PyModel/pythinker-code/pull/1995) [`73eb5f8`](https://github.com/PyModel/pythinker-code/commit/73eb5f89e06fb15d42c7585a147eb1c5caef0725) Thanks [@liruifengv](https://github.com/liruifengv)! - Remove red coloring from syntax highlighting in code previews and markdown code blocks. + +- [#2014](https://github.com/PyModel/pythinker-code/pull/2014) [`576d650`](https://github.com/PyModel/pythinker-code/commit/576d65038035570bea90b58d5824bcd60ca11258) Thanks [@liruifengv](https://github.com/liruifengv)! - Add a reminder for third-party install sources to use the official installer in the update prompt. + +## 0.28.1 ### Patch Changes -- [#43](https://github.com/PyModel/pythinker-code/pull/43) [`0ea74d4`](https://github.com/PyModel/pythinker-code/commit/0ea74d4b7afa49e79440bff9464ed4019e3fcb1c) - Dim the wording on background task status lines so only the status dot is coloured. +- [#934](https://github.com/PyModel/pythinker-code/pull/934) [`c5b6103`](https://github.com/PyModel/pythinker-code/commit/c5b6103bb9b0a163d48cbce0034c3fc7dea7c344) Thanks [@tt-a1i](https://github.com/tt-a1i)! - Allow ACP sessions to start with configured non-OAuth model credentials instead of requiring terminal login. -## 0.13.0 +- [#1967](https://github.com/PyModel/pythinker-code/pull/1967) [`ad8cc85`](https://github.com/PyModel/pythinker-code/commit/ad8cc8525198a08bc1181cee9a15bbb4521cd9bc) Thanks [@sailist](https://github.com/sailist)! - Run web servers foreground-only end to end: the /web slash command now always starts a new server, and the `pythinker web kill` / `pythinker web ps` subcommands are removed — foreground servers stop with Ctrl+C. `pythinker server kill` remains as a deprecated fallback that only stops servers started by a version before 0.28.0. + +- [#1948](https://github.com/PyModel/pythinker-code/pull/1948) [`f6f4192`](https://github.com/PyModel/pythinker-code/commit/f6f4192957ace3f0cceb734a04b3b26b1d2f88be) Thanks [@sailist](https://github.com/sailist)! - Fix running subagents not observing permission mode switches made after they started. + +## 0.28.0 ### Minor Changes -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Let permission rules gate the model a subagent runs on, so `Agent(model:some-model)` and `DynamicWorkflow(model:some-model)` now match instead of being silently ignored. +- [#1826](https://github.com/PyModel/pythinker-code/pull/1826) [`a41a09c`](https://github.com/PyModel/pythinker-code/commit/a41a09c33c8e432fbc306f5882692c967ed5ea17) Thanks [@sailist](https://github.com/sailist)! - Replace the `pythinker server` command tree with `pythinker web`: the server runs in the foreground (the background daemon and OS-service lifecycle commands are removed), and multiple servers can now share one home directory, each taking the next free port. Manage instances with `pythinker web kill [server-id|all]`, `pythinker web ps`, and `pythinker web rotate-token`; any `pythinker server …` invocation prints a deprecation notice and exits 1. -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Show the Dynamic Workflow plan before the run in `auto` permission mode, which previously approved the call without displaying it. The approval is asked once per distinct plan; `yolo` still approves without asking. +- [#1933](https://github.com/PyModel/pythinker-code/pull/1933) [`11c1683`](https://github.com/PyModel/pythinker-code/commit/11c1683a1cd2adab276562419d2d353629063d80) Thanks [@liruifengv](https://github.com/liruifengv)! - Thinking effort persists only levels below the model's top tier (max). ### Patch Changes -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Fix Dynamic Workflow recent activity showing one growing line three times instead of the last three lines an agent wrote. +- [#1867](https://github.com/PyModel/pythinker-code/pull/1867) [`3086e47`](https://github.com/PyModel/pythinker-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Rename the stale "afk" reference to "auto" in the built-in MCP config skill guidance. -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Drop the preamble every Dynamic Workflow task repeats so each agent row shows the part that names it. +- [#1867](https://github.com/PyModel/pythinker-code/pull/1867) [`3086e47`](https://github.com/PyModel/pythinker-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Correct the YOLO and Auto permission mode descriptions in CLI --help output and in the ACP session mode selector shown by IDE clients. -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Fix the Dynamic Workflow card clipping an agent's task down to one character once that agent returned a long summary. +- [#1867](https://github.com/PyModel/pythinker-code/pull/1867) [`3086e47`](https://github.com/PyModel/pythinker-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - web: Correct the YOLO and Auto permission mode descriptions in the slash command list and the mobile permission sheet. -- [#41](https://github.com/PyModel/pythinker-code/pull/41) [`e534040`](https://github.com/PyModel/pythinker-code/commit/e534040c82d1e3b8c217e6e35ddcf248065ff950) - Fix `/workflow save` leaving the saved workflow uncallable until the session was reloaded, and add `Session.reloadSkills()` to re-discover skills written while a session is open. +- [#1867](https://github.com/PyModel/pythinker-code/pull/1867) [`3086e47`](https://github.com/PyModel/pythinker-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the YOLO and Auto permission mode descriptions to match their actual behavior: YOLO auto-approves tool actions but the agent may still ask questions, while Auto is fully autonomous and never asks. -## 0.12.0 +- [#1867](https://github.com/PyModel/pythinker-code/pull/1867) [`3086e47`](https://github.com/PyModel/pythinker-code/commit/3086e4703992fbbe7a41379405ee243713ad9ced) Thanks [@RealKai42](https://github.com/RealKai42)! - Correct the YOLO mode notice shown when replaying a session: tool actions are auto-approved, but the agent may still ask questions. + +- [#1843](https://github.com/PyModel/pythinker-code/pull/1843) [`a3e773f`](https://github.com/PyModel/pythinker-code/commit/a3e773f90ce66abe6db229607440c20769537c93) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix the web backend ignoring symbolic links when loading AGENTS.md files and reading files. + +- [#1940](https://github.com/PyModel/pythinker-code/pull/1940) [`d71bf9e`](https://github.com/PyModel/pythinker-code/commit/d71bf9e5a56b5978316e715f7c131c784967d562) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add a note in the model switcher that switching models or thinking effort invalidates the existing prompt cache. + +## 0.27.0 ### Minor Changes -- [#35](https://github.com/PyModel/pythinker-code/pull/35) [`2ce6b5e`](https://github.com/PyModel/pythinker-code/commit/2ce6b5e66935335567a6525413ad8e77b84d852f) - Show the plan before a Dynamic Workflow runs, and let a good one be saved as a command +- [#1822](https://github.com/PyModel/pythinker-code/pull/1822) [`a5c568d`](https://github.com/PyModel/pythinker-code/commit/a5c568dc7a84962bae70a16858709c453fc90a07) Thanks [@liruifengv](https://github.com/liruifengv)! - Add the /copy slash command to copy the last assistant message to the clipboard. + +- [#1824](https://github.com/PyModel/pythinker-code/pull/1824) [`bfecd01`](https://github.com/PyModel/pythinker-code/commit/bfecd0128fe7d88971a84095e24ef8a56ba34e71) Thanks [@liruifengv](https://github.com/liruifengv)! - Using an API key for Pythinker coding models now also fetches the latest model list automatically. + +### Patch Changes + +- [#1811](https://github.com/PyModel/pythinker-code/pull/1811) [`cec15e2`](https://github.com/PyModel/pythinker-code/commit/cec15e2188b24e0f904e5ca660a2e72c06364647) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix Esc and Ctrl+C cancelling compaction instead of closing an open /btw panel. + +- [#1806](https://github.com/PyModel/pythinker-code/pull/1806) [`9b49694`](https://github.com/PyModel/pythinker-code/commit/9b496946dcb3c7fa9507e6d5c251c1941e44a316) Thanks [@sailist](https://github.com/sailist)! - Mount the dev-only /api/v1/debug RPC surface behind the --debug-endpoints flag, exposing every scoped service for local debugging on loopback binds. Pass --debug-endpoints to pythinker server run to enable it. + +- [#1788](https://github.com/PyModel/pythinker-code/pull/1788) [`365ba00`](https://github.com/PyModel/pythinker-code/commit/365ba0001de206863ff1de8e106c85d7f187c192) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix `/export-debug-zip` and `pythinker export` overwriting the previous ZIP archive when run repeatedly on the same session; the default export filename now includes a timestamp. + +- [#1840](https://github.com/PyModel/pythinker-code/pull/1840) [`fa7e4ba`](https://github.com/PyModel/pythinker-code/commit/fa7e4ba4218703bb1ef3112ab2493496983b0539) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix AGENTS.md files installed as symbolic links being ignored by the web backend. - Manual mode used to approve every `DynamicWorkflow` call outright. That approval - only ever fired in manual mode — auto and yolo approve earlier in the chain — so - the one mode whose purpose is to ask was the one mode that never saw what it was - agreeing to. A `DynamicWorkflow` call in manual mode now asks, and the approval - carries the fan-out: how many subagents, the task list, the prompt template, the - worker model, and the summed size of the prompts about to be sent. "Approve for - this session" is keyed to that workflow's description rather than granting every - future `DynamicWorkflow` call. +- [#1829](https://github.com/PyModel/pythinker-code/pull/1829) [`1b907b0`](https://github.com/PyModel/pythinker-code/commit/1b907b07cdcc0e9cba5203fe40dacae85a4b768d) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix whitespace-only thinking content rendering as a blank bullet line in the transcript, both while streaming and when replaying session history. - `/workflow save ` writes the last run back out as a skill under - `.pythinker-code/skills/`, so a fan-out that worked can be re-run by name. +- [#1809](https://github.com/PyModel/pythinker-code/pull/1809) [`56a321d`](https://github.com/PyModel/pythinker-code/commit/56a321d4d127c0b4cf7a3e15e2959ebf3eded192) Thanks [@sailist](https://github.com/sailist)! - web: Fix duplicate workspace groups on Windows when the same folder is opened with different path spellings, such as a different drive-letter casing; all of the folder's sessions now list under the single merged group. -- [#38](https://github.com/PyModel/pythinker-code/pull/38) [`44efbc7`](https://github.com/PyModel/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Let a release declare a minimum supported version, so a client below it is offered the update without waiting for its staged rollout batch. +- [#1847](https://github.com/PyModel/pythinker-code/pull/1847) [`56ba8e0`](https://github.com/PyModel/pythinker-code/commit/56ba8e0196a3053ad1115a7e8f8b8c4c0cd1b320) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix LaTeX formulas rendering as garbled overlapping text when the web UI is accessed over the network; the server's content security policy now allows the inline styles that math and code highlighting rely on, while scripts remain strictly restricted. -- [#38](https://github.com/PyModel/pythinker-code/pull/38) [`44efbc7`](https://github.com/PyModel/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed. +- [#1816](https://github.com/PyModel/pythinker-code/pull/1816) [`44f3341`](https://github.com/PyModel/pythinker-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Harden the embedded key-value engine's durability: WAL compaction now always terminates under sustained write storms instead of chasing the tail forever, a committed write can no longer slip through a compaction rotation undetected, torn WAL tails no longer misplace later disk-mode value pointers, read-only opens never create or modify database files or compact under a live writer, corrupt index-definition files no longer force a full rebuild, stale compaction temp files are cleaned on open, and the process lock can no longer be taken over by several processes at once. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Rename the ACP authentication method to reflect that login is multi-provider: it now reads "Log in with a provider" and explains that the provider is chosen in a terminal. Clients matching the previous wording will need updating. +- [#1816](https://github.com/PyModel/pythinker-code/pull/1816) [`44f3341`](https://github.com/PyModel/pythinker-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Speed up the embedded key-value engine under stress: queries with skip/limit now stream candidates instead of decoding every match first, LRU eviction picks victims in O(1) instead of scanning every key, bursts of simultaneously expired TTL keys are drained within seconds, existence checks and size counting no longer read values when they only need metadata, and one oversized token can no longer poison the full-text index. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Let a Dynamic Workflow require structured output from its subagents. Passing `output_schema` makes each subagent return a validated object instead of free text, and a subagent that cannot satisfy the schema is reported separately from one that failed outright. +- [#1816](https://github.com/PyModel/pythinker-code/pull/1816) [`44f3341`](https://github.com/PyModel/pythinker-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Cluster readers of the embedded key-value engine now catch up incrementally by replaying only newly appended WAL frames after another process writes, instead of fully reopening the shard on every read; cross-process read latency drops by orders of magnitude at larger shard sizes, and readers still fall back to a full reopen after WAL rotation or truncation. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - `pythinker login` now opens a provider picker instead of going straight to one provider, and accepts `--provider ` to skip it. The VS Code extension's sign-in offers the same providers, and both surfaces present the same thinking-effort levels for a given model. +- [#1816](https://github.com/PyModel/pythinker-code/pull/1816) [`44f3341`](https://github.com/PyModel/pythinker-code/commit/44f334191989183d21920f6867c405581347c748) Thanks [@sailist](https://github.com/sailist)! - Keep the embedded key-value engine writable when a WAL compaction rotation fails mid-way instead of wedging it until reopen, stop a rolled-back write from erasing a concurrently committed value for the same key, let the RESP server survive aborted connections, recover after oversized requests, and answer each pipelined command independently, and keep the previous full-text index intact when a postings rebuild fails. -- [#34](https://github.com/PyModel/pythinker-code/pull/34) [`42da384`](https://github.com/PyModel/pythinker-code/commit/42da384cb36d29ecf0cc147f753790e022e13709) - Make the login platform layer provider-neutral. Model listing, capability derivation and the on-disk config shape are now one set of types shared by every login path, instead of living in a provider-specific module that other providers imported from; the duplicate copies of the capability derivation and the model-info parser are collapsed into one. +- [#1808](https://github.com/PyModel/pythinker-code/pull/1808) [`b53e00d`](https://github.com/PyModel/pythinker-code/commit/b53e00db91872efc602743d07d2283f7938eaea2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Include the underlying network cause (DNS failure, refused connection, TLS or timeout errors) in OAuth connection error messages instead of a bare "fetch failed". - Logging in is an API key, a models.dev catalog provider, or OpenAI Codex OAuth. "Is the user logged in" is now a single predicate over configured providers with a usable credential, shared by the CLI, the VS Code extension and the ACP adapter. `/feedback` opens the issue tracker. +- [#1790](https://github.com/PyModel/pythinker-code/pull/1790) [`373abb0`](https://github.com/PyModel/pythinker-code/commit/373abb02f03ef817e2e1937e1cdc4423ef0cd149) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix repeated request rejections after an interrupted model response by recording tool calls that never ran and closing them with an interrupted result. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Give every Dynamic Workflow run an id and stamp it on the subagent events it produces, so a client can tell which run a given subagent belongs to when several are in flight. +- [#1791](https://github.com/PyModel/pythinker-code/pull/1791) [`3144972`](https://github.com/PyModel/pythinker-code/commit/31449728b72df94e22bcb2de350a1e7624895e30) Thanks [@sailist](https://github.com/sailist)! - Fix the built-in URL fetch tool's network safeguards: crafted domains and redirect chains can no longer reach loopback or internal network services. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Add two ways to rein in Dynamic Workflow fan-out: `disableWorkflows` turns the tool off entirely, and `workflowSizeGuideline` sets an advisory ceiling that is mentioned to the model and warned about, on every surface, when a run exceeds it. Both are settable in config or by environment variable. +- [#1787](https://github.com/PyModel/pythinker-code/pull/1787) [`319001a`](https://github.com/PyModel/pythinker-code/commit/319001ae5cde6df383579214a126564b9ed2b114) Thanks [@sailist](https://github.com/sailist)! - web: Remove per-workspace git repo badges and branch labels; branch, PR, and diff status remain shown for the active session. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Bound subagent fan-out with hard caps: 128 subagents per call, 200 per session, and a nesting depth of 3. Nesting was previously unbounded, so a workflow that spawned workflows could grow without limit; past depth 3 the call now fails instead. +- [#1838](https://github.com/PyModel/pythinker-code/pull/1838) [`9e12484`](https://github.com/PyModel/pythinker-code/commit/9e1248416faa22d9f0b777b91ad092bbf1e19182) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Remember the thinking level per model, fixing an empty and unresponsive thinking picker when the active model does not support a previously stored level. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Replace the Dynamic Workflow progress bar with the two things it can actually know: how many tool calls each agent has made, and how long it has been silent. The old bar pinned every tool-using agent at 75% until it finished, so an agent working hard and one wedged for ten minutes looked identical. A row that goes quiet now turns amber, then red. +- [#1833](https://github.com/PyModel/pythinker-code/pull/1833) [`03021b6`](https://github.com/PyModel/pythinker-code/commit/03021b6db7166c750dd34043edaa85c423d3202f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued messages silently re-sending previously uploaded files when a session is reopened. -- [#30](https://github.com/PyModel/pythinker-code/pull/30) [`463b176`](https://github.com/PyModel/pythinker-code/commit/463b1766a80389fe44cd675bff29b83b3ce6c86b) - Let a Dynamic Workflow run its subagents on a different model than the agent orchestrating them. `DynamicWorkflow` accepts `model` and `effort` for every subagent in the call, and `/workflow model ` sets that model for the session so an expensive orchestrator can hand mechanical work to a cheaper or faster one. +## 0.26.0 + +### Minor Changes + +- [#1776](https://github.com/PyModel/pythinker-code/pull/1776) [`ffaf0b9`](https://github.com/PyModel/pythinker-code/commit/ffaf0b98ca76bb90ba9c989256441dceb468d85f) Thanks [@sailist](https://github.com/sailist)! - Expand the coder subagent tool set to include background tasks, todo lists, plan mode, skill invocation, and nested agents, mirroring the main agent's capabilities; a subagent run also waits for its background tasks to settle before reporting completion. Applies automatically to coder subagents launched through the Agent tool. ### Patch Changes -- [#37](https://github.com/PyModel/pythinker-code/pull/37) [`12069a8`](https://github.com/PyModel/pythinker-code/commit/12069a890144380bff5d648ad51d7411ece94437) - Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. +- [#1771](https://github.com/PyModel/pythinker-code/pull/1771) [`b513975`](https://github.com/PyModel/pythinker-code/commit/b5139757e2df1b5b8723d4bab5137266f5eb0f01) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the unit formatting of the context usage display. + +- [#1765](https://github.com/PyModel/pythinker-code/pull/1765) [`d531398`](https://github.com/PyModel/pythinker-code/commit/d531398d0143cd3b0a2f4a099ff537894c9245e9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Pythinker-provider models routed through the Anthropic protocol incorrectly showing reasoning effort options. Effort choices now come only from the model's declared metadata, and the inferred fallback profile applies solely to non-Pythinker Anthropic-compatible providers. + +- [#1774](https://github.com/PyModel/pythinker-code/pull/1774) [`3d5d630`](https://github.com/PyModel/pythinker-code/commit/3d5d630c12ea71fb7066e8018dfa2cb6d42da3e8) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor an explicit thinking "off" on OpenAI-compatible (chat completions) providers: it used to be indistinguishable from "never configured", so the history-based auto `reasoning_effort` injection kept the model reasoning (and could leak the field to models that reject it). The provider now also reports the actual current thinking effort ("on"/"off") instead of recording "off" for both. + +- [#1766](https://github.com/PyModel/pythinker-code/pull/1766) [`7042af3`](https://github.com/PyModel/pythinker-code/commit/7042af3571dbfbf5600535a56692434b84afb4ce) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the sidebar resize handle being covered by the chat composer background. + +- [#1769](https://github.com/PyModel/pythinker-code/pull/1769) [`d1ca65e`](https://github.com/PyModel/pythinker-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Keep legacy migrations idempotent across multiple Pythinker homes and report damaged or unmapped sessions instead of silently skipping them. + +- [#1763](https://github.com/PyModel/pythinker-code/pull/1763) [`81414b6`](https://github.com/PyModel/pythinker-code/commit/81414b6ad5eddb64bbd959a8754ffb6c20b4f6fe) Thanks [@liruifengv](https://github.com/liruifengv)! - Warn in the /model and /effort pickers that switching invalidates the existing prompt cache, and hint to use /new to avoid extra token costs. + +- [#1773](https://github.com/PyModel/pythinker-code/pull/1773) [`1169a6d`](https://github.com/PyModel/pythinker-code/commit/1169a6d5fdafca4c1455c3ac4889586ef42f4435) Thanks [@RealKai42](https://github.com/RealKai42)! - Replay empty thinking content verbatim instead of substituting a placeholder space on Anthropic-compatible and Pythinker preserved-thinking endpoints. + +- [#1781](https://github.com/PyModel/pythinker-code/pull/1781) [`09e8554`](https://github.com/PyModel/pythinker-code/commit/09e855401be62431b967dcb3b7caf1bcc9705df5) Thanks [@kermanx](https://github.com/kermanx)! - Report when users stop tasks and preserve other stop reasons in model context. + +- [#1784](https://github.com/PyModel/pythinker-code/pull/1784) [`d465591`](https://github.com/PyModel/pythinker-code/commit/d465591eb3fdb30c0c0348d6edb6f4d3d2f72698) Thanks [@sailist](https://github.com/sailist)! - Fix a resumed session being marked as just updated and jumping to the top of the session list without any new activity. + +- [#1759](https://github.com/PyModel/pythinker-code/pull/1759) [`9e3e670`](https://github.com/PyModel/pythinker-code/commit/9e3e6700f9276f4ab60219897b297fc96be2355a) Thanks [@sailist](https://github.com/sailist)! - Fix a race where resuming a background subagent right after it was manually stopped could fail with an "already running" error. -- [#38](https://github.com/PyModel/pythinker-code/pull/38) [`44efbc7`](https://github.com/PyModel/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. +- [#1782](https://github.com/PyModel/pythinker-code/pull/1782) [`072eed4`](https://github.com/PyModel/pythinker-code/commit/072eed476b5fe7599d994783649a21083320df58) Thanks [@sailist](https://github.com/sailist)! - Fix the context size indicator under-reporting the model's actual context usage. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Survive two malformed inputs that used to end a run. A catalog entry that is not an object is now dropped when the catalog is read, instead of reaching the provider picker and throwing past the bundled-catalog fallback that was meant to save the login. A non-finite subagent concurrency limit now falls back to the default: `NaN` passed every clamp, and each free-slot test against it was false, so the batch launched nothing and never finished. +- [#1769](https://github.com/PyModel/pythinker-code/pull/1769) [`d1ca65e`](https://github.com/PyModel/pythinker-code/commit/d1ca65e1de189617e9edbc54010e62d472a1de3d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Support in-process editor hosts with session lifecycle, context, MCP configuration, and cross-platform session storage APIs. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Offer a model's declared thinking-effort levels when signing in to OpenAI Codex. The picker previously fell back to low / medium / high regardless of what the model supports, disagreeing with the effort list recorded in the config it then wrote. +- [#1772](https://github.com/PyModel/pythinker-code/pull/1772) [`78967e2`](https://github.com/PyModel/pythinker-code/commit/78967e283d28238337e6437b4824f67b2c3cea7d) Thanks [@sailist](https://github.com/sailist)! - web: Refresh the model catalog for all providers when opening the model picker, so newly available models always show up. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Accept a provider's plain id for `--provider` at login, so a catalog provider no longer has to be named by its full display name, and stop a cancelled OpenAI Codex sign-in from holding the process open for the rest of its two-minute callback timeout. In the editor extension, signing in now shows one cancellable progress notification, a repeated sign-in joins the one already running instead of opening a second set of prompts, and a completed sign-in is no longer reported as failed when the status refresh behind it fails. +## 0.25.0 -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing `low`, `medium`, or `xhigh` reopened the session at `high`, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. +### Minor Changes + +- [#1731](https://github.com/PyModel/pythinker-code/pull/1731) [`0b790cd`](https://github.com/PyModel/pythinker-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Allow attaching any file type in chat; files the model cannot consume inline (documents, SVG images, archives, …) are uploaded to the server and given to the model as a file path it can read on demand. + +### Patch Changes -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Write the thinking effort picked at login to disk. The apply step recorded the level, but the patch that saved the result listed everything except it, so an API-key login still reopened at the default effort. Choosing `off` now also clears a level a previous login left behind, which a patch that only merges could not do by omitting the key. +- [#1746](https://github.com/PyModel/pythinker-code/pull/1746) [`918c135`](https://github.com/PyModel/pythinker-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Honor adaptive_thinking = false on Anthropic-compatible models by limiting thinking efforts to the legacy budget set and omitting the effort parameter from requests. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Keep the configured provider signed in when a login is abandoned. Backing out at the model picker, or a failure while fetching the model list, no longer clears the existing credentials, and dismissing the provider picker returns to the sign-in screen instead of reporting a failed login. +- [#1746](https://github.com/PyModel/pythinker-code/pull/1746) [`918c135`](https://github.com/PyModel/pythinker-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Apply official Anthropic effort profiles and a 128k output fallback for unknown models. Preserve compatible-provider thinking history across session resumes and model switches, normalize incomplete stream events, and warn on unlisted efforts. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Refuse a device authorization whose verification URL is not HTTPS. Every surface hands that URL to the host's "open externally" API, so a provider answering with `file:`, `javascript:`, or an installed application's own scheme had the agent launch it. The check runs where the response is parsed, so the terminal, the TUI, and the editor extension are all covered. +- [#1746](https://github.com/PyModel/pythinker-code/pull/1746) [`918c135`](https://github.com/PyModel/pythinker-code/commit/918c1354d9ff4a7dc66a02ede3a504d19e1f53d1) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix custom-named models on Anthropic-compatible providers starting new sessions with thinking effort off instead of the model default, and not showing the thinking control in ACP clients. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Finish handling blank Dynamic Workflow items. A run that dropped one reported its results after a note explaining the drop, which made the whole result parse as unsupported and rendered a successful run as failed; the note now follows the results. A blank entry also no longer leaves a row queued forever with the header stuck below its total, and no longer pushes a full item list over the subagent cap and back into whole-call rejection. +- [#1757](https://github.com/PyModel/pythinker-code/pull/1757) [`f0c8a10`](https://github.com/PyModel/pythinker-code/commit/f0c8a103c620b4a66761c0f34c1a8cc7ece9b86c) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the diagnostic log missing the actual error when the CLI exits unexpectedly. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Ignore empty entries in a Dynamic Workflow's item list instead of rejecting the call. A trailing empty item used to fail argument validation, which discarded the whole workflow before any subagent started and forced the agent to send every prompt again. The dropped count is now reported with the results, and the launch panel counts only the subagents that will actually run. +- [#1731](https://github.com/PyModel/pythinker-code/pull/1731) [`0b790cd`](https://github.com/PyModel/pythinker-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - Fix the Content-Security-Policy on non-loopback server binds blocking the web UI's theme bootstrap script and bundled fonts, and tighten the policy with explicit form-action, base-uri, and frame-ancestors directives. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Stop a Dynamic Workflow row that has not started from reading as stalled. A queued row measured its silence from the launch of the whole run, so a long queue turned every waiting row amber and then red while nothing was wrong. A queued row now shows the same placeholder a finished one does, and a suspended row keeps its count without the alarm colours, because only a running row can stall. +- [#1758](https://github.com/PyModel/pythinker-code/pull/1758) [`1d7c205`](https://github.com/PyModel/pythinker-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix the CLI exiting unexpectedly when reading an image from the clipboard fails; it now falls back to pasting text. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Keep a Dynamic Workflow subagent's output schema when a provider rate limit forces its turn to be retried. The retried turn lost the schema, so the subagent answered in prose and the workflow reported it as completed rather than as a schema failure. +- [#1753](https://github.com/PyModel/pythinker-code/pull/1753) [`d8ddabb`](https://github.com/PyModel/pythinker-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the web server bearer-token check being bypassed by percent-encoded API paths (e.g. `/%61pi/v1/…`), which allowed unauthenticated access to every API route. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Show a Dynamic Workflow's running rows with a spinning grey dot, so a working agent reads as motion rather than as a static dot the eye cannot tell from a finished one, and shimmer the Orchestrating label in periwinkle instead of grey. +- [#1758](https://github.com/PyModel/pythinker-code/pull/1758) [`1d7c205`](https://github.com/PyModel/pythinker-code/commit/1d7c205e8397983d3d79e59704db3f67a0c72937) Thanks [@RealKai42](https://github.com/RealKai42)! - Report crash telemetry for unhandled promise rejections, so exits they cause are no longer invisible. -- [#32](https://github.com/PyModel/pythinker-code/pull/32) [`a504a82`](https://github.com/PyModel/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Show the whole large-workflow warning in the editor extension. The line was truncated to the panel width, so in a narrow side panel the reader saw the opening words and no reason. +- [#1753](https://github.com/PyModel/pythinker-code/pull/1753) [`d8ddabb`](https://github.com/PyModel/pythinker-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix the session filesystem API following symlinks that point outside the workspace, which allowed reading, listing, creating, and downloading host files beyond the session directory through a planted symlink. -- [#28](https://github.com/PyModel/pythinker-code/pull/28) [`cf5b6b1`](https://github.com/PyModel/pythinker-code/commit/cf5b6b16e999431bd1a8f511c09883c330fc569d) - Keep a subagent on the model and effort its profile assigns when the subagent is resumed or retried, instead of reverting it to the main agent's model. +- [#1753](https://github.com/PyModel/pythinker-code/pull/1753) [`d8ddabb`](https://github.com/PyModel/pythinker-code/commit/d8ddabb605c1f6fdcfa9fade8cc09b5f8c93651f) Thanks [@sailist](https://github.com/sailist)! - Fix sessions failing to be created when the workspace directory is given through a symlink, which the v2 engine rejected as "not a directory". -- [#31](https://github.com/PyModel/pythinker-code/pull/31) [`e5e9de4`](https://github.com/PyModel/pythinker-code/commit/e5e9de46f0f51be6f3ab3d03d59a5841779c2215) - Brighten the periwinkle accent in the VS Code extension's dark theme so inline code in chat is easier to read. +- [#1754](https://github.com/PyModel/pythinker-code/pull/1754) [`1186686`](https://github.com/PyModel/pythinker-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix completed background subagents losing their final output after a session reload, and retry the output backfill when a transient fetch failure occurs. -- [#31](https://github.com/PyModel/pythinker-code/pull/31) [`e5e9de4`](https://github.com/PyModel/pythinker-code/commit/e5e9de46f0f51be6f3ab3d03d59a5841779c2215) - Let `/yolo` and `/auto` be used in the VS Code extension before the first message is sent — the request now applies to the session that chat opens next instead of failing with "Could not change the permission mode." +- [#1755](https://github.com/PyModel/pythinker-code/pull/1755) [`4f99114`](https://github.com/PyModel/pythinker-code/commit/4f99114342da11ebf7a403e3af6e0cf2c8cca431) Thanks [@kermanx](https://github.com/kermanx)! - Move the server's v1 wire schema definitions into the engine domains and the server package, removing the shared schema package from the v2 server stack with no behavior change. -- [#24](https://github.com/PyModel/pythinker-code/pull/24) [`ae01098`](https://github.com/PyModel/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - Fix the Dynamic Workflow card showing `[object Object]`, phantom extra agent rows, and tool labels fused into streamed text when a workflow is called with object items. +- [#1731](https://github.com/PyModel/pythinker-code/pull/1731) [`0b790cd`](https://github.com/PyModel/pythinker-code/commit/0b790cdc056475593abd572f657d010504caf752) Thanks [@sailist](https://github.com/sailist)! - web: Show every attachment a user sends — files, images, and videos — as chips in the message bubble, and let files be attached by dropping them anywhere in the window. -- [#24](https://github.com/PyModel/pythinker-code/pull/24) [`ae01098`](https://github.com/PyModel/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - Show Dynamic Workflow member progress from the observed stage only, so a running subagent no longer sits at 99% for the rest of its run. +- [#1744](https://github.com/PyModel/pythinker-code/pull/1744) [`b89d385`](https://github.com/PyModel/pythinker-code/commit/b89d385fa56915f067d656160086bc3c3126f8a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix Enter not confirming modal confirmation dialogs in dev builds, and keep the dialog open with a loading state until the confirmed action (such as archiving a session) completes. -## 0.9.2 +- [#1756](https://github.com/PyModel/pythinker-code/pull/1756) [`e885aec`](https://github.com/PyModel/pythinker-code/commit/e885aec7ffa9ee62d122908b68837c5e010d5d04) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show full diagnostics for model request failures — a semantic title, the provider's raw message, and expandable details (error code, HTTP status, request ID) with copy support — instead of a bare "Connection error" toast. + +- [#1751](https://github.com/PyModel/pythinker-code/pull/1751) [`df75a0f`](https://github.com/PyModel/pythinker-code/commit/df75a0f5c2f2e2dd3291c8adaba96a832ee1f179) Thanks [@kermanx](https://github.com/kermanx)! - web: Keep session activity indicators in sync with agent work, prevent duplicate streamed content after session activation races or LLM retries, and flush durable session events promptly. + +- [#1754](https://github.com/PyModel/pythinker-code/pull/1754) [`1186686`](https://github.com/PyModel/pythinker-code/commit/11866865544b8ec88330372b8582e97a35113308) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a background subagent showing up as two identical rows in the agents dock panel during streaming. + +## 0.24.2 ### Patch Changes -- [#25](https://github.com/PyModel/pythinker-code/pull/25) [`649ec69`](https://github.com/PyModel/pythinker-code/commit/649ec69f8e039c031248ce939faadf253bee7259) - Let `/yolo` and `/auto` take effect in the VS Code extension while the agent is running, and auto-approve the requests already waiting on screen. +- [#1704](https://github.com/PyModel/pythinker-code/pull/1704) [`38a2363`](https://github.com/PyModel/pythinker-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Align the print-mode run lifecycle across engines: `print_background_mode` and `print_max_turns` now take effect for `pythinker -p` on the experimental engine, with the same exit / drain / steer semantics and defaults as the default engine, and `pythinker -p "/goal ..."` now stays alive until the goal reaches a terminal state instead of exiting after the first turn. -## 0.9.0 +- [#1704](https://github.com/PyModel/pythinker-code/pull/1704) [`38a2363`](https://github.com/PyModel/pythinker-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Align the subagent timeout across engines: a fixed 2-hour default, overridable with `[subagent] timeout_ms` in config.toml or the PYTHINKER_SUBAGENT_TIMEOUT_MS environment variable. + +- [#1727](https://github.com/PyModel/pythinker-code/pull/1727) [`286d3e7`](https://github.com/PyModel/pythinker-code/commit/286d3e7aca40a778cc4136eb377e14f14c70141c) Thanks [@liruifengv](https://github.com/liruifengv)! - Add a builtin `check-pythinker-code-docs` skill that answers Pythinker Code product questions (CLI usage, configuration, membership, error codes) against the official documentation with source links. It triggers automatically on product questions, or run `/check-pythinker-code-docs`. + +- [#1707](https://github.com/PyModel/pythinker-code/pull/1707) [`8490c3e`](https://github.com/PyModel/pythinker-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - Add the number of messages dropped during compaction retries to the session wire log's LLM request traces. + +- [#1740](https://github.com/PyModel/pythinker-code/pull/1740) [`a74ab44`](https://github.com/PyModel/pythinker-code/commit/a74ab44ac7d5656e2dd9cf93b8e484936b05a0c8) Thanks [@sailist](https://github.com/sailist)! - Increase the default per-step LLM retry budget from 3 to 10 attempts, so transient provider failures (429 / overload) are retried with exponential backoff for a few minutes before the turn fails. Tune with `loop_control.max_retries_per_step` in config.toml. + +- [#1707](https://github.com/PyModel/pythinker-code/pull/1707) [`8490c3e`](https://github.com/PyModel/pythinker-code/commit/8490c3e36b6a6cc3ba5c0f15d93b87347ce23878) Thanks [@sailist](https://github.com/sailist)! - Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`, matching the model catalog vocabulary; the `select_tools` tool and the `tool-select` flag are unchanged. + +- [#1698](https://github.com/PyModel/pythinker-code/pull/1698) [`722694a`](https://github.com/PyModel/pythinker-code/commit/722694adf99c53dc608d417ea6d8c90a5712c33f) Thanks [@chengluyu](https://github.com/chengluyu)! - Enforce goal wall-clock budgets while model or tool work is still running. + +- [#1730](https://github.com/PyModel/pythinker-code/pull/1730) [`72f425e`](https://github.com/PyModel/pythinker-code/commit/72f425e18d0264010e1442af67ee8d9acf5f0659) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix tool call id collisions across turns for Gemini-protocol models, which merged separate dynamic_workflow runs into a single card in the web UI. + +- [#1695](https://github.com/PyModel/pythinker-code/pull/1695) [`5c0f17c`](https://github.com/PyModel/pythinker-code/commit/5c0f17cfcf99c27eb697be11ae9b61243d993e4a) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve active goal elapsed time across crash recovery. + +- [#1743](https://github.com/PyModel/pythinker-code/pull/1743) [`481b28b`](https://github.com/PyModel/pythinker-code/commit/481b28b8f4d527c43c640c4d742c52aa006c3bb0) Thanks [@chengluyu](https://github.com/chengluyu)! - Correct the guidance text shown when a goal cannot be paused or resumed. + +- [#1692](https://github.com/PyModel/pythinker-code/pull/1692) [`e53cd79`](https://github.com/PyModel/pythinker-code/commit/e53cd799572db6b2c73f6938703d586b83013cec) Thanks [@chengluyu](https://github.com/chengluyu)! - Allow goals to use every configured turn before the turn budget stops further work. + +- [#1719](https://github.com/PyModel/pythinker-code/pull/1719) [`b24a347`](https://github.com/PyModel/pythinker-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Restore the AgentDynamicWorkflow member list after a page refresh on the v2 backend. + +- [#1704](https://github.com/PyModel/pythinker-code/pull/1704) [`38a2363`](https://github.com/PyModel/pythinker-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Fix sessions created by newer builds failing to open in older CLI builds on the same machine; new sessions are written in a compatible layout, and existing sessions are healed on first open. + +- [#1708](https://github.com/PyModel/pythinker-code/pull/1708) [`ddfdfb0`](https://github.com/PyModel/pythinker-code/commit/ddfdfb0b09b59d95888eca7e9ddb7bb63be5e204) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix sub-agent completions being signaled as session turn completions, which fired premature completion notifications, sounds, and unread markers while the main turn was still running. + +- [#1714](https://github.com/PyModel/pythinker-code/pull/1714) [`20b6972`](https://github.com/PyModel/pythinker-code/commit/20b69724aafc8fb0b56a414988eb762a8b8a3ed1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix code block copy buttons when the web UI is served over plain HTTP. + +- [#1643](https://github.com/PyModel/pythinker-code/pull/1643) [`d8d4e8c`](https://github.com/PyModel/pythinker-code/commit/d8d4e8ceb55d7a5cae7ce9b579996c9ff5601914) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent long streaming responses from stalling after a tab is backgrounded. + +- [#1715](https://github.com/PyModel/pythinker-code/pull/1715) [`de493ae`](https://github.com/PyModel/pythinker-code/commit/de493aeec973623bc0e258d6598f6d9215693a5f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Use an upward chevron for the expand button on minimized plan review and question cards so the icon matches the direction the cards open. + +- [#1641](https://github.com/PyModel/pythinker-code/pull/1641) [`b6ae0a1`](https://github.com/PyModel/pythinker-code/commit/b6ae0a1054635fc71efde61dafa03da8a8b0c4c8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session list loading failures without discarding sessions that are still available. + +- [#1719](https://github.com/PyModel/pythinker-code/pull/1719) [`b24a347`](https://github.com/PyModel/pythinker-code/commit/b24a347e20a3efa7bba948316784a76439ed7cf5) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Expand the AgentDynamicWorkflow card by default while its subagents are still running. + +- [#1693](https://github.com/PyModel/pythinker-code/pull/1693) [`7de218a`](https://github.com/PyModel/pythinker-code/commit/7de218a909d8f3e676ea3c160834090c9f19ca54) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Resume paused goals when you select Resume. + +- [#1700](https://github.com/PyModel/pythinker-code/pull/1700) [`3107f96`](https://github.com/PyModel/pythinker-code/commit/3107f963a532de88d0affd0a08c60749455c5013) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent late activity from replaced goals from changing or consuming the budget of replacement goals. + +- [#1459](https://github.com/PyModel/pythinker-code/pull/1459) [`6eb8e13`](https://github.com/PyModel/pythinker-code/commit/6eb8e13417f28a553b4183f113e5b96eb31e4211) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix mobile safe-area handling, including the composer floating above the on-screen keyboard on iOS, doubled landscape insets, the PWA top bar under the notch, and toasts overlapping the composer as it grows. + +- [#1696](https://github.com/PyModel/pythinker-code/pull/1696) [`b781e8c`](https://github.com/PyModel/pythinker-code/commit/b781e8cbcfac2cd0e73e3b1b79fa3386c632fa5b) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve final status messages when automatic goal continuations reach a budget or report a blocker. + +- [#1722](https://github.com/PyModel/pythinker-code/pull/1722) [`3703d03`](https://github.com/PyModel/pythinker-code/commit/3703d0346e79e42f18b5097f5606e6ef7b0ff2dd) Thanks [@sailist](https://github.com/sailist)! - In print mode (`pythinker -p`), keep the run alive by default while background tasks are pending and feed each completion back to the main agent as a new turn, with an effectively unbounded wait ceiling and turn cap and a 72-hour subagent timeout. Set `print_background_mode = "exit"` (or `"drain"`) to restore the previous exit-after-one-turn behavior. + +- [#1737](https://github.com/PyModel/pythinker-code/pull/1737) [`5d6ff02`](https://github.com/PyModel/pythinker-code/commit/5d6ff022b1a3732cf0b12d1a87497870def52c0c) Thanks [@sailist](https://github.com/sailist)! - In print mode (`pythinker -p`), background Bash tasks and subagents no longer have a timeout by default — they run until they finish or the model stops them, and a foreground Bash command that times out is moved to the background without a new deadline. Interactive defaults are unchanged; tune per mode with `bash_task_timeout_s` under `[background]` or `timeout_ms` under `[subagent]` (`0` = no timeout). + +- [#1697](https://github.com/PyModel/pythinker-code/pull/1697) [`2bf009f`](https://github.com/PyModel/pythinker-code/commit/2bf009fe27d1b0259e90f285e94264a8bf6b5832) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject subagent goal requests consistently instead of starting goals they cannot finish. + +- [#1711](https://github.com/PyModel/pythinker-code/pull/1711) [`9eff230`](https://github.com/PyModel/pythinker-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Log failed requests, WebSocket auth rejections, shutdowns, and key operations (abort, cancel, approvals, config changes) in the web UI server so daemon problems can be diagnosed from its logs. + +- [#1704](https://github.com/PyModel/pythinker-code/pull/1704) [`38a2363`](https://github.com/PyModel/pythinker-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - Fix `pythinker server` reporting the internal server package version instead of the CLI version in its metadata; the web UI settings now show the CLI version. + +- [#1741](https://github.com/PyModel/pythinker-code/pull/1741) [`8a3f1ff`](https://github.com/PyModel/pythinker-code/commit/8a3f1ffa6fbd7855fd0b10d96587afc6b690ebe3) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the session title not being generated when the first message is a skill slash command. + +- [#1694](https://github.com/PyModel/pythinker-code/pull/1694) [`513f374`](https://github.com/PyModel/pythinker-code/commit/513f374aa08bd86b428f62697c1ca12594d533e9) Thanks [@chengluyu](https://github.com/chengluyu)! - Reject malformed persisted goal records during session recovery. + +- [#1704](https://github.com/PyModel/pythinker-code/pull/1704) [`38a2363`](https://github.com/PyModel/pythinker-code/commit/38a2363a006d8ed32ff6100ccff2dc7d1a70b2b0) Thanks [@sailist](https://github.com/sailist)! - web: Show each message's actual send time in chat history after reloading a session, instead of the session creation time. + +- [#1711](https://github.com/PyModel/pythinker-code/pull/1711) [`9eff230`](https://github.com/PyModel/pythinker-code/commit/9eff230f976c6bd8cc757678293276d8dec013d8) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Surface server error details when actions such as stopping a session, archiving, or toggling modes fail, instead of failing silently, and log every operation failure to the console and the exported web log. + +- [#1701](https://github.com/PyModel/pythinker-code/pull/1701) [`07c3632`](https://github.com/PyModel/pythinker-code/commit/07c3632415fa77972c49c39d7171ee5a4790bd01) Thanks [@sailist](https://github.com/sailist)! - Keep the workspace catalog complete and durable: creating a session registers its directory as a workspace, the server backfills missing workspaces from session history at startup, and a removed workspace no longer reappears after a restart. + +## 0.24.1 + +### Patch Changes + +- [#1678](https://github.com/PyModel/pythinker-code/pull/1678) [`ec1c974`](https://github.com/PyModel/pythinker-code/commit/ec1c9748c816d152bf06af2456e82ac35786bba9) Thanks [@chengluyu](https://github.com/chengluyu)! - Preserve goal completion summaries and show untyped LLM errors without an internal error-code prefix in step interruption events. + +- [#1688](https://github.com/PyModel/pythinker-code/pull/1688) [`94c0ef8`](https://github.com/PyModel/pythinker-code/commit/94c0ef89d29ea8532be02828201328fa1281273c) Thanks [@sailist](https://github.com/sailist)! - Fix built-in tools being unavailable when the model provider becomes ready after the session starts. + +- [#1684](https://github.com/PyModel/pythinker-code/pull/1684) [`e417ee7`](https://github.com/PyModel/pythinker-code/commit/e417ee7c2c282f00113dc0e4f4514ca5018b76c9) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Pythinker sessions getting stuck when preserved-thinking history contains an empty reasoning step. + +- [#1673](https://github.com/PyModel/pythinker-code/pull/1673) [`0f64b4d`](https://github.com/PyModel/pythinker-code/commit/0f64b4dcc4f2d295d0039b176d96d8003cb49991) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align thinking-level handling with the CLI: submit the selected level verbatim instead of silently downgrading it, pin the model's catalog default when nothing was chosen, pre-select the target model's default on model switches, and persist explicit picks as the daemon-wide default so new sessions inherit them. + +- [#1689](https://github.com/PyModel/pythinker-code/pull/1689) [`ab22a2a`](https://github.com/PyModel/pythinker-code/commit/ab22a2adf0ca17cbb94f1abdab334ebc58814e8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show just the level name (e.g. Max) in the model pill instead of "thinking: max". + +- [#1625](https://github.com/PyModel/pythinker-code/pull/1625) [`d158e0a`](https://github.com/PyModel/pythinker-code/commit/d158e0a7ac4e432046d56787263dd2dbac40285e) Thanks [@RealKai42](https://github.com/RealKai42)! - Fix Thinking effort routing so non-Pythinker providers preserve configured values for upstream validation, while Pythinker models validate runtime selections, fall back safely during model resolution, and synchronize the effective effort back to clients. + +## 0.24.0 ### Minor Changes -- [#22](https://github.com/PyModel/pythinker-code/pull/22) [`45be822`](https://github.com/PyModel/pythinker-code/commit/45be8227077847f760fa7b6b09333cf5a8127f32) - Name the managed OAuth provider after the platform that serves it. It is reached over `auth.kimi.com` and `api.kimi.com`, but it was registered as `managed:pythinker-code`, which read as a first-party service in a client that talks to several providers. The provider id is now `managed:kimi-code`, its models are aliased `kimi-code/*`, and its credentials are stored under `oauth/kimi-code`. +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Add v2 session export support for packaging diagnostic zip archives. + +- [#1591](https://github.com/PyModel/pythinker-code/pull/1591) [`83e1753`](https://github.com/PyModel/pythinker-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Move foreground Bash commands that hit their timeout to the background instead of killing them, so long-running commands survive the timeout and report back on completion. Set `bash_auto_background_on_timeout = false` under `[background]` in config.toml to restore the kill-on-timeout behavior. + +- [#1617](https://github.com/PyModel/pythinker-code/pull/1617) [`4ec2e7f`](https://github.com/PyModel/pythinker-code/commit/4ec2e7fab14ab89cddf77821082c3ff4911f737b) Thanks [@sailist](https://github.com/sailist)! - Run the local server (`pythinker server run` / `pythinker web`) on the agent-core-v2 engine by default — the `PYTHINKER_CODE_EXPERIMENTAL_FLAG` opt-in is no longer needed, and the legacy v1 server package has been removed. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Port progressive tool disclosure to the new agent engine: MCP tool schemas stay out of the top-level tool list, and the model loads them by name on demand through the announcements plus the select_tools tool, keeping the prompt cache stable. Off by default; set PYTHINKER_CODE_EXPERIMENTAL_TOOL_SELECT=1 to enable. + +- [#1646](https://github.com/PyModel/pythinker-code/pull/1646) [`5eb6217`](https://github.com/PyModel/pythinker-code/commit/5eb62178b3b67d8659788bdf91132469f6588653) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add session diagnostic export to download a session and bounded metadata-only troubleshooting logs as a ZIP. Run `/export` or pick Export session from a session's more menu. Web downloads are limited to 64 MiB. + +### Patch Changes + +- [#1638](https://github.com/PyModel/pythinker-code/pull/1638) [`7c889f3`](https://github.com/PyModel/pythinker-code/commit/7c889f3a960482cc9382203bda55d972b6fb6acd) Thanks [@RealKai42](https://github.com/RealKai42)! - In auto permission mode, plan exits are now marked as auto-approved (not user-reviewed) in both the tool result and the transcript, so the agent no longer treats automatic plan approval as a user signal to start executing. + +- [#1598](https://github.com/PyModel/pythinker-code/pull/1598) [`4feca6b`](https://github.com/PyModel/pythinker-code/commit/4feca6b0738ee0120ab8bea04604b8f467a72e48) Thanks [@kermanx](https://github.com/kermanx)! - web: Recover transient subagent rate limits without surfacing them as session errors. + +- [#1635](https://github.com/PyModel/pythinker-code/pull/1635) [`e49b3b8`](https://github.com/PyModel/pythinker-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Request task-owned work to stop on session close, honoring `background.keep_alive_on_exit` for independent processes and `background.kill_grace_period_ms` before attempting force-stop. + +- [#1629](https://github.com/PyModel/pythinker-code/pull/1629) [`0527ca2`](https://github.com/PyModel/pythinker-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix session fork losing everything except the conversation log: forked sessions now carry over media attachments, plan files, background task output, and cron tasks, and a failed fork no longer leaves a broken half-copy behind. + +- [#1627](https://github.com/PyModel/pythinker-code/pull/1627) [`28e9dd4`](https://github.com/PyModel/pythinker-code/commit/28e9dd4d627f01143b715976cb071e7d16cd2001) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Continue blocked goals after the user resumes them from the goal controls. + +- [#1631](https://github.com/PyModel/pythinker-code/pull/1631) [`2d874fb`](https://github.com/PyModel/pythinker-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Fix a race where a heartbeat write in flight during server shutdown could recreate the instance file right after it was removed. + +- [#1663](https://github.com/PyModel/pythinker-code/pull/1663) [`1294a0e`](https://github.com/PyModel/pythinker-code/commit/1294a0e1ad739151573163505f9c58afb2d543e4) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix OAuth login hanging after browser authorization when the provider configuration changes during sign-in. + +- [#1657](https://github.com/PyModel/pythinker-code/pull/1657) [`32a89c3`](https://github.com/PyModel/pythinker-code/commit/32a89c36432f9aea452a697734102a7956e42e92) Thanks [@RealKai42](https://github.com/RealKai42)! - Prevent oversized image reads from poisoning sessions and recover existing request-too-large failures by removing unsafe media from provider requests. + +- [#1635](https://github.com/PyModel/pythinker-code/pull/1635) [`e49b3b8`](https://github.com/PyModel/pythinker-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Store background task records per agent again, so tasks written by older versions are found on resume and one agent's restore no longer marks another agent's tasks as lost. + +- [#1632](https://github.com/PyModel/pythinker-code/pull/1632) [`a4aae87`](https://github.com/PyModel/pythinker-code/commit/a4aae87cd9a240d3567601ed1a9aefaab540b075) Thanks [@sailist](https://github.com/sailist)! - Fix providers without a configured base_url being rejected: anthropic/openai and other protocol providers now fall back to their official default endpoints again, as before. + +- [#1588](https://github.com/PyModel/pythinker-code/pull/1588) [`2061590`](https://github.com/PyModel/pythinker-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted media being dropped from /skill and plugin command arguments. + +- [#1588](https://github.com/PyModel/pythinker-code/pull/1588) [`2061590`](https://github.com/PyModel/pythinker-code/commit/20615902c2c3776d17c6c334cedec1c8723222b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix pasted images being dropped when steering with Ctrl-S. + +- [#1629](https://github.com/PyModel/pythinker-code/pull/1629) [`0527ca2`](https://github.com/PyModel/pythinker-code/commit/0527ca2267f8cf355d0c158953f3dbfc0c9692ac) Thanks [@sailist](https://github.com/sailist)! - Fix the v2 engine never activating tool-call deduplication: identical tool calls issued in the same step no longer execute multiple times, and repeated identical calls across steps receive escalating reminders again. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a race in the experimental v2 config service that could drop a just-written setting from the config response. + +- [#1614](https://github.com/PyModel/pythinker-code/pull/1614) [`3c0e368`](https://github.com/PyModel/pythinker-code/commit/3c0e368cbdfebff9632cffca3b18365615a146b8) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix a server crash when the first goal-mode prompt is submitted while the v2 agent is still starting. + +- [#1631](https://github.com/PyModel/pythinker-code/pull/1631) [`2d874fb`](https://github.com/PyModel/pythinker-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Surface the provider's actual rejection message instead of a misleading re-login prompt when an OAuth-managed model keeps returning 401 after a token refresh. + +- [#1631](https://github.com/PyModel/pythinker-code/pull/1631) [`2d874fb`](https://github.com/PyModel/pythinker-code/commit/2d874fbd73eb511e4ef4c8d4c88bd47e429580b2) Thanks [@sailist](https://github.com/sailist)! - Rewrite repeated-tool-call reminders to redirect the agent toward a different action instead of prohibiting the call, and treat a dismissed question prompt as no answer rather than the recommended option. + +- [#1636](https://github.com/PyModel/pythinker-code/pull/1636) [`8027fe2`](https://github.com/PyModel/pythinker-code/commit/8027fe291b03fbfce6dc60aa06f8699ad0976ec5) Thanks [@sailist](https://github.com/sailist)! - Make file tools able to reach skill directories outside the working directory in the v2 engine (experimental), and honor --skillsDir in v2 print mode and the server's skillDirs option. + +- [#1630](https://github.com/PyModel/pythinker-code/pull/1630) [`0303b82`](https://github.com/PyModel/pythinker-code/commit/0303b82c3e691836163ecf906febfb6324c81d74) Thanks [@sailist](https://github.com/sailist)! - Fix ReadMediaFile results losing their image rendering after a session reload or resume on the v2 server backend. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix a storage race in the experimental v2 engine that could fail value reads when writes overlap with compaction. + +- [#1601](https://github.com/PyModel/pythinker-code/pull/1601) [`dc309a7`](https://github.com/PyModel/pythinker-code/commit/dc309a7dfb38b6ef885b8ae80be51b49f8486207) Thanks [@kermanx](https://github.com/kermanx)! - web: Fix the context usage indicator dropping to 0 when a session is reopened or the session list reloads (e.g. after a sidebar search) — the cached live usage is now kept instead of the session record's all-zero placeholder. + +- [#1620](https://github.com/PyModel/pythinker-code/pull/1620) [`e91a616`](https://github.com/PyModel/pythinker-code/commit/e91a616f2196ab9ffc69b3fcc0f2015398d86bd4) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix duplicate user message bubbles after a session snapshot resync. + +- [#1672](https://github.com/PyModel/pythinker-code/pull/1672) [`88629ba`](https://github.com/PyModel/pythinker-code/commit/88629bac3add2a8a17ae8288ee4edbdc9313d55a) Thanks [@yicun](https://github.com/yicun)! - web: Fix uploaded and persisted images failing to display on non-loopback server connections. + +- [#1609](https://github.com/PyModel/pythinker-code/pull/1609) [`e223549`](https://github.com/PyModel/pythinker-code/commit/e223549a79c80e442850947c0cf60d58b2d18667) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix a running multi-step turn rendering a duplicated wall of text after the page reconnects or refreshes mid-turn. + +- [#1611](https://github.com/PyModel/pythinker-code/pull/1611) [`32cbd0c`](https://github.com/PyModel/pythinker-code/commit/32cbd0cf6109f4f3f124e6e4ee7c4c87fa344247) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Fix the workspace picker menu sizing too narrowly for its content. + +- [#1635](https://github.com/PyModel/pythinker-code/pull/1635) [`e49b3b8`](https://github.com/PyModel/pythinker-code/commit/e49b3b877750ba5ca0ea80e154549d5b53455575) Thanks [@sailist](https://github.com/sailist)! - Fix possible record loss when resuming sessions whose wire log needs migration, and reject session logs missing the version envelope instead of silently misreading them. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix MCP tools being unavailable on the first turn after session startup. + +- [#1580](https://github.com/PyModel/pythinker-code/pull/1580) [`83370f1`](https://github.com/PyModel/pythinker-code/commit/83370f17ef38770561a421e3b3a15f6244219aa5) Thanks [@wszqkzqk](https://github.com/wszqkzqk)! - Fix bash auto-detection on Windows failing when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64). + +- [#1676](https://github.com/PyModel/pythinker-code/pull/1676) [`d1820ff`](https://github.com/PyModel/pythinker-code/commit/d1820ff0f853689e84b3e9d4c482532c481eb9bd) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve empty model reasoning blocks across providers so multi-step tool calls can continue. + +- [#1669](https://github.com/PyModel/pythinker-code/pull/1669) [`490303d`](https://github.com/PyModel/pythinker-code/commit/490303db16ed374eae20572e4c6f9880db911547) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Refine goal mode controls with animated strip interactions, budget-aware progress, and design-system cancellation confirmation. + +- [#1597](https://github.com/PyModel/pythinker-code/pull/1597) [`d601847`](https://github.com/PyModel/pythinker-code/commit/d601847f22366b041d949d7c9f7857471be8970c) Thanks [@7Sageer](https://github.com/7Sageer)! - Send the pythinker-code-cli User-Agent on provider registry (api.json) and model catalog fetches, so registries can identify the client version. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Fix approval and question prompts not appearing in real time for web clients connected to the v2 server; they previously only showed up after a page refresh. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Log a warning when a skill fails to parse instead of silently dropping it, and fix the skill catalog so scanned skill roots and policy-skipped skills are actually reported. + +- [#1589](https://github.com/PyModel/pythinker-code/pull/1589) [`f338fcd`](https://github.com/PyModel/pythinker-code/commit/f338fcdac4fa8d4235c44310953e5d512f6549fb) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the AgentDynamicWorkflow member list disappearing after a page refresh while subagents are still running. + +- [#1591](https://github.com/PyModel/pythinker-code/pull/1591) [`83e1753`](https://github.com/PyModel/pythinker-code/commit/83e175399f4dc3dfc3bb478543ff5897a24dfa3d) Thanks [@liruifengv](https://github.com/liruifengv)! - Optimize the TaskOutput tool prompts to discourage blocking waits on background tasks. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Enforce a typed registry for v2 engine telemetry events and redact URLs, tokens, and file paths from outgoing telemetry properties. + +- [#1624](https://github.com/PyModel/pythinker-code/pull/1624) [`3215129`](https://github.com/PyModel/pythinker-code/commit/321512986099037acb4b2677d4455db316d27b50) Thanks [@kermanx](https://github.com/kermanx)! - Fix the experimental v2 engine crashing when the first prompt is sent right after a new conversation is created (for example sending /goal on the web's new-conversation page): agent creation now joins the in-flight bootstrap instead of failing, and the v2 agent lifecycle is split into focused existence, sub-agent, and session MCP domains. + +- [#1637](https://github.com/PyModel/pythinker-code/pull/1637) [`0e0a6e9`](https://github.com/PyModel/pythinker-code/commit/0e0a6e9a5170c28c5e6809c1b2cf6d6f8904de73) Thanks [@sailist](https://github.com/sailist)! - Support caller-supplied MCP server configs on session create in the v2 engine (experimental), merged over the file config and under plugin servers. + +- [#1626](https://github.com/PyModel/pythinker-code/pull/1626) [`1c85f94`](https://github.com/PyModel/pythinker-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: expose the prompt scheduler over /api/v2 for native clients, and add an experimental fault-injection service (PYTHINKER_CODE_EXPERIMENTAL_FAULT_INJECTION) that arms a one-shot provider failure so the media-degraded / media-stripped recovery resends can be exercised end-to-end. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Introduce a graded error taxonomy for the v2 engine's filesystem, storage, and wire layers, translating raw OS and parse failures into specific error codes instead of generic internal errors. - This is a breaking change for an existing config: the previous entries are not rewritten, so run `pythinker login` once to provision the managed provider under its current name, then remove the stale `managed:pythinker-code` entry. +- [#1626](https://github.com/PyModel/pythinker-code/pull/1626) [`1c85f94`](https://github.com/PyModel/pythinker-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: block unsupported image formats (AVIF, HEIC, BMP, TIFF, ICO) at every ingestion point so they can no longer poison session history, and auto-recover provider image-format rejections with a media-stripped resend. -- [#22](https://github.com/PyModel/pythinker-code/pull/22) [`45be822`](https://github.com/PyModel/pythinker-code/commit/45be8227077847f760fa7b6b09333cf5a8127f32) - Resolve a workspace's skills without opening a session, so an editor panel can list them before its first message. +- [#1626](https://github.com/PyModel/pythinker-code/pull/1626) [`1c85f94`](https://github.com/PyModel/pythinker-code/commit/1c85f94472ead2746ad6860ec0e09f4384dd95ec) Thanks [@sailist](https://github.com/sailist)! - v2 engine: recover image-heavy sessions from provider request-size rejections (HTTP 413) by resending with older media degraded to text markers, re-encode oversized WebP images instead of passing them through, and keep downscaled PNGs readable by switching to JPEG below 1000px. + +- [#1613](https://github.com/PyModel/pythinker-code/pull/1613) [`b2daa40`](https://github.com/PyModel/pythinker-code/commit/b2daa405f075cb6847c0a313809b1bcac750b611) Thanks [@7Sageer](https://github.com/7Sageer)! - Support the `services.pymodel_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider. + +- [#1590](https://github.com/PyModel/pythinker-code/pull/1590) [`8a4ee05`](https://github.com/PyModel/pythinker-code/commit/8a4ee05951ebe4f804fd1fb0989aaf44b3b7a3ed) Thanks [@sailist](https://github.com/sailist)! - Fix bash auto-detection on Windows in the experimental v2 engine when git comes from a native MSYS2 toolchain (ucrt64/clang64/clangarm64). + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Send the CLI identity headers (User-Agent and device identity) with outbound requests from the experimental v2 server, matching direct CLI runs. + +- [#1593](https://github.com/PyModel/pythinker-code/pull/1593) [`2185237`](https://github.com/PyModel/pythinker-code/commit/2185237c2f5c5fb3cc6b44c01ac158c6e2b81fe6) Thanks [@kermanx](https://github.com/kermanx)! - Declare v2 engine wire op payloads with required zod schemas and derive their types from the schemas, with ops declared on their models and every op type registered for replay classification. + +- [#1441](https://github.com/PyModel/pythinker-code/pull/1441) [`ceb158d`](https://github.com/PyModel/pythinker-code/commit/ceb158dc54586f254819edbc83c27e21dca1ecf6) Thanks [@sailist](https://github.com/sailist)! - Keep sessions from the new agent engine compatible with existing transcript replay. + +- [#1592](https://github.com/PyModel/pythinker-code/pull/1592) [`924d5c9`](https://github.com/PyModel/pythinker-code/commit/924d5c914143d178020c2dddc56906ce15088680) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show the connected backend engine (v1 / v2) in Settings, and add a dev-mode backend pill next to the sidebar brand that can switch the dev proxy between the two engines at runtime. + +- [#1606](https://github.com/PyModel/pythinker-code/pull/1606) [`2da45fc`](https://github.com/PyModel/pythinker-code/commit/2da45fc419cf5285a9353df8690bba444037ffe4) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix the goal card disappearing after a page refresh while a session goal is active. + +- [#1587](https://github.com/PyModel/pythinker-code/pull/1587) [`49a8c84`](https://github.com/PyModel/pythinker-code/commit/49a8c84a493610c2b2cc2c7da0a8ec0261d876db) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow up to 1040px with each column capped at 700px so long cell content wraps, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table. + +## 0.23.6 ### Patch Changes -- [#22](https://github.com/PyModel/pythinker-code/pull/22) [`45be822`](https://github.com/PyModel/pythinker-code/commit/45be8227077847f760fa7b6b09333cf5a8127f32) - Add an SDK routine that imports a catalog provider and its models into the persisted config, and use it for the CLI provider import so both entry points preserve existing defaults the same way. +- [#1550](https://github.com/PyModel/pythinker-code/pull/1550) [`f17a6ec`](https://github.com/PyModel/pythinker-code/commit/f17a6ecb52907ffabf67a26de65df89572ac515a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Treat a dismissed question prompt as the user choosing not to answer, instead of implicitly selecting the recommended option. + +- [#1488](https://github.com/PyModel/pythinker-code/pull/1488) [`7bd29ab`](https://github.com/PyModel/pythinker-code/commit/7bd29ab0117a1c15691404f411fd67f511bbb897) Thanks [@starquakee](https://github.com/starquakee)! - Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`. + +- [#1497](https://github.com/PyModel/pythinker-code/pull/1497) [`c6e02da`](https://github.com/PyModel/pythinker-code/commit/c6e02daf421b47e8451e60ed4d7b3847a895d00b) Thanks [@sailist](https://github.com/sailist)! - Add a print-mode background policy that lets `pythinker -p` stay alive across background-task completions so the main agent can be steered into follow-up turns. Set `[background].print_background_mode = "steer"` to enable it. + +- [#1555](https://github.com/PyModel/pythinker-code/pull/1555) [`2f97917`](https://github.com/PyModel/pythinker-code/commit/2f97917bb5edc8bdb9837724e57a88f5c0e1f2bd) Thanks [@sailist](https://github.com/sailist)! - Keep `pythinker -p` runs alive after a turn ends while a goal is still active or a cron task is pending, so goal continuations and cron fires run their turns instead of being cut off when the main turn finishes. + +- [#1564](https://github.com/PyModel/pythinker-code/pull/1564) [`cc03816`](https://github.com/PyModel/pythinker-code/commit/cc03816ee0a89b272c1ab87ca43ed246833f0453) Thanks [@sailist](https://github.com/sailist)! - Recognize the support_efforts and default_effort fields when importing a custom registry, so thinking effort levels are available for those models. + +- [#1562](https://github.com/PyModel/pythinker-code/pull/1562) [`faefad0`](https://github.com/PyModel/pythinker-code/commit/faefad0e290ceacb89851baa42043c8685b08dc9) Thanks [@sailist](https://github.com/sailist)! - Add a `subagent.timeout_ms` config option to control how long a single subagent may run before timing out, and raise the default from 30 minutes to 2 hours. Set `[subagent] timeout_ms` in config.toml (or the `PYTHINKER_SUBAGENT_TIMEOUT_MS` env var) to adjust it. + +- [#1572](https://github.com/PyModel/pythinker-code/pull/1572) [`3a7aad6`](https://github.com/PyModel/pythinker-code/commit/3a7aad653f1226ce4e2c7103318471f65154c406) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sessions getting stuck in a sending state after a reconnect, so the working spinner stops and the next message sends normally once a turn finishes while the connection is down. + +- [#1574](https://github.com/PyModel/pythinker-code/pull/1574) [`b1942bd`](https://github.com/PyModel/pythinker-code/commit/b1942bd5718c46991ba5021b4ae96dbf2458617c) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the first visit after starting or updating the web UI bouncing to the login page when the initial auth check fails; the connecting screen now stays up, shows the connection error, and retries until the server answers. -- [#22](https://github.com/PyModel/pythinker-code/pull/22) [`45be822`](https://github.com/PyModel/pythinker-code/commit/45be8227077847f760fa7b6b09333cf5a8127f32) - Stop the fixed-layout TUI anchoring its first frames to the shell cursor, which pushed the panel border into scrollback. +- [#1553](https://github.com/PyModel/pythinker-code/pull/1553) [`264525e`](https://github.com/PyModel/pythinker-code/commit/264525eb51f87409a8961bcf3f5f0271ab767c49) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the chat view jumping downward while scrolling through conversation history. -## 0.8.1 +- [#1565](https://github.com/PyModel/pythinker-code/pull/1565) [`1d3dba5`](https://github.com/PyModel/pythinker-code/commit/1d3dba56832b69628f3bb22ce240f38a08f0af3a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the model dropdown showing checkmarks on same-named models from other providers; the current model is now matched by its unique model id. + +- [#1567](https://github.com/PyModel/pythinker-code/pull/1567) [`f901b9e`](https://github.com/PyModel/pythinker-code/commit/f901b9e1da9a9575b5d47dba40babe2ccd035180) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the server access token for up to 7 days across tab close and browser restarts, instead of asking for it again with every new tab. + +- [#1552](https://github.com/PyModel/pythinker-code/pull/1552) [`37bb4b8`](https://github.com/PyModel/pythinker-code/commit/37bb4b870edf6a5458dda755a5b4a432c32df2a7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix ReadMediaFile results rendering as plain tool cards instead of images after resuming or reloading a session. + +- [#1563](https://github.com/PyModel/pythinker-code/pull/1563) [`c982386`](https://github.com/PyModel/pythinker-code/commit/c98238699c1ae51a2237969b43282373fc0c0e89) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix sidebar lag with many sessions by removing repeated session list scans during rendering. + +- [#1475](https://github.com/PyModel/pythinker-code/pull/1475) [`5a208cb`](https://github.com/PyModel/pythinker-code/commit/5a208cb041530e320f343a46e231bf3c109e30c9) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Auto-enable the default thinking effort when switching to a model that supports effort levels in the web UI. + +- [#1577](https://github.com/PyModel/pythinker-code/pull/1577) [`6fc1deb`](https://github.com/PyModel/pythinker-code/commit/6fc1deb45312574a9e97ffaf6d7ced530d38910d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables in the desktop chat grow beyond the reading column up to 1040px, temporarily hiding the conversation outline while a table passes under it; anything wider still scrolls inside the table. + +- [#1575](https://github.com/PyModel/pythinker-code/pull/1575) [`9d96b53`](https://github.com/PyModel/pythinker-code/commit/9d96b538bf4311fdf07aa262a1b4141f7bdd83ed) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Let wide Markdown tables scroll horizontally inside the table instead of being squeezed into the reading column. + +- [#1556](https://github.com/PyModel/pythinker-code/pull/1556) [`d2c2c33`](https://github.com/PyModel/pythinker-code/commit/d2c2c33f3e89c7c9ed06aa7c2376b88b6107e41d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add workspaces by typing an absolute path directly in the workspace picker's search box, with live validation and completion suggestions. + +- [#1547](https://github.com/PyModel/pythinker-code/pull/1547) [`19c5aa6`](https://github.com/PyModel/pythinker-code/commit/19c5aa64ebef86925ad58074ebcac6a5a7a8ff8d) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Update the WebBridge install page link opened from the /plugins panel. + +## 0.23.5 ### Patch Changes -- [`b5b7b97`](https://github.com/PyModel/pythinker-code/commit/b5b7b976df2a5fddd28a15ad034a2bd7cc8babb7) - Fix the native install script exiting immediately without installing anything when run the documented way, `curl -fsSL … | bash`, which also broke automatic background updates for native installs. +- [#1542](https://github.com/PyModel/pythinker-code/pull/1542) [`f80b2ea`](https://github.com/PyModel/pythinker-code/commit/f80b2eaf04925ce920f693fc8d4d81cb00e825d7) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn. -- [`d7a5db0`](https://github.com/PyModel/pythinker-code/commit/d7a5db02c668ec88f94cb3ccbdb7f314d8a28791) - Report why an automatic update failed instead of failing silently: the installer's error output is now recorded and shown on the next update prompt, native installs on macOS and Linux pin the version the rollout picked, and update messages tell you to open a new terminal to apply the update. +- [#1535](https://github.com/PyModel/pythinker-code/pull/1535) [`04041eb`](https://github.com/PyModel/pythinker-code/commit/04041eb998b6798898fa5df97f7587b3aa119b27) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Hide the internal image-compression note so it no longer renders as user message text. -## 0.8.0 +- [#1536](https://github.com/PyModel/pythinker-code/pull/1536) [`db61c9e`](https://github.com/PyModel/pythinker-code/commit/db61c9e2dddcb6c35b019ef7f374385248ece881) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop unsupported image formats (AVIF, BMP, TIFF, ICO, …) from breaking sessions at every entry point — including remote image URLs and images mislabeled by a tool — and recover an already-stuck session by dropping the offending image and retrying, so one such image can no longer make every later request fail. -### Minor Changes +- [#1530](https://github.com/PyModel/pythinker-code/pull/1530) [`9f66ec4`](https://github.com/PyModel/pythinker-code/commit/9f66ec416cc658842dc1414b79b6447d1b4cc7f9) Thanks [@sailist](https://github.com/sailist)! - Retry provider 429, overload, and other transient errors more reliably, honoring the server Retry-After delay, and surface retries in `-p --output-format stream-json`. + +## 0.23.4 + +### Patch Changes + +- [#1501](https://github.com/PyModel/pythinker-code/pull/1501) [`b91099e`](https://github.com/PyModel/pythinker-code/commit/b91099ed7a2590d1afa4d6e3675671da52b7661c) Thanks [@liruifengv](https://github.com/liruifengv)! - Display Extra Usage (fuel pack) balance in `/usage` and `/status` commands. + +- [#1517](https://github.com/PyModel/pythinker-code/pull/1517) [`173bdfd`](https://github.com/PyModel/pythinker-code/commit/173bdfdab1f484ed79927aeaac7dc8116d3fd346) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix resuming sessions whose original working directory no longer exists. -- [`23e0bc7`](https://github.com/PyModel/pythinker-code/commit/23e0bc7ed62e718cee0b709ab0ab483ef6b87708) - Improve performance and fix bugs. +- [#1516](https://github.com/PyModel/pythinker-code/pull/1516) [`9fb1915`](https://github.com/PyModel/pythinker-code/commit/9fb19154accf6b6f7abfbf7a9820ccda517bc87e) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix prompt-mode goals so they run until completion and report invalid goal commands before sending prompts. -- [`c0f0976`](https://github.com/PyModel/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Remove the Pythinker Datasource plugin from the marketplace; its data gateway backend is not available, so every datasource query failed. +- [#1508](https://github.com/PyModel/pythinker-code/pull/1508) [`1bf2c9a`](https://github.com/PyModel/pythinker-code/commit/1bf2c9afee4643fbf6755f0b92fd60aa14240501) Thanks [@RealKai42](https://github.com/RealKai42)! - Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `PYTHINKER_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session. -- [`c0f0976`](https://github.com/PyModel/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Enable automatic updates for native installs on Windows: `/update` now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. +- [#1519](https://github.com/PyModel/pythinker-code/pull/1519) [`170ae44`](https://github.com/PyModel/pythinker-code/commit/170ae4420526b6592d696cd597d1693dbd1a660b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Polish the session sidebar layout, colors, icons, and typography. + +- [#1521](https://github.com/PyModel/pythinker-code/pull/1521) [`046b6c4`](https://github.com/PyModel/pythinker-code/commit/046b6c417581792933732c7ffe154e120c96171d) Thanks [@RealKai42](https://github.com/RealKai42)! - The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression. + +- [#1494](https://github.com/PyModel/pythinker-code/pull/1494) [`a354803`](https://github.com/PyModel/pythinker-code/commit/a3548035a8b6d25df9a11daab37a21daee1ef73f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add a Pythinker WebBridge entry to the Official tab of the /plugins panel that opens the WebBridge install page in your browser. + +- [#1479](https://github.com/PyModel/pythinker-code/pull/1479) [`735922c`](https://github.com/PyModel/pythinker-code/commit/735922c291ec3d32d60da6af053f75e1c6179f92) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Add notifications when a tool needs approval, and improve notification reliability. + +- [#1522](https://github.com/PyModel/pythinker-code/pull/1522) [`ec8dc34`](https://github.com/PyModel/pythinker-code/commit/ec8dc3456c1696a5eba6c37b6e26ef99837c35e2) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix an occasional "another turn is active" error when sending the first message of a new conversation, and show a starting state while it is being sent. + +- [#1502](https://github.com/PyModel/pythinker-code/pull/1502) [`ad30a1c`](https://github.com/PyModel/pythinker-code/commit/ad30a1c6328327729221f9f5fc700b621dfef779) Thanks [@chengluyu](https://github.com/chengluyu)! - web: Polish the chat UI with Inter typography, localized labels, and tighter sidebar, composer, and menu styling. + +## 0.23.3 ### Patch Changes -- [`c0f0976`](https://github.com/PyModel/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Fix Kimi and Moonshot models rejecting every request with an invalid tool schema error when a tool declares `anyOf` alongside its own type or properties. +- [#1506](https://github.com/PyModel/pythinker-code/pull/1506) [`e83511a`](https://github.com/PyModel/pythinker-code/commit/e83511a7118652a67676bbcfd41148907ad7b8de) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix a misleading "OAuth login expired" message shown when a model is not available for the current account. -## 0.7.0 +## 0.23.2 + +### Patch Changes + +- [#1489](https://github.com/PyModel/pythinker-code/pull/1489) [`2206d21`](https://github.com/PyModel/pythinker-code/commit/2206d21327129aa2331b6b159cfce61110b7f94f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Add the Vercel plugin to the bundled plugin marketplace. Run /plugins and select Vercel Plugin to install it. + +- [#1477](https://github.com/PyModel/pythinker-code/pull/1477) [`150206a`](https://github.com/PyModel/pythinker-code/commit/150206a6f7027879df954e26736b4baa5d336235) Thanks [@chengluyu](https://github.com/chengluyu)! - Count the turn that starts an autonomous goal toward its goal turn usage. + +- [#1483](https://github.com/PyModel/pythinker-code/pull/1483) [`f30781b`](https://github.com/PyModel/pythinker-code/commit/f30781bb273321f3e3bbb548a9d0724ab6299fc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Fix `pythinker -p` runs exiting with code 0 when a turn fails. + +- [#1466](https://github.com/PyModel/pythinker-code/pull/1466) [`063bce2`](https://github.com/PyModel/pythinker-code/commit/063bce2a2f52601abaa0d13173ab88371cbbe9ae) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix console windows flashing on Windows each time a hook runs. + +- [#1474](https://github.com/PyModel/pythinker-code/pull/1474) [`11c6a37`](https://github.com/PyModel/pythinker-code/commit/11c6a37ce030f8e64de5c810da07ec7fab3b0615) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the connection error toast lingering after the WebSocket reconnects when returning from the background. + +- [#1476](https://github.com/PyModel/pythinker-code/pull/1476) [`d1a964f`](https://github.com/PyModel/pythinker-code/commit/d1a964fba9b3dca902ea6f81bacaccc839955c03) Thanks [@chengluyu](https://github.com/chengluyu)! - Prevent autonomous goals from being paused by model-reported status updates. + +- [#1481](https://github.com/PyModel/pythinker-code/pull/1481) [`1317000`](https://github.com/PyModel/pythinker-code/commit/131700097a732b97b3d17c5e2efa1c5a44b013ef) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance for blocked and complete status updates. + +- [#1460](https://github.com/PyModel/pythinker-code/pull/1460) [`474ce28`](https://github.com/PyModel/pythinker-code/commit/474ce289dd39aa42d1a77a9a2e15531aee49aa15) Thanks [@RealKai42](https://github.com/RealKai42)! - Raise the image downscale cap from 2000px to 3000px, and fix swapped width/height for EXIF-rotated (portrait) photos in compression captions and media read notes so region readback coordinates map correctly. + +- [#1467](https://github.com/PyModel/pythinker-code/pull/1467) [`ee38545`](https://github.com/PyModel/pythinker-code/commit/ee385456d0eda380fec067db92c025462db13f5a) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Compile icons at build time so the bundled web UI only carries the icons it renders. + +- [#1471](https://github.com/PyModel/pythinker-code/pull/1471) [`9b76e5b`](https://github.com/PyModel/pythinker-code/commit/9b76e5bff631cceaeecb2b0cbc096533c5fdc8cc) Thanks [@starquakee](https://github.com/starquakee)! - Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active. + +- [#1491](https://github.com/PyModel/pythinker-code/pull/1491) [`0cc9831`](https://github.com/PyModel/pythinker-code/commit/0cc9831a2f79d93903259bd3353e746abac01b67) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: The composer model switcher switches the active session's model as before and additionally bumps the global default model, so new sessions inherit the choice. + +- [#1490](https://github.com/PyModel/pythinker-code/pull/1490) [`b30a45e`](https://github.com/PyModel/pythinker-code/commit/b30a45efecfa5ece4f4f10f2c5403ba097e7690b) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Press Enter to confirm in archive and other confirmation dialogs. + +- [#1480](https://github.com/PyModel/pythinker-code/pull/1480) [`2ad0120`](https://github.com/PyModel/pythinker-code/commit/2ad0120c2a5c8383892e4da1ee7c6853926ed365) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Redesign the scheduled reminder UI. + +- [#1492](https://github.com/PyModel/pythinker-code/pull/1492) [`b0809dd`](https://github.com/PyModel/pythinker-code/commit/b0809ddac833d8d920d95187f7ef64f97bafdbc6) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Show session skills in the slash menu as `/skill:` so they are distinguishable from built-in commands; typing the bare skill name still works. + +## 0.23.1 + +### Patch Changes + +- [#1432](https://github.com/PyModel/pythinker-code/pull/1432) [`25a655c`](https://github.com/PyModel/pythinker-code/commit/25a655cf88b2f5861f9c0b7ea95ba9308f48d23a) Thanks [@RealKai42](https://github.com/RealKai42)! - Preserve prior turns' thinking by default on the Anthropic provider (Claude and Pythinker's Anthropic-compatible mode), matching the Pythinker default. Disable with `[thinking] keep = "off"` or `PYTHINKER_MODEL_THINKING_KEEP=off`. + +- [#1451](https://github.com/PyModel/pythinker-code/pull/1451) [`16dc940`](https://github.com/PyModel/pythinker-code/commit/16dc940834d9cc693b1f0022c4c70ef0004a6102) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Recover chat streaming after a stale background-tab WebSocket instead of requiring a page refresh. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal completion and blocked updates to produce one final user-facing outcome summary from the tool result. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal startup and queue handling so failed starts restore permission mode and queued goals wait behind new user messages. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal token budgets to count model completion tokens and stop without extra continuation steps when the budget is exhausted. + +- [#1452](https://github.com/PyModel/pythinker-code/pull/1452) [`244ec07`](https://github.com/PyModel/pythinker-code/commit/244ec077f98c2b498cee1d0002978b6963ccfd4d) Thanks [@sailist](https://github.com/sailist)! - Fix pythinker -p abandoning background subagents that start late or run long, so their results reach the main agent. + +- [#1457](https://github.com/PyModel/pythinker-code/pull/1457) [`260a807`](https://github.com/PyModel/pythinker-code/commit/260a80793a95d7796950a00bdc89cf99f8b196ad) Thanks [@liruifengv](https://github.com/liruifengv)! - Respect the --skills-dir flag in interactive mode. + +- [#1445](https://github.com/PyModel/pythinker-code/pull/1445) [`809a88c`](https://github.com/PyModel/pythinker-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/btw []` opening an empty side chat on the new-session screen. + +- [#1445](https://github.com/PyModel/pythinker-code/pull/1445) [`809a88c`](https://github.com/PyModel/pythinker-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix `/goal ` silently doing nothing on the new-session screen. + +- [#1445](https://github.com/PyModel/pythinker-code/pull/1445) [`809a88c`](https://github.com/PyModel/pythinker-code/commit/809a88cb34d2d5d02e43f030530bb1cd320b4a6a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix slash skill activations (for example `/pre-changelog`) silently doing nothing on the new-session screen. + +- [#1437](https://github.com/PyModel/pythinker-code/pull/1437) [`743f66e`](https://github.com/PyModel/pythinker-code/commit/743f66e547279916d5e37454e78b11eb4b54dca3) Thanks [@RealKai42](https://github.com/RealKai42)! - Stop showing tool-produced `` metadata in tool outputs; failed tools now show their own error text. + +- [#1465](https://github.com/PyModel/pythinker-code/pull/1465) [`bfdbce5`](https://github.com/PyModel/pythinker-code/commit/bfdbce593f1dd667530cbfb5b10b5659b1968e48) Thanks [@liruifengv](https://github.com/liruifengv)! - Honor explicit Anthropic `max_output_size` settings instead of clamping them to built-in ceilings. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Keep goal tools available to the main agent and return clear messages for invalid goal-control calls. + +- [#1463](https://github.com/PyModel/pythinker-code/pull/1463) [`03e78ae`](https://github.com/PyModel/pythinker-code/commit/03e78ae19063b38119f27b1bc89097a09614c0ce) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix newer Claude minor versions (e.g. Opus 4.8) defaulting to the family-baseline max output tokens; an uncatalogued minor now reuses the nearest earlier known version's limit. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Show long-running goal wall-clock budget reminders in hours. + +- [#1456](https://github.com/PyModel/pythinker-code/pull/1456) [`e9ef939`](https://github.com/PyModel/pythinker-code/commit/e9ef9399d075461c7753d1f0467bd742a81cdbf6) Thanks [@chengluyu](https://github.com/chengluyu)! - Tighten goal-mode guidance so agents continue reasonable work across turns instead of ending goals prematurely. + +- [#1450](https://github.com/PyModel/pythinker-code/pull/1450) [`7a65e0d`](https://github.com/PyModel/pythinker-code/commit/7a65e0d1c0da515dbd69f1266ba7e75713e0108e) Thanks [@liruifengv](https://github.com/liruifengv)! - Clarify the permission mode descriptions shown by `/permission`, `/auto`, and `/yolo`, and reorder `/auto` and `/yolo` in the command list. + +- [#1448](https://github.com/PyModel/pythinker-code/pull/1448) [`65d3017`](https://github.com/PyModel/pythinker-code/commit/65d30177adc11a56bdbbe9fbc3c4b92f96efd6bb) Thanks [@RealKai42](https://github.com/RealKai42)! - Record a per-request trace in the session wire log, so model requests can be reconstructed for debugging. Not a user-facing feature. + +## 0.23.0 ### Minor Changes -- [#12](https://github.com/PyModel/pythinker-code/pull/12) [`02f7f8d`](https://github.com/PyModel/pythinker-code/commit/02f7f8d93ff138611298f8d46c5c54e928c7ae59) - Prepare verified Homebrew updates in the background and install them automatically on the next interactive launch. +- [#1417](https://github.com/PyModel/pythinker-code/pull/1417) [`79b360c`](https://github.com/PyModel/pythinker-code/commit/79b360c96ad5d0af6a8c1d3a8df73adc65254d7c) Thanks [@RealKai42](https://github.com/RealKai42)! - Enable Preserved Thinking by default for Pythinker models when Thinking is on, keeping prior reasoning across turns. Set `[thinking] keep = "off"` in config.toml (or `PYTHINKER_MODEL_THINKING_KEEP=off`) to disable it. + +- [#1073](https://github.com/PyModel/pythinker-code/pull/1073) [`6c0ce09`](https://github.com/PyModel/pythinker-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - Add server APIs to restore archived sessions and list only archived sessions. + +- [#1369](https://github.com/PyModel/pythinker-code/pull/1369) [`f0896a5`](https://github.com/PyModel/pythinker-code/commit/f0896a53b01f7e5b9bf5b8f93d2cd7387d765f07) Thanks [@starquakee](https://github.com/starquakee)! - Add experimental progressive tool disclosure (`select_tools`). When the `tool-select` experimental flag is on and the active model declares the `select_tools` capability, MCP tool schemas stay out of the request's top-level `tools[]` (preserving the provider prompt cache); the model loads tools on demand by exact name via the new built-in `select_tools` tool, guided by `/` announcements. Off by default and inert on models without the capability — behavior is unchanged until a supporting model is catalogued. The SDK additionally maps the `select_tools` capability when building model aliases from a catalog and reports the new flag through `getExperimentalFeatures()`. + +- [#1073](https://github.com/PyModel/pythinker-code/pull/1073) [`6c0ce09`](https://github.com/PyModel/pythinker-code/commit/6c0ce09414bbfd42d8991c88c89b82c088ff2099) Thanks [@sailist](https://github.com/sailist)! - web: Add an Archived sessions page in Settings to browse and restore archived sessions. Open Settings → Archived to find it. + +- [#1425](https://github.com/PyModel/pythinker-code/pull/1425) [`c5e3e80`](https://github.com/PyModel/pythinker-code/commit/c5e3e80041a763143934c271d2524ac555d48d2a) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Replace the dynamic_workflow footer with a single inline tool card that shows live subagent progress and the aggregated result, and keep dynamic_workflow badges stable after refresh. ### Patch Changes -- [#12](https://github.com/PyModel/pythinker-code/pull/12) [`02f7f8d`](https://github.com/PyModel/pythinker-code/commit/02f7f8d93ff138611298f8d46c5c54e928c7ae59) - Fix context compaction failing with provider "Invalid max_tokens" errors by capping requested completion tokens to the remaining context window and a safe output ceiling instead of the full context window size. +- [#1414](https://github.com/PyModel/pythinker-code/pull/1414) [`c12f309`](https://github.com/PyModel/pythinker-code/commit/c12f30951f2c278bebb42d0f61d67946c42b9577) Thanks [@RealKai42](https://github.com/RealKai42)! - AskUserQuestion now feeds answers back to the model as question text and option labels (e.g. `{"answers":{"Which database?":"Postgres"}}`) instead of synthesized ids like `q_0`/`opt_0_1`, so the model no longer has to map positional ids back to the original options — the wire protocol is unchanged, clients still answer with option ids. Question texts must now be unique per call and option labels unique per question; the web transcript card resolves both the new label form and legacy id transcripts. + +- [#1408](https://github.com/PyModel/pythinker-code/pull/1408) [`fc259ab`](https://github.com/PyModel/pythinker-code/commit/fc259abdb415fe9ac10132a142bdb5ce507ccda2) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix `@` file completion missing deeply nested files in large projects after adding extra workspace directories. + +- [#1346](https://github.com/PyModel/pythinker-code/pull/1346) [`b9258ee`](https://github.com/PyModel/pythinker-code/commit/b9258ee07d32ff63afe9a2eb40fce6d136548fb2) Thanks [@liruifengv](https://github.com/liruifengv)! - Show compaction summaries in the TUI after compaction. Press Ctrl-O to show or hide the summary. + +- [#1419](https://github.com/PyModel/pythinker-code/pull/1419) [`5ea3ec4`](https://github.com/PyModel/pythinker-code/commit/5ea3ec489e0a7d66b844c39ee65162fd6a8ed8b1) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Bash tool card collapsing in height when a multi-line command finishes with short output, and visually separate the command from its output. + +- [#1410](https://github.com/PyModel/pythinker-code/pull/1410) [`1c817df`](https://github.com/PyModel/pythinker-code/commit/1c817df1e522f438d4392568b64fc039dc867031) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the edit approval preview shown by ctrl+e to include surrounding context lines, matching the summary panel. + +- [#1421](https://github.com/PyModel/pythinker-code/pull/1421) [`1de0286`](https://github.com/PyModel/pythinker-code/commit/1de028612c80c38cd6fbe4483c123ded57a0a678) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the Edit tool card jumping in height and flickering while its result streams in. + +- [#1389](https://github.com/PyModel/pythinker-code/pull/1389) [`ebdffc7`](https://github.com/PyModel/pythinker-code/commit/ebdffc7df7b89dcafcf62f5705eafb50bfbaf5ab) Thanks [@sailist](https://github.com/sailist)! - Fix tool calling with Google Gemini models, including Gemini 3 thinking-signature round-trips across turns. + +- [#1393](https://github.com/PyModel/pythinker-code/pull/1393) [`4c43935`](https://github.com/PyModel/pythinker-code/commit/4c43935e31170140699a54cba631792872628655) Thanks [@justjavac](https://github.com/justjavac)! - web: Show the correct session search shortcut on Windows. + +- [#1406](https://github.com/PyModel/pythinker-code/pull/1406) [`ce41f4b`](https://github.com/PyModel/pythinker-code/commit/ce41f4b58d128ae47b0312eab24a845bbc0d08a3) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the collapsed sidebar not hiding and squeezing the conversation layout. + +- [#1413](https://github.com/PyModel/pythinker-code/pull/1413) [`913d042`](https://github.com/PyModel/pythinker-code/commit/913d042208b4bfe45dc13144c4797dba1cac5d05) Thanks [@liruifengv](https://github.com/liruifengv)! - Fix the input box shifting upward after the slash command menu closes. + +- [#1433](https://github.com/PyModel/pythinker-code/pull/1433) [`ac5b5e4`](https://github.com/PyModel/pythinker-code/commit/ac5b5e4cbfdb050817c9fce7e08dd3bdd8ea354e) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix tool components jumping the conversation when expanded or collapsed. + +- [#1394](https://github.com/PyModel/pythinker-code/pull/1394) [`e95fc83`](https://github.com/PyModel/pythinker-code/commit/e95fc83cc295dccf3e4c748fba087530dba614b6) Thanks [@justjavac](https://github.com/justjavac)! - web: Fix the font size setting so chat text, composer text, and sidebar text follow the selected size. + +- [#1411](https://github.com/PyModel/pythinker-code/pull/1411) [`e6e6dd5`](https://github.com/PyModel/pythinker-code/commit/e6e6dd53ce9106f47684534a91acb1a803d1ab07) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Stop the chat history from replaying its entrance animation every time a session is opened. + +- [#1409](https://github.com/PyModel/pythinker-code/pull/1409) [`578f7d3`](https://github.com/PyModel/pythinker-code/commit/578f7d334c7919e5987229c157060b1daae30139) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix the end of a reply staying missing after reopening a session. + +- [#1390](https://github.com/PyModel/pythinker-code/pull/1390) [`083d0ca`](https://github.com/PyModel/pythinker-code/commit/083d0caf0524ed9cc7978007cd0f342f6bd2917e) Thanks [@sailist](https://github.com/sailist)! - Fix sessions that exist on disk but were missing from the session list or returned 404 on direct access, by rebuilding the session index at server startup and keeping it consistent. + +- [#1357](https://github.com/PyModel/pythinker-code/pull/1357) [`be7c991`](https://github.com/PyModel/pythinker-code/commit/be7c9916b019b19e057301c39bc7944fcac09414) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Fix queued media messages not loading back into the composer and keep attachments when undoing a message. -- [#12](https://github.com/PyModel/pythinker-code/pull/12) [`02f7f8d`](https://github.com/PyModel/pythinker-code/commit/02f7f8d93ff138611298f8d46c5c54e928c7ae59) - Fix Dynamic Workflow progress sticking at 90% during long streaming, show a Finalizing state once all delegated agents finish, and fix member row alignment at narrow widths. +- [#1428](https://github.com/PyModel/pythinker-code/pull/1428) [`903e8ed`](https://github.com/PyModel/pythinker-code/commit/903e8ed93afc5b35d0fa1d33c86da2b3fae9ba9f) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Add an Archived sessions entry to the mobile settings sheet and clarify the archive confirmation to mention restoring from Settings. -## 0.6.2 +- [#1391](https://github.com/PyModel/pythinker-code/pull/1391) [`c5c6282`](https://github.com/PyModel/pythinker-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON. + +- [#1423](https://github.com/PyModel/pythinker-code/pull/1423) [`fa6d198`](https://github.com/PyModel/pythinker-code/commit/fa6d198b0174ad76aa4ca3c0ea2ed45e099e521b) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Fix an almost-invisible composer input caret and a washed-out strikethrough on completed todos. + +- [#1436](https://github.com/PyModel/pythinker-code/pull/1436) [`a5fbcb7`](https://github.com/PyModel/pythinker-code/commit/a5fbcb75b4b3ab937536a7a2f621c0374812c753) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Keep the composer toolbar from clipping its controls on narrow windows and phones, with the context ring staying visible at every width. + +- [#1426](https://github.com/PyModel/pythinker-code/pull/1426) [`2374bc4`](https://github.com/PyModel/pythinker-code/commit/2374bc41c35adc1d2e2b5116559946c8de1b98a8) Thanks [@liruifengv](https://github.com/liruifengv)! - web: Show scheduled-reminder (cron) fires as notice cards in the chat instead of hiding them. + +- [#1391](https://github.com/PyModel/pythinker-code/pull/1391) [`c5c6282`](https://github.com/PyModel/pythinker-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel. + +- [#1434](https://github.com/PyModel/pythinker-code/pull/1434) [`4aacddc`](https://github.com/PyModel/pythinker-code/commit/4aacddc43222d0a44f202360462617788ca75660) Thanks [@wbxl2000](https://github.com/wbxl2000)! - Show the Pythinker icon and clearer titles in web desktop notifications. + +- [#1392](https://github.com/PyModel/pythinker-code/pull/1392) [`4963c90`](https://github.com/PyModel/pythinker-code/commit/4963c9016fa19d1e01f8dc938c8d250afec87965) Thanks [@sailist](https://github.com/sailist)! - web: Show available skills in the composer before a session is created. + +- [#1438](https://github.com/PyModel/pythinker-code/pull/1438) [`d86fa38`](https://github.com/PyModel/pythinker-code/commit/d86fa38e119c5834fff13a67194efe8d62c117e1) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Prevent chat text from hyphenating at line breaks and render code without font ligatures. + +- [#1391](https://github.com/PyModel/pythinker-code/pull/1391) [`c5c6282`](https://github.com/PyModel/pythinker-code/commit/c5c6282f447dba202c79cf0e3b7524712d2c2748) Thanks [@wbxl2000](https://github.com/wbxl2000)! - web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header. + +## 0.22.3 ### Patch Changes -- [#10](https://github.com/PyModel/pythinker-code/pull/10) [`ad2391b`](https://github.com/PyModel/pythinker-code/commit/ad2391b5601f173d7eecdaab55f1110f506c1ad1) - Clear the terminal before the install script's animated intro so earlier shell output no longer interleaves with the logo animation. +- [#1367](https://github.com/PyModel/pythinker-code/pull/1367) [`23daf0f`](https://github.com/PyModel/pythinker-code/commit/23daf0f3c199b4aaa9bd9388a2903d7827f98d32) - Revert the recent TUI transcript rendering changes to the original upstream behavior and fix related rendering issues. + +- [#1343](https://github.com/PyModel/pythinker-code/pull/1343) [`ec758c7`](https://github.com/PyModel/pythinker-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Add click-to-enlarge for images uploaded in the web chat. Click an image in a message to open it. + +- [#1343](https://github.com/PyModel/pythinker-code/pull/1343) [`ec758c7`](https://github.com/PyModel/pythinker-code/commit/ec758c747a95555847b8a0275ed0809010c7d5e7) - Fix uploaded videos failing to play in the web chat. + +- [#1371](https://github.com/PyModel/pythinker-code/pull/1371) [`5394fea`](https://github.com/PyModel/pythinker-code/commit/5394feaabb5d373fab046b3986b10a1180b4991d) - Wait for background subagents to finish and respond to their results before exiting in `pythinker -p`, instead of ending the turn early. -- [#10](https://github.com/PyModel/pythinker-code/pull/10) [`ad2391b`](https://github.com/PyModel/pythinker-code/commit/ad2391b5601f173d7eecdaab55f1110f506c1ad1) - Restyle the browser OAuth sign-in confirmation pages for all providers to match the website's light design. +- [#1373](https://github.com/PyModel/pythinker-code/pull/1373) [`e715b16`](https://github.com/PyModel/pythinker-code/commit/e715b1648c57bd0863edf859cb67db0327b7bb94) - Add `--dangerous-bypass-auth` and `--keep-alive` flags to `pythinker server run`, so the server can run without a token on trusted networks and stay alive past the idle timeout. -## 0.6.1 +- [#1344](https://github.com/PyModel/pythinker-code/pull/1344) [`26b9022`](https://github.com/PyModel/pythinker-code/commit/26b90225d21bd18f4f7e3b775f3f7f49034afad9) - Add a segmented thinking-level control in the web model picker for models that support multiple reasoning efforts. Open the composer model menu to choose a level. + +## 0.22.2 ### Patch Changes -- [#7](https://github.com/PyModel/pythinker-code/pull/7) [`d396320`](https://github.com/PyModel/pythinker-code/commit/d396320235e31ce09f04f0da244ec5a694e2bcfe) - Prompt for an API key when connecting a catalog provider whose environment variable is not set, instead of failing with "Environment variable is not set or is empty". Applies to `/login`, `/provider`, and `pythinker provider catalog add`, which now also accepts `--api-key `. +- [#1353](https://github.com/PyModel/pythinker-code/pull/1353) [`68ad686`](https://github.com/PyModel/pythinker-code/commit/68ad686211760eb1c3e6b5c23eb28ace9009c17f) - Fix duplicated transcript content appearing in scrollback during streaming. -- [#7](https://github.com/PyModel/pythinker-code/pull/7) [`d396320`](https://github.com/PyModel/pythinker-code/commit/d396320235e31ce09f04f0da244ec5a694e2bcfe) - Explain in `/update` and the startup update notice that Homebrew installs do not auto-update, and point to the native installer for automatic background updates. +- [#1340](https://github.com/PyModel/pythinker-code/pull/1340) [`e2fe62a`](https://github.com/PyModel/pythinker-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix sessions silently dropping later user messages after a turn was interrupted between a tool call and its result. -- [#7](https://github.com/PyModel/pythinker-code/pull/7) [`d396320`](https://github.com/PyModel/pythinker-code/commit/d396320235e31ce09f04f0da244ec5a694e2bcfe) - Point the native install scripts at the published release assets. +- [#1342](https://github.com/PyModel/pythinker-code/pull/1342) [`84d8d5b`](https://github.com/PyModel/pythinker-code/commit/84d8d5b06399d29a9d8caba701835061c30a4817) - Have context-compaction notes capture a forward plan for the remaining work — upcoming steps, settled decisions, and foreseeable obstacles — instead of only the immediate next step, so the agent continues more coherently after auto-compaction. -- [#7](https://github.com/PyModel/pythinker-code/pull/7) [`d396320`](https://github.com/PyModel/pythinker-code/commit/d396320235e31ce09f04f0da244ec5a694e2bcfe) - Show a clear requirement message with the native-installer alternative when the CLI is launched on Node.js older than 26.4, instead of failing with a cryptic flag error. +- [#1340](https://github.com/PyModel/pythinker-code/pull/1340) [`e2fe62a`](https://github.com/PyModel/pythinker-code/commit/e2fe62a5eff124b816a0f656355fc5bea85e3893) - Fix requests being rejected by strict providers when the model emits duplicate tool call ids. -- [#8](https://github.com/PyModel/pythinker-code/pull/8) [`9b1b195`](https://github.com/PyModel/pythinker-code/commit/9b1b19577a5826f33a2bd70116c48bfeb46362ad) - Fix the CLI failing to start on Windows with "process.execve is unavailable" by using the spawn fallback instead of calling execve there. +- [#1339](https://github.com/PyModel/pythinker-code/pull/1339) [`021786f`](https://github.com/PyModel/pythinker-code/commit/021786f5a201df5466a963d4d1ac915b3977582b) - Enrich PATH from the user's login shell at startup, so shell commands find user-installed tools (e.g. Homebrew's `gh`) even when pythinker-code was launched without the full profile PATH. -## 0.6.0 +- [#1336](https://github.com/PyModel/pythinker-code/pull/1336) [`4c1d0a1`](https://github.com/PyModel/pythinker-code/commit/4c1d0a1633c98ae5703addbf86ffe50b81545c08) - Keep automatic background updates from flashing a console window on Windows. -### Minor Changes +- [#1332](https://github.com/PyModel/pythinker-code/pull/1332) [`93f16c3`](https://github.com/PyModel/pythinker-code/commit/93f16c32d71d974f30c3ea3b1134691936ac5f53) - Fix `pythinker upgrade` failing on Windows with a spawn error when installing the new version. + +- [#1348](https://github.com/PyModel/pythinker-code/pull/1348) [`175b95f`](https://github.com/PyModel/pythinker-code/commit/175b95f3af684f7c5447967b9fe7c8a58b6ffe1b) - Fix compressed-image prompts leaking an internal `` compression note into the visible message and the session title. -- [`d7a2554`](https://github.com/PyModel/pythinker-code/commit/d7a25545a6f6fb0c2024a11dcde8012a087c9e44) - Maintenance release with internal improvements and dependency updates. +- [#1338](https://github.com/PyModel/pythinker-code/pull/1338) [`276407d`](https://github.com/PyModel/pythinker-code/commit/276407d2a46b03ce32cce02b73c5d485b1b02b17) - Promote the language-matching rule to a dedicated section in the system prompt, so replies and reasoning consistently follow the user's language through long English tool output, while repository artifacts keep project conventions. -## 0.5.1 +- [#1347](https://github.com/PyModel/pythinker-code/pull/1347) [`02da587`](https://github.com/PyModel/pythinker-code/commit/02da5877953ce082826ba5ab1a1abd914d82b24a) - In `pythinker -p` runs, wait for background subagents to finish before exiting when `background.keep_alive_on_exit` is enabled. Set `keep_alive_on_exit = true` to let concurrent background subagents complete. + +- [#1349](https://github.com/PyModel/pythinker-code/pull/1349) [`e9db9ca`](https://github.com/PyModel/pythinker-code/commit/e9db9cafcf7a0d26122b2cac247d866d7724fd7a) - Record model response ids in session wire logs to make individual model requests easier to trace. + +- [#1345](https://github.com/PyModel/pythinker-code/pull/1345) [`3ed22e3`](https://github.com/PyModel/pythinker-code/commit/3ed22e35a4ee09ce353e699406c6c994423ff39f) - Keep subagent cards at a stable height and show a live status spinner with a compact two-row activity window. + +- [#1305](https://github.com/PyModel/pythinker-code/pull/1305) [`9091627`](https://github.com/PyModel/pythinker-code/commit/909162725770700efd3051f4cfa68156d9b84fa8) - Add a TUI preference to keep rapid multi-line pastes from submitting line by line when bracketed paste is unavailable. Set `disable_paste_burst = true` in `tui.toml` to turn it off. + +- [#1328](https://github.com/PyModel/pythinker-code/pull/1328) [`01b65bd`](https://github.com/PyModel/pythinker-code/commit/01b65bdddc28c7c492096000103687f6a507e353) - Rebuild the web design-system easter egg as an in-app overlay that uses the app's real design tokens, so it stays in sync instead of drifting as a separate copy. + +## 0.22.1 ### Patch Changes -- README: point install commands at the canonical code.pythinker.com URLs and correct the npm install path's Node.js floor to 26.4.0 (matching the package engines field). +- [#1304](https://github.com/PyModel/pythinker-code/pull/1304) [`0fc0ae3`](https://github.com/PyModel/pythinker-code/commit/0fc0ae380b09aa96aad0eff1ae66f239e061d01a) - When large images are compressed, tell the model the original and delivered image details. Keep the original image available, and support cropped or full-resolution reads for fine details. -## 0.5.0 +- [#1315](https://github.com/PyModel/pythinker-code/pull/1315) [`b40bb71`](https://github.com/PyModel/pythinker-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Fix TUI rendering bugs that caused the screen to go blank and the input box to disappear. -### Minor Changes +- [#1303](https://github.com/PyModel/pythinker-code/pull/1303) [`2639786`](https://github.com/PyModel/pythinker-code/commit/2639786ce578f15c020a2c11c344797dae18de61) - Fix the TUI crashing when the terminal is resized to a very narrow width while the input contains CJK or emoji text. -- Release pipeline hardening and native-bundle recovery: idempotent npm publish (re-pushes without changesets no longer fail Release), unsigned macOS bundles with a warning when signing secrets are absent, refreshed Nix pnpm-deps hash, monorepo dependency-version sync, strict pre-push quality gates, and Dependabot configuration. Ships the native bundles that 0.4.0's release run failed to produce. +- [#1315](https://github.com/PyModel/pythinker-code/pull/1315) [`b40bb71`](https://github.com/PyModel/pythinker-code/commit/b40bb7139939eb2ba734ce5dd4871b894d7033e8) - Clear the screen fully when starting a new session via /new, /clear, or a session switch. -## 0.4.0 +- [#1301](https://github.com/PyModel/pythinker-code/pull/1301) [`c3653a1`](https://github.com/PyModel/pythinker-code/commit/c3653a1c50ffa3856484599e132980628eb9fca4) - Show an up arrow on the web composer send button. -### Minor Changes +- [#1290](https://github.com/PyModel/pythinker-code/pull/1290) [`3ea84a5`](https://github.com/PyModel/pythinker-code/commit/3ea84a56e4dfdeaddd58add5b269be0342f3f986) - Fix the session search dialog showing a horizontal scrollbar for long session titles or snippets. -- Shift-Tab now cycles thinking effort (plan mode moves to /plan), the prompt-box border tints with a per-effort color gradient, upstream fixes ported (goal step-cap continuation, repeat-breaker for validation-rejected tool calls, fail-fast on quota-exhausted 429s), no default provider endpoint (configure your own base URL), restored real Kimi coding-plan model ids in the ACP thinking-toggle list, and dependency security updates. +- [#1316](https://github.com/PyModel/pythinker-code/pull/1316) [`5322c63`](https://github.com/PyModel/pythinker-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Fix web tooltips that could get stuck on screen when their trigger element is removed while open. -## 0.3.0 +- [#1319](https://github.com/PyModel/pythinker-code/pull/1319) [`e8ab7ca`](https://github.com/PyModel/pythinker-code/commit/e8ab7ca78661de7f00a8196444be1db93e7c14b4) - Fix the sidebar session row shifting its title and status badges when hovered. + +- [#1293](https://github.com/PyModel/pythinker-code/pull/1293) [`6a469b3`](https://github.com/PyModel/pythinker-code/commit/6a469b3e07022e56b29b1fd8a7c58df36b2111fe) - Refresh the web UI icon set and unify the message copy and undo button hover states and tooltips. + +- [#1311](https://github.com/PyModel/pythinker-code/pull/1311) [`b40649b`](https://github.com/PyModel/pythinker-code/commit/b40649b2ae7a4b6a0aea04e32eba200555393064) - Remove duplicate newline-shortcut handling from the prompt editor. + +- [#1317](https://github.com/PyModel/pythinker-code/pull/1317) [`78a058a`](https://github.com/PyModel/pythinker-code/commit/78a058acd2fc91de5cca0c1d66d415ee35884889) - Remove the experimental micro compaction feature and its toggle from the experiments panel. + +- [#1283](https://github.com/PyModel/pythinker-code/pull/1283) [`ea55911`](https://github.com/PyModel/pythinker-code/commit/ea55911062eefcb0414cfddb84c8a4494c45f363) - Improve compaction handoff summaries for more reliable resumed sessions. They now keep the latest intent, key tool results, decisions, open questions, and context to re-check. + +- [#1295](https://github.com/PyModel/pythinker-code/pull/1295) [`77eb3a9`](https://github.com/PyModel/pythinker-code/commit/77eb3a9fe40c93fa32e335f07160b8128355bab6) - Save shell commands to input history and recall them in bash mode. Press Up on an empty `!` prompt to browse previous shell commands. + +- [#1316](https://github.com/PyModel/pythinker-code/pull/1316) [`5322c63`](https://github.com/PyModel/pythinker-code/commit/5322c638895a934c1ce220fefed54f5077d2a49e) - Trim redundant and incorrect tooltips in the web UI. + +- [#1320](https://github.com/PyModel/pythinker-code/pull/1320) [`444e6b1`](https://github.com/PyModel/pythinker-code/commit/444e6b15f0e53b6c4d75d1bfdc0b35639dce6f4c) - Fix the web UI becoming sluggish after opening many sessions. + +- [#1322](https://github.com/PyModel/pythinker-code/pull/1322) [`5441ad1`](https://github.com/PyModel/pythinker-code/commit/5441ad1838a5cfa1f3df0ca2ee1524e1433fb513) - Let the web sidebar collapse an expanded workspace session list back to its first page. + +## 0.22.0 ### Minor Changes -- [`fc6a226`](https://github.com/PyModel/pythinker-code/commit/fc6a22694298d884070130d5918ce7b14c585fdb) - Add the `/update` slash command (alias `/upgrade`) the welcome banner has been advertising: it checks the CDN for a newer version and installs it in the background, falling back to a copyable command for installs that cannot self-update (e.g. Homebrew). `pythinker doctor` now reports whether auto-update is on, off via `tui.toml [upgrade].auto_install`, or disabled by `PYTHINKER_CODE_NO_AUTO_UPDATE`. +- [#1243](https://github.com/PyModel/pythinker-code/pull/1243) [`ace7901`](https://github.com/PyModel/pythinker-code/commit/ace79010669d19ad175bc25443b6efb41ca2e2ac) - Automatically compress oversized images before they reach the model. Whatever the source — pasted into the CLI, uploaded from the web/desktop client, sent over ACP, read via `ReadMediaFile`, or returned by an MCP tool — images are downsampled (longest edge ≤ 2000px) and re-encoded to fit a per-image byte budget, cutting vision-token cost and avoiding provider image-size errors. Screenshots stay lossless PNG and only degrade to JPEG when the byte budget cannot otherwise be met. Compression runs as an input-stage step at each ingestion point (while the content part is built), and guards against decompression bombs by skipping absurdly large pixel/byte payloads before decoding. Best-effort: if it fails for any reason the original image is sent unchanged. -## 0.2.0 +- [#1262](https://github.com/PyModel/pythinker-code/pull/1262) [`c070fbe`](https://github.com/PyModel/pythinker-code/commit/c070fbeddeb1c147d8859a76046f9465f696c9cb) - Add model alias overrides so manual thinking effort levels and model metadata survive provider catalog refreshes. Set them under `[models."".overrides]`. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Refresh the web UI with a new design system, including updated colors, typography, spacing, light and dark palettes, restyled tooltips, and subtle enter/exit and expand/collapse animations. + +### Patch Changes + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show draft pull requests with a distinct draft status instead of displaying them as open. + +- [#1254](https://github.com/PyModel/pythinker-code/pull/1254) [`7859b0a`](https://github.com/PyModel/pythinker-code/commit/7859b0afe8898852806e5a0c21b9dd52cb82f834) - Fix the transcript jumping to the top when scrolling up through history during streaming output. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix plan, dynamic_workflow, and goal modes being shared across sessions in the web UI; each session now keeps its own toggles. + +- [#1264](https://github.com/PyModel/pythinker-code/pull/1264) [`003733c`](https://github.com/PyModel/pythinker-code/commit/003733c751584ce30d8ebae4f5e608f0df049d32) - Hide the unsupported Off option in the /model thinking switcher for always-on models that already expose multiple effort levels. + +- [#1272](https://github.com/PyModel/pythinker-code/pull/1272) [`54703d9`](https://github.com/PyModel/pythinker-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions. + +- [#1272](https://github.com/PyModel/pythinker-code/pull/1272) [`54703d9`](https://github.com/PyModel/pythinker-code/commit/54703d9457dcda7bc782301fc2dbb41a2c8d7293) - Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit. + +- [#1265](https://github.com/PyModel/pythinker-code/pull/1265) [`8cfb165`](https://github.com/PyModel/pythinker-code/commit/8cfb1657ad7bf525269df4ab6cf5c12aa1d406a9) - Reduce the default TUI transcript window to keep long sessions responsive. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Replace the Explore and Native theme options with a single chat layout and a Blue or Black accent-color setting. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show time, duration, connection, and stack details in web error and warning toasts. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix an active workspace showing only its five most recent sessions on load, so it now keeps loading older sessions from the last 12 hours. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Reduce the web composer's default height for a more compact empty state, and fix ArrowUp recalling the previous message while editing a multi-line draft; ArrowUp now recalls only from the very start of the text and is disabled in the expanded editor. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix the Thinking-by-default setting not taking effect, so new sessions correctly start with thinking enabled. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Fix spurious errors from the web question, approval, and task actions when the action was already complete, and add loading feedback so each click is acknowledged immediately. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show queued prompts inline below the running turn in the web chat, and split Stop into its own button so Send no longer interrupts. + +- [#1278](https://github.com/PyModel/pythinker-code/pull/1278) [`bbda90a`](https://github.com/PyModel/pythinker-code/commit/bbda90af846ca66232158d2e9605d3d59a7e3a49) - Hide the conversation outline when there is not enough room to expand its labels, so it no longer clips against the window edge. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Show the conversation outline as one entry per user query that expands into a labeled list on hover. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Remove the fade-out animation when undoing a message in the web chat. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Improve session search with a Cmd/Ctrl+K palette that filters by title, workspace, and last prompt with highlighted matches. Press Cmd+K or Ctrl+K to open it. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Group consecutive tool calls into a collapsible stack with per-tool renderers, including diff line-count chips for edits and inline previews for image, video, and audio results. + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Use one consistent modal dialog for confirmations in the web UI (archive session, delete workspace, delete provider, undo message, and mode toggles). + +- [#1258](https://github.com/PyModel/pythinker-code/pull/1258) [`b905dd4`](https://github.com/PyModel/pythinker-code/commit/b905dd49108c567d0fecd38a096808c121672795) - Add workspace sorting by manual order or last-edited time, plus collapse-all and expand-all controls, to the sidebar. + +## 0.21.1 + +### Patch Changes + +- [#1256](https://github.com/PyModel/pythinker-code/pull/1256) [`0cc02ac`](https://github.com/PyModel/pythinker-code/commit/0cc02ac67d465d1d4d7fe070422bab17053cdaa3) - Keep the waiting spinner visible while encrypted reasoning streams, fixing a blank spinner-less gap before the first response text appears. + +## 0.21.0 ### Minor Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add provider-native Fast mode controls for supported OpenAI and Anthropic models. +- [#1204](https://github.com/PyModel/pythinker-code/pull/1204) [`5cb80ce`](https://github.com/PyModel/pythinker-code/commit/5cb80ce879406d239048c32d61202778cb860e58) - Plugins can now provide slash commands via a `commands` field in their manifest, registered as `:` and invoked with `$ARGUMENTS` expansion. + +- [#1214](https://github.com/PyModel/pythinker-code/pull/1214) [`86e0c92`](https://github.com/PyModel/pythinker-code/commit/86e0c9201ed58c7c1ce5543b1dfb47a4cf5117f6) - Rework conversation compaction: -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add validated session and persistent workspace directories with SDK, CLI, and TUI management. + - Keep only recent user prompts plus a single user-role summary; drop assistant and tool messages. + - Repair tool_use/tool_result adjacency before sending, fixing a strict-provider HTTP 400 when a tool call and its result became non-adjacent. + - Merge consecutive user turns for strict providers (Gemini/Vertex), fixing an HTTP 400 ("roles must alternate") after compaction or when a turn is steered in right after a tool result. + - Micro-compaction now defaults off. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose the precedence-resolved agent profile catalog through the SDK and a searchable TUI command. +- [#1132](https://github.com/PyModel/pythinker-code/pull/1132) [`108299b`](https://github.com/PyModel/pythinker-code/commit/108299be3cdffc31a23f64efd3ff5ba50976b412) - Refactor the thinking effort system -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add persistent named agent teams with background teammate spawning, shared task scopes, direct and broadcast messaging, assignment delivery, shutdown coordination, and native terminal identities. +### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add project, user, and namespaced plugin subagent profiles with per-profile turn limits and persistent memory, context-fork workers, per-agent model and working-directory overrides, and Git worktree isolation with native terminal status. +- [#1231](https://github.com/PyModel/pythinker-code/pull/1231) [`ceb27f5`](https://github.com/PyModel/pythinker-code/commit/ceb27f5e449e177493f320d90e292487a8fc3410) - Add a server-side key-value store API for persisting web UI preferences to the user's data directory. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add Anthropic Claude Code marketplace browsing and installation with searchable source selection and install-definition support. +- [#1220](https://github.com/PyModel/pythinker-code/pull/1220) [`ec51324`](https://github.com/PyModel/pythinker-code/commit/ec51324230484f2ebaad1ab0aebf2e38f531d914) - Add a double-Esc shortcut to open the undo selector. Press Esc twice while idle to undo. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Preserve full truncated Bash output on disk, interpret informational exit codes, and expand destructive-command warnings in the terminal approval flow. +- [#1223](https://github.com/PyModel/pythinker-code/pull/1223) [`80e6888`](https://github.com/PyModel/pythinker-code/commit/80e6888e34e4362247c0eac5b77340df014ba286) - Fix @ file mentions not opening when typed inside a slash command argument. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add clearer TUI startup status, effort heat, Dynamic Workflow progress colors, and a brighter dark-theme primary. +- [#1233](https://github.com/PyModel/pythinker-code/pull/1233) [`020992c`](https://github.com/PyModel/pythinker-code/commit/020992c286f0f6bff6a038a7c7bd7e9db639e3c9) - Force-exit headless runs (`pythinker -p`) so a stray ref'd handle left over from the run can't keep a completed run alive until an external timeout, and bound prompt cleanup so a wedged shutdown step can't hang shutdown. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add catalog-backed provider connections with interactive or environment-referenced credentials, live model discovery, provider-aware model selection, and model-specific thinking controls. +- [#1225](https://github.com/PyModel/pythinker-code/pull/1225) [`659062d`](https://github.com/PyModel/pythinker-code/commit/659062d11cc272fe631fc6d4faf64d0e0b1a0142) - Show file path completions when typing `/` in shell mode (`!`). -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add an agent-callable Config tool for approved, validated reads and writes of supported Pythinker settings. +- [#1236](https://github.com/PyModel/pythinker-code/pull/1236) [`bfe8e6a`](https://github.com/PyModel/pythinker-code/commit/bfe8e6ace3cda76b1991bf29c25b9444611d5512) - Fix adding a workspace by path in the web UI failing silently when the daemon rejects the path; it now shows an error instead of a broken workspace. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add configurable Global and Chat TUI keybindings with unbinding, two-key chords, reserved shortcuts, dynamic help labels, template creation, and editor-backed reload. +- [#1221](https://github.com/PyModel/pythinker-code/pull/1221) [`a3f9cec`](https://github.com/PyModel/pythinker-code/commit/a3f9cec8a975f11e37e992e42f954789ed394207) - Fix duplicate workspaces showing in the web sidebar when the same folder is registered more than once. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose files loaded by Read through the SDK and a `/files` TUI command. +- [#1241](https://github.com/PyModel/pythinker-code/pull/1241) [`8ac337a`](https://github.com/PyModel/pythinker-code/commit/8ac337a2b2ac800aa79a373459308abb6c9e63bb) - Stop a malformed message history from permanently bricking a session on strict providers (Anthropic). The request is repaired before sending — orphaned tool calls are closed and empty/whitespace-only text blocks dropped — and if the provider still rejects its structure, it is resent once with a wire-compliant rebuild. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose the model-visible context breakdown through the SDK and a `/context` TUI report. +- [#1228](https://github.com/PyModel/pythinker-code/pull/1228) [`42e37eb`](https://github.com/PyModel/pythinker-code/commit/42e37eb898b722829d2ec83e909525ff18e336a5) - Split LLM streaming timing in the session log and `PYTHINKER_CODE_DEBUG=1` output into client vs. API-server portions, so slow turns can be attributed without parsing the wire log. Time-to-first-token splits into the API-server portion (network + server) and the client portion (in-process request building); the decode window splits into time awaiting tokens from the server and time the client spends processing each streamed chunk. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Show Dynamic Workflow in a coral-framed mission-control panel with live per-agent progress, and let workflow agents run without an automatic timeout. +- [#1234](https://github.com/PyModel/pythinker-code/pull/1234) [`882cf35`](https://github.com/PyModel/pythinker-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Hide the provider management dialog in the web UI until the server supports it. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add persisted file checkpoints with preview and recovery-backed code or conversation rewind through the SDK, CLI, and TUI. +- [#1226](https://github.com/PyModel/pythinker-code/pull/1226) [`7f05f58`](https://github.com/PyModel/pythinker-code/commit/7f05f589e7bc77a2f26463a41317ff7087e3c3a0) - Add Mermaid diagram rendering to the web chat. Fenced `mermaid` blocks in assistant responses now render as diagrams. KaTeX math and Mermaid diagram parsing also run in Web Workers to keep the UI responsive during live streaming. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add fail-closed Read/Edit/Write state tracking, quote-preserving edits, automatic parent creation, and cell-aware Jupyter notebook editing with terminal summaries. +- [#1232](https://github.com/PyModel/pythinker-code/pull/1232) [`aa6b0d0`](https://github.com/PyModel/pythinker-code/commit/aa6b0d065ee888056c3812781483ddb74739897f) - Always show the usage-data opt-out toggle in the web settings with a clearer label and description. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add unchanged-range deduplication and structured Jupyter notebook reads with cell, text-output, and image-output support. +- [#1234](https://github.com/PyModel/pythinker-code/pull/1234) [`882cf35`](https://github.com/PyModel/pythinker-code/commit/882cf355a9cb45bb5b3424a27b953bde8e106bb0) - Fix the web workspace rename not persisting after a page refresh. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Rework the TUI into a fixed full-height layout: the input box and status bar stay pinned to the bottom, the mouse wheel scrolls the conversation, drag-selecting text copies it to the clipboard, and `layout = "inline"` in tui.toml restores the legacy inline behavior. +## 0.20.3 -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add configurable context-aware keybindings across dialogs, plugins, rewind, - message actions, footer controls, and both terminal renderers. +### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add experimental plugin-configured language-server support with lazy stdio servers and agent-callable navigation, symbol, hover, reference, implementation, and call-hierarchy operations. +- [#1207](https://github.com/PyModel/pythinker-code/pull/1207) [`14d9e98`](https://github.com/PyModel/pythinker-code/commit/14d9e98903f30f83199e30b5fa20b3c61ab28781) - Refresh provider model lists automatically in the background instead of only at startup, so newly available models appear without restarting. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add MCP resource discovery and reading with terminal-native summaries. +- [#1191](https://github.com/PyModel/pythinker-code/pull/1191) [`0df1812`](https://github.com/PyModel/pythinker-code/commit/0df18125022103dabb149b4f26f90959b669187b) - Fix provider error messages rendering as blank lines in the TUI when the server returns an HTML error page. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add live instruction refresh and a `/memory` command for user and project memory files. +- [#1212](https://github.com/PyModel/pythinker-code/pull/1212) [`636ccc4`](https://github.com/PyModel/pythinker-code/commit/636ccc40f19f259bdd6653b2ca563a75b3548e23) - Fix the web composer being hidden behind the mobile Safari toolbar and the page auto-zooming when the composer is focused. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add discoverable built-in, user, project, and plugin output styles with config-backed prompt injection and TUI selection. +- [#1068](https://github.com/PyModel/pythinker-code/pull/1068) [`c82dcf9`](https://github.com/PyModel/pythinker-code/commit/c82dcf9cd8276eddf6acbf1030d1712b83a38083) - Glob now uses ripgrep, so it respects .gitignore by default, supports brace patterns, returns only files, and keeps partial results with a warning when some directories are unreadable. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add experimental native PowerShell execution on Windows with non-interactive invocation, streamed foreground and background output, timeout handling, exact-command approval, and terminal language metadata. +- [#1209](https://github.com/PyModel/pythinker-code/pull/1209) [`0635387`](https://github.com/PyModel/pythinker-code/commit/063538744f64a1bd3da6f37ebd0643d10bfc068f) - Align malformed tool call argument handling with schema validation fallback. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add responsive option previews, per-question notes, answer annotations, automatic Other choices, and source telemetry tags to structured questions. +## 0.20.2 -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Complete Glob and Grep parity with absolute glob patterns, sensitive-name filtering, context aliases, and multiple glob filters. +### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add `/cost` to show accumulated session spend and current model token rates, with pricing data available through SDK session status. +- [#1166](https://github.com/PyModel/pythinker-code/pull/1166) [`dfcfdfd`](https://github.com/PyModel/pythinker-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Add an optional exclude_empty parameter to the session list API to omit sessions that have no messages. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose session metadata through the SDK and add searchable session tags with `/tag`. +- [#1156](https://github.com/PyModel/pythinker-code/pull/1156) [`794db55`](https://github.com/PyModel/pythinker-code/commit/794db55538e01b4bf0c008c493de5d8b8bf67c5d) - Cap compaction output at 128k tokens by default to avoid provider max_tokens errors. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add explicitly requested session worktree entry and exit with named resume, cwd and hook rebinding, safe keep behavior, and fail-closed removal confirmation. +- [#1129](https://github.com/PyModel/pythinker-code/pull/1129) [`d02b5c4`](https://github.com/PyModel/pythinker-code/commit/d02b5c49844d65e005632fafcb1c172a7d32bfbe) - Fix compaction ignoring the configured max output size. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add JSON Schema validated structured output to prompt mode. +- [#1188](https://github.com/PyModel/pythinker-code/pull/1188) [`db5fbc5`](https://github.com/PyModel/pythinker-code/commit/db5fbc53c00c9945fc1fa98c69c4e5c7efb8077e) - Fix unnecessary full-screen redraws when typing in the input box or toggling the slash panel. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add persistent project-task creation, lookup, dependency-aware listing, and updates while retaining explicit background-task listing. +- [#1187](https://github.com/PyModel/pythinker-code/pull/1187) [`97f9263`](https://github.com/PyModel/pythinker-code/commit/97f9263c6f13ead5edc051f96993f8d1d7d5ec6f) - Fix debug timing output lingering after undoing a turn. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add per-model thinking effort levels: pick the level in the model selector or with the new `/effort` command, cycle it with Ctrl-T, and see the current level in the footer and on the input box border. +- [#1163](https://github.com/PyModel/pythinker-code/pull/1163) [`ff6e8bb`](https://github.com/PyModel/pythinker-code/commit/ff6e8bbd7c328dcc6575902cfd0cb3e522f20948) - Fix the web composer occasionally keeping typed text after sending the first message of a new session. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Show estimated token throughput while output streams in the TUI footer, then replace it with the provider-reported completed rate. +- [#1189](https://github.com/PyModel/pythinker-code/pull/1189) [`04b3492`](https://github.com/PyModel/pythinker-code/commit/04b3492e740dad5fca2af9f66eca98da3e14058a) - Fix working tips getting squeezed against the agent dynamic_workflow progress bar. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add a TUI copy command for recent assistant responses and fenced code blocks with clipboard and file fallbacks. +- [#1159](https://github.com/PyModel/pythinker-code/pull/1159) [`23a553b`](https://github.com/PyModel/pythinker-code/commit/23a553bb91e9ee794aaf769f78f5acec739aec85) - In the bundled web UI, `/new` and `/clear` are now aliases that open the session onboarding composer and focus the input. iOS auto-zoom is prevented by keeping text inputs at 16px instead of disabling viewport scaling. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Build the OpenTUI dialog slice with native searchable-dialog interactions. +- [#1186](https://github.com/PyModel/pythinker-code/pull/1186) [`821847c`](https://github.com/PyModel/pythinker-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add `PYTHINKER_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Pythinker providers. Set `PYTHINKER_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose configuration and keybinding diagnostics through the TUI doctor command. +- [#1186](https://github.com/PyModel/pythinker-code/pull/1186) [`821847c`](https://github.com/PyModel/pythinker-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Route managed Pythinker Code models on the Anthropic-compatible protocol through the beta Messages API. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add `/colors` and polish terminal progress, context, question, and Markdown activity displays while preserving reasoning-summary boundaries. +- [#1170](https://github.com/PyModel/pythinker-code/pull/1170) [`cf558cd`](https://github.com/PyModel/pythinker-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Recover from provider 413 context overflows by compacting before retrying. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add native TUI management for persisted allow, ask, and deny permission rules. +- [#1170](https://github.com/PyModel/pythinker-code/pull/1170) [`cf558cd`](https://github.com/PyModel/pythinker-code/commit/cf558cd74267393d6497ddedf25e192eaac4f94b) - Support the Anthropic-compatible protocol for managed Pythinker Code, including video input. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add TUI commands for listing discovered skills and configured hooks. +- [#1186](https://github.com/PyModel/pythinker-code/pull/1186) [`821847c`](https://github.com/PyModel/pythinker-code/commit/821847cb4b88d9128014609aad307ab8d9e9a5f3) - Add provider type and protocol attributes to turn and API error telemetry. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expand the TUI colour palette from 18 to 50 semantic tokens — shimmer variants, eight per-subagent identity colours, dimmed diff shades, a rainbow set, mode-identity badges, background surfaces, and progress-bar fill — and add a curried theme-aware `colorize` helper that accepts either a palette token or a raw hex. +- [#1155](https://github.com/PyModel/pythinker-code/pull/1155) [`54baf5d`](https://github.com/PyModel/pythinker-code/commit/54baf5d07fe718b70b8840e509a905ac48b1ccac) - Upgrade web markdown renderer dependencies (katex, markstream-vue, shiki) for bug fixes and performance improvements. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Wire the vim core into the composer behind an opt-in `vimMode` option. A narrow, version-pinned bridge is the only seam to pi-tui's private editor state; terminal escape sequences and bracketed pastes are classified before vim sees them, so paste, arrows, and Kitty-protocol keys keep working in every mode. +- [#1162](https://github.com/PyModel/pythinker-code/pull/1162) [`b070846`](https://github.com/PyModel/pythinker-code/commit/b0708464f4160f7b73f25a520e493bf87e92149f) - Rework the web ask-user-question card into a step-by-step wizard so multi-question navigation and the final Submit action are easier to see. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add the `vim_mode` experimental flag, off by default, so modal editing in the composer can be toggled through `/vim` or `/experiments`, `PYTHINKER_CODE_EXPERIMENTAL_VIM_MODE`, or config. The editor picks the flag up when the snapshot lands and follows runtime configuration changes. +- [#1179](https://github.com/PyModel/pythinker-code/pull/1179) [`fc3d69d`](https://github.com/PyModel/pythinker-code/commit/fc3d69dbdc965e525b5486a6b91e4ec44194ca97) - Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add the vim-mode state machine for the composer: NORMAL/INSERT transitions, counts, and the full movement set (character, line, word, line-anchored, document, and line-local find with repeat). Pure and renderer-agnostic — editing operators, text objects, and visual mode follow, as does the editor wiring. +- [#1165](https://github.com/PyModel/pythinker-code/pull/1165) [`f3b1532`](https://github.com/PyModel/pythinker-code/commit/f3b15322da518b0e3d0560d19651435793c790d9) - Replace the web composer attach button's plus icon with an image icon. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add vim editing operators to the composer state machine: `d`/`c`/`y` over any motion, doubled linewise forms, the single-key shortcuts (`x`, `X`, `s`, `S`, `D`, `C`, `Y`), text objects (`iw`/`aw`, quotes, nested brackets), and an unnamed register with charwise and linewise paste. +- [#1167](https://github.com/PyModel/pythinker-code/pull/1167) [`c63edd5`](https://github.com/PyModel/pythinker-code/commit/c63edd5bf6d764c3ab771cb697a334ac100a0944) - In the bundled web UI, a new session is now created only when the first message is sent, so + New without a workspace opens the composer instead of making an empty session. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Complete the vim core for the composer: charwise and linewise visual mode with selection operators, and dot-repeat (`.`) driven by a structured repeat spec rather than replayed keystrokes. +- [#1166](https://github.com/PyModel/pythinker-code/pull/1166) [`dfcfdfd`](https://github.com/PyModel/pythinker-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Hide unused "New Session" entries from the web session list by default. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add a read-only verification agent and request independent checks when multi-step task lists close without verification. - Accept TodoWrite-compatible checklist items and show their active labels while tasks are in progress. - Cache successful local URL fetches for 15 minutes with a 50 MiB bound. - Preserve binary URL responses and save them through Kaos with MIME-derived filenames. - Propagate WebSearch cancellation, report live search progress, and remind responses to cite relevant results. - Retry transient LSP content-modified responses with bounded exponential backoff. - Filter gitignored files from location-based LSP results. - Restart configured language servers when a session enters or exits a worktree. - Surface bounded, deduplicated passive language-server diagnostics before the next model step. - Lighten the dark-theme periwinkle, clip structural tool rows instead of wrapping them, and copy mouse selections through both terminal and system clipboards. - Add policy-controlled HTTP hooks with secret-safe headers, cancellation, SSRF protection, and structured allow or block responses. - Support one-shot command and HTTP hooks through `once = true`. - Run command and HTTP hooks in the background through `async = true`. - Support `async_rewake` command hooks that run in the background and steer exit-code-2 blocking errors back into the main agent. +- [#1181](https://github.com/PyModel/pythinker-code/pull/1181) [`1dab2c2`](https://github.com/PyModel/pythinker-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Restore each session's scroll position when switching back to it in the web UI. - Add prompt and agent hook executors with argument substitution, structured allow/block results, model overrides, bounded timeouts, current-conversation context for prompt checks, and the existing read-only verification profile for agent checks. +- [#1181](https://github.com/PyModel/pythinker-code/pull/1181) [`1dab2c2`](https://github.com/PyModel/pythinker-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep the open side panel when switching between sessions in the web UI. - Support permission-rule `if` filters for pre-tool, post-tool, post-tool-failure, and permission-request hooks using each tool's existing rule matcher. - Emit `PermissionDenied` hooks with the rejected tool call and reason for policy and user approval denials. - Honor structured `PermissionDenied` retry guidance for policy denials without weakening explicit user rejections. - Run source-compatible `Setup(init)` hooks before `/init` generates project instructions and through the hidden `--init` startup flag. - Run `Setup(init)` and `SessionStart(startup)` through hidden `--init-only`, then close before mounting the TUI. - Run source-compatible `Setup(maintenance)` hooks through the hidden `--maintenance` startup flag before fresh or resumed session lifecycle hooks. +- [#1166](https://github.com/PyModel/pythinker-code/pull/1166) [`dfcfdfd`](https://github.com/PyModel/pythinker-code/commit/dfcfdfd9ddbe14fb6e358694394e1ddcc21b8911) - Remove the /sessions slash command from the web UI; the sidebar already covers session browsing. - Allow command hooks to select deterministic non-interactive PowerShell execution while preserving Bash as the default. - Prefer PowerShell 7 for native PowerShell tool calls and fall back to Windows PowerShell when it is unavailable. - Show configured hook status messages in a transient TUI spinner while command, HTTP, prompt, or agent hooks run. - Run blocking `TaskCreated` and `TaskCompleted` hooks around project task mutations, rolling back rejected creation and preventing rejected completion. - Report user and project AGENTS files loaded into the main context through source-compatible `InstructionsLoaded` hooks. - Report worktree entry and exit through source-compatible `CwdChanged` hooks. - Watch configured workspace files and emit source-compatible `FileChanged` hooks for add, change, and unlink events. - Accept absolute dynamic watch paths from structured hook output and replace the live `FileChanged` watcher set. - Reroot relative `FileChanged` hook paths when a worktree changes the session working directory. - Run matching `ConfigChange(user_settings)` hooks before in-app configuration writes and leave the file unchanged when a hook blocks. - Run `SessionStart` hooks with the `compact` source after successful compaction and before `PostCompact`. - Show command and HTTP hook targets plus `once` and `async` modes in `/hooks` without exposing headers. - Render compaction progress as elapsed seconds with a matching 40-cell percentage bar. - Add an experimental coordinator main-agent profile backed by the existing worker catalog and durable session profiles. - Continue explicit experimental token-target prompts until they approach the target or hit diminishing returns. - Carry the authorized-security and destructive-abuse boundary in the default agent prompt. - Keep default responses, progress updates, reasoning, and generated AGENTS instructions in English unless the user explicitly requests another response language. - Honor output styles that disable the bundled coding instructions. - Load bounded project memory into the main agent when experimental agent memory is enabled. - Classify persistent memories by user, feedback, project, or reference type and verify recalled source claims against live project state. - Warn when agent-memory topic files are older than one day and require live verification of recalled code claims. - Reload TUI keybindings automatically when `keybindings.json` changes. - Support `command:` keybindings and warn about duplicate entries, inactive contexts, and shortcuts that may be intercepted by the terminal or macOS while leaving soft-reserved bindings available. - Detect normalized shortcut conflicts across separate keybinding blocks and aliases. - Support source-compatible redraw, history search/navigation, model picker, cancel, and submit keybinding actions through native editor behavior. - Search the current project's persisted prompt history with Ctrl+R and restore the selected input. - Include a bounded Git repository snapshot with sanitized remote metadata, branches, configured user, dirty files, and recent commits in the main agent's startup context. - Honor skill `user-invocable` and `argument-hint` frontmatter in TUI command discovery, keep model access independent, and expand source-compatible skill directory and session placeholders. - Activate skills with `paths` frontmatter after successful matching Read, Write, or Edit calls. - Discover Git-ignore-safe nested project skill directories after successful file-tool access. - Run `context: fork` skills through foreground subagents for model and user invocations, honoring profile, model, effort, and scoped allowed-tool metadata. - Scope inline skill model, effort, and allowed-tool overrides to the active turn and restore the prior runtime afterward. - Validate and register session-scoped hooks from invoked skill frontmatter. - Preload profile-declared skills into a subagent's first prompt. - Validate profile frontmatter hooks, scope them to the child agent, and remove them after completion. - Run blocking `TeammateIdle` hooks before teammates go idle and continue the child when a hook requests more work. - Reload AGENTS instructions after successful compaction and emit `InstructionsLoaded(compact)` before post-compaction hooks. - Load descendant AGENTS instructions after successful file access, deduplicate them until compaction, and emit `InstructionsLoaded(nested_traversal)` with the triggering file. - Use configured `WorktreeCreate` and `WorktreeRemove` hooks as a VCS-neutral isolation backend for session and subagent worktrees. - Resume legacy task-tool calls through canonical TaskOutput and TaskStop aliases, including KillShell shell_id inputs. - Activate pending plugin changes through the source-compatible `/reload-plugins` command. - Resolve the source `/reset`, `/continue`, `/bashes`, and `/bug` aliases to their native TUI commands. - Capture private JavaScript heap snapshots and memory diagnostics through the hidden `/heapdump` command. - Expose the canonical Pythinker changelog through `/release-notes`. - Resolve the source `/plugin` command to Pythinker's native plugin manager. - Expand `/review` into a focused pull request review workflow that uses the existing agent and permission system. - Expand `/commit` and `/commit-push-pr` into guarded Git publishing workflows without bypassing hooks or attribution rules. - Manage persisted telemetry privacy through `/privacy-settings`, with immediate runtime opt-out. - Report native Shift-Enter and universal Ctrl-J multiline input support through `/terminal-setup`. - Report the running version, install source, package root, executable path, duplicate PATH installations, and resolved ripgrep source through `/doctor`. - Report Pythinker's validated cached CDN rollout version through `/doctor`. - Include config warnings, agent-profile parse failures, and plugin diagnostics in the TUI doctor report. - Warn through `/doctor` when custom-agent descriptions or MCP tool schemas consume excessive context. - Enable debug-level diagnostics and analyze a bounded current-session log tail through `/debug`. - Create Pythinker-native functional verifier skills through `/init-verifiers` and let the read-only verification agent invoke them without gaining mutation tools. - Expand `/security-review` and `/pr-comments` into focused GitHub review workflows through the existing agent and permission system. - Schedule recurring prompts through the built-in `/loop` workflow and execute the requested prompt immediately once. - Expose active built-in tools over stdio through `pythinker mcp serve`, preserving schema validation, permissions, and multimodal results. - Handle MCP form elicitation through the existing question UI with typed JSON Schema validation and paged fields. - Run source-compatible `Elicitation` and `ElicitationResult` hooks around MCP forms, including validated hook-supplied responses. +- [#1181](https://github.com/PyModel/pythinker-code/pull/1181) [`1dab2c2`](https://github.com/PyModel/pythinker-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Keep unsent composer attachments scoped to their session in the web UI, so switching sessions no longer leaks them into another session's next message. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add cancellable URL fetching with per-host approval and safe redirect handoff, plus allowed and blocked domain filters for web search. +- [#1161](https://github.com/PyModel/pythinker-code/pull/1161) [`d968642`](https://github.com/PyModel/pythinker-code/commit/d968642384f672295756394ee07a536dbfdb4dfd) - Show the first five sessions per workspace in the web sidebar instead of ten. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Expose bounded working-tree diffs through the SDK and a native `/diff` browser. +- [#1181](https://github.com/PyModel/pythinker-code/pull/1181) [`1dab2c2`](https://github.com/PyModel/pythinker-code/commit/1dab2c2268af6f74464b6573981c1e1bb4bda703) - Scope the web composer's up/down input history to the current session instead of sharing it across all sessions. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Add a configurable `[status_line]` TUI section to toggle footer status items, and show the YOLO indicator on a dedicated row beneath the model. +## 0.20.1 ### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Align Anthropic-compatible thinking profiles, output limits, and incomplete stream handling with model capabilities. +- [#1125](https://github.com/PyModel/pythinker-code/pull/1125) [`e9a3b7c`](https://github.com/PyModel/pythinker-code/commit/e9a3b7c83a623c7323da509ba885567c465093fc) - Add an `update` alias for the `pythinker upgrade` command. Run `pythinker update` to upgrade to the latest version. + +- [#1122](https://github.com/PyModel/pythinker-code/pull/1122) [`820d77a`](https://github.com/PyModel/pythinker-code/commit/820d77ab4cfad7752358a4692fd3d7def49f005d) - Show the done / in progress / pending breakdown of hidden todos in the collapsed todo panel. + +- [#1131](https://github.com/PyModel/pythinker-code/pull/1131) [`76c643b`](https://github.com/PyModel/pythinker-code/commit/76c643bcb6da447c8c47728b4f58512a7a11cfa6) - Cap completion tokens to the remaining context window for chat-completions providers, avoiding context-overflow and invalid max_tokens errors. + +- [#1120](https://github.com/PyModel/pythinker-code/pull/1120) [`e736349`](https://github.com/PyModel/pythinker-code/commit/e736349a7c8ff55b73e05cc0192dfaf0114745fa) - Add optional feedback attachments for diagnostic logs and codebase context. + +- [#1135](https://github.com/PyModel/pythinker-code/pull/1135) [`bf51fb7`](https://github.com/PyModel/pythinker-code/commit/bf51fb7a105b2f34a59ed4e83d2588e790cfb086) - Fix the local server failing to start on Windows after the first run because the persistent token file's synthesized mode was rejected as too permissive. + +- [#1102](https://github.com/PyModel/pythinker-code/pull/1102) [`9c97161`](https://github.com/PyModel/pythinker-code/commit/9c9716125e104b217540d0591229d03c6d676ead) - Harden the default system prompt and built-in tool descriptions: stop the agent from blocking on background tasks it should let run, keep its guidance matched to the tools each profile actually provides, and surface tool-result details (fetched-page mode, Grep match totals) it previously missed. + +- [#1127](https://github.com/PyModel/pythinker-code/pull/1127) [`184acf5`](https://github.com/PyModel/pythinker-code/commit/184acf5db521a964a8af9dfdb1502121a9be76dc) - Plugins can now declare hooks in their manifest to run scripts on lifecycle events. + +- [#1128](https://github.com/PyModel/pythinker-code/pull/1128) [`0886bff`](https://github.com/PyModel/pythinker-code/commit/0886bff2bcd3aed954990c948201d84787c0f3f3) - Add a --allowed-host flag to pythinker server run that lets extra Host header values pass the DNS-rebinding check, and include allow guidance in the 403 error message. Pass --allowed-host to allow an extra host. + +- [#1119](https://github.com/PyModel/pythinker-code/pull/1119) [`b0b2aee`](https://github.com/PyModel/pythinker-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep the terminal responsive in long conversations by caching rendered message lines. + +- [#1119](https://github.com/PyModel/pythinker-code/pull/1119) [`b0b2aee`](https://github.com/PyModel/pythinker-code/commit/b0b2aee8c5a496c2b679fc9dbbc05e3d1934d5d9) - Keep long sessions responsive by retaining only recent turns in the transcript and collapsing older steps within each turn. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Pulse the Bash activity marker while a command is running and keep the completed marker green. +- [#1121](https://github.com/PyModel/pythinker-code/pull/1121) [`81ba48f`](https://github.com/PyModel/pythinker-code/commit/81ba48f45534e133947c4e5e78907c2ad0db0b90) - Make the web chat input grow with its content and add an expandable editor for longer messages. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Accept `/branch` and `/rewind` as compatibility aliases for `/fork` and `/undo`. +- [#1133](https://github.com/PyModel/pythinker-code/pull/1133) [`f1c8175`](https://github.com/PyModel/pythinker-code/commit/f1c8175f9c5766f6a928fd07fb680e3159c564b0) - Fix the /web slash command not carrying the server token, so the opened web UI signs in automatically and the token is shown before the terminal exits. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Allow provider catalog refresh for providers that register an API key directly instead of an environment variable. +## 0.20.0 + +### Minor Changes + +- [#1079](https://github.com/PyModel/pythinker-code/pull/1079) [`2db5fc2`](https://github.com/PyModel/pythinker-code/commit/2db5fc20ecdf3212afd47e7c26195e428f8eddd5) - Add shell mode for running shell commands. + Type `!` in the input box to enable it. + The command output is visible to the AI. + For long-running commands, press Ctrl+B to move them to the background. + For example, you can run `!gh auth login` to sign in to the GitHub CLI without opening a new terminal, so Pythinker can use `gh`. + +- [#1088](https://github.com/PyModel/pythinker-code/pull/1088) [`0030f76`](https://github.com/PyModel/pythinker-code/commit/0030f76c5cc6465c5a6646c166375127d83696d3) - Add a confirmation prompt before installing third-party plugins. + +- [#1066](https://github.com/PyModel/pythinker-code/pull/1066) [`3554f7e`](https://github.com/PyModel/pythinker-code/commit/3554f7e7d6e472413aa7a9873d7a2eef5f2b819c) - Show update badges on the /plugins Installed tab, where Enter now installs the available update and I opens plugin details. + +- [#1025](https://github.com/PyModel/pythinker-code/pull/1025) [`5ef66dd`](https://github.com/PyModel/pythinker-code/commit/5ef66ddfeda2f23c40fc0cf53225cdaf3cc1147d) - Redesign `/plugins` as a single tabbed panel: **Installed** (manage installed + plugins — toggle, remove, MCP, details, reload), **Official** (Pythinker-maintained + marketplace plugins), **Third-party** (marketplace plugins from other + publishers), and **Custom** (install straight from a GitHub URL, zip URL, or + local path). `Tab` / `Shift-Tab` switch tabs. The Official and Third-party + catalogs load lazily, so `/plugins` opens instantly and keeps working offline — + a marketplace fetch failure is shown inline instead of closing the panel. The + tab strip is shared with the `/model` provider tabs via the new `renderTabStrip` + helper. + +- [#1006](https://github.com/PyModel/pythinker-code/pull/1006) [`60dfb68`](https://github.com/PyModel/pythinker-code/commit/60dfb68a2d4c342cfbad5f48d4d269fb6cdd43c0) - Add server authentication and safe `--host` exposure. The local server now + requires a per-start bearer token on all API and WebSocket calls (the CLI reads + it automatically), enforces Host/Origin checks, and gains `--host` with a + public-binding hardening tier: mandatory `PYTHINKER_CODE_PASSWORD`, TLS (or + `--insecure-no-tls`), auth-failure rate limiting, disabled remote + shutdown/terminals, and security response headers. See `packages/server/SECURITY.md`. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Update OpenAI Codex OAuth for the new model catalog: bump the models client_version gate to 0.145.0 so the gpt-5.6 family appears, carry each model's supported reasoning efforts into config, send real max effort on the wire (ultra maps in as max), clamp requests to what each model supports, and default Codex sign-in to the top supported effort. +- [#1040](https://github.com/PyModel/pythinker-code/pull/1040) [`6664038`](https://github.com/PyModel/pythinker-code/commit/66640380ebf60141994986beadf5347617f82814) - Replace silent AGENTS.md truncation with a visible warning in the TUI status bar and web UI. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Align context usage displays with 1024-based units and ceiled percentages. +- [#1101](https://github.com/PyModel/pythinker-code/pull/1101) [`3ea6ac2`](https://github.com/PyModel/pythinker-code/commit/3ea6ac278d2e57bb859ab423704bbd0fb2033c72) - Show the plan body and approach choices in the plan review card when exiting plan mode in the web UI. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Prevent repeated session debug exports from overwriting earlier archives by including a timestamp in the default filename. +- [#1103](https://github.com/PyModel/pythinker-code/pull/1103) [`18f7c34`](https://github.com/PyModel/pythinker-code/commit/18f7c34a0739dab454af1f09d951a1bbf278cccb) - Show a line-by-line diff when the agent edits or writes a file in the web chat. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Default agent reasoning and responses to English unless the user explicitly requests another language. +### Patch Changes + +- [#1072](https://github.com/PyModel/pythinker-code/pull/1072) [`a86bb97`](https://github.com/PyModel/pythinker-code/commit/a86bb9757d99f32983e82a6a82fd3ccaab691b1a) - Improve the image paste hint. + +- [#1076](https://github.com/PyModel/pythinker-code/pull/1076) [`500677a`](https://github.com/PyModel/pythinker-code/commit/500677ab8baf9081b73a35df5fbbcfc49cb2f9b7) - Fix Ctrl-C during compaction so it clears a pending editor draft first instead of cancelling immediately. + +- [#1067](https://github.com/PyModel/pythinker-code/pull/1067) [`0e227ba`](https://github.com/PyModel/pythinker-code/commit/0e227ba18aec793aa4c233be7c578068ae91e604) - Fix explore subagents silently losing git context when git commands time out or the directory is not a repository. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Increase the default per-step LLM retry budget from 3 to 10 attempts. +- [#1075](https://github.com/PyModel/pythinker-code/pull/1075) [`3aaf1e5`](https://github.com/PyModel/pythinker-code/commit/3aaf1e58037c4045aaa3b9fbabaffa158c60d2ca) - Fix a startup crash on Linux caused by an unhandled native clipboard error. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Allow DynamicWorkflow items to be complete prompts when no template is supplied. +- [#1094](https://github.com/PyModel/pythinker-code/pull/1094) [`8ee5c0f`](https://github.com/PyModel/pythinker-code/commit/8ee5c0ff813d361733226a1606e7c724e5e38f2e) - Fix the terminal window repeatedly losing focus on Linux Wayland, which broke IME input. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Show DynamicWorkflow request failures once instead of repeating the reason for every member. +- [#1057](https://github.com/PyModel/pythinker-code/pull/1057) [`ee69e16`](https://github.com/PyModel/pythinker-code/commit/ee69e16dc8fb18153d7ddff04bef1f4fc593688a) - Fix MCP server working directories when sessions are hosted by the web server. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Pin @agentclientprotocol/sdk to ^0.23.0 to restore the unstable session-model API the ACP adapter implements, and fix the adapter's typecheck. +- [#1064](https://github.com/PyModel/pythinker-code/pull/1064) [`a752a53`](https://github.com/PyModel/pythinker-code/commit/a752a5309b3c456f7da0e6141bcd435b497d127a) - Fix truncated skill descriptions missing an ellipsis in the model's skill listing. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Rename the stale afk reference to auto in the built-in MCP configuration guidance. +- [#903](https://github.com/PyModel/pythinker-code/pull/903) [`bbd8a1a`](https://github.com/PyModel/pythinker-code/commit/bbd8a1a947ba26c0e59f98819cab9e20898ff0b7) - Fix `pythinker web` and `/web` failing to start the background server daemon on Windows with `spawn EFTYPE` when the CLI is installed via npm/pnpm or run from source. The official single-binary install script was not affected. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Prevent agent idle cleanup failures from surfacing as unhandled rejections. +- [#1070](https://github.com/PyModel/pythinker-code/pull/1070) [`ff17715`](https://github.com/PyModel/pythinker-code/commit/ff177155ca630248bcd692421faab21e7b5be069) - Stop auto-dismissing questions in the web UI after 60 seconds so they wait for the user's answer. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Translate binary download failures through the standard fetch error path. +- [#1097](https://github.com/PyModel/pythinker-code/pull/1097) [`27ef516`](https://github.com/PyModel/pythinker-code/commit/27ef5166955b5deaecc367a4b3393909b0ccc9f9) - Add a hint to the per-turn step limit error pointing users to the loop_control.max_steps_per_turn config option. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix Unicode-safe Vim editing, application shortcuts, selector state, key chords, mouse-selection auto-scrolling, and active-tab contrast. +- [#1062](https://github.com/PyModel/pythinker-code/pull/1062) [`ea6a4bf`](https://github.com/PyModel/pythinker-code/commit/ea6a4bfe6ef8914f67f254f24b0c5c543c48a341) - Preserve full tool output logs when previews are truncated and link background task completion notifications to saved output. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Preserve graceful shutdown and exit codes under repeated signals or closed output streams, persist provider removals and cleared defaults, reject unsafe marketplace refs, and recover safely from stalled marketplace loads and interrupted update installs. +- [#1086](https://github.com/PyModel/pythinker-code/pull/1086) [`fe667d7`](https://github.com/PyModel/pythinker-code/commit/fe667d7c2ef113aef8a9546148f980d9adf560a3) - `/reload` now refreshes the assistant's view of plugin skills, so plugin changes take effect in the current session instead of requiring a new one. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix ctrl+b / ctrl+f paging in the approval preview and task output viewer under the Kitty keyboard protocol. Both shortcuts compared raw C0 bytes, so they did nothing in terminals that send CSI-u — including VSCode's integrated terminal — while the page-up/page-down checks beside them worked. +- [#1081](https://github.com/PyModel/pythinker-code/pull/1081) [`8fc6aa5`](https://github.com/PyModel/pythinker-code/commit/8fc6aa5f6842aa78acf8f23912342b721efcf7a9) - Sync session title changes across all connected clients in server mode. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Keep Dynamic Workflow results bounded and correctly decoded while preventing undone workflows from receiving late events. +- [#1078](https://github.com/PyModel/pythinker-code/pull/1078) [`75ca3b2`](https://github.com/PyModel/pythinker-code/commit/75ca3b21609d7197bb2c9b4389901595840ac7e3) - Add Ctrl+U and Ctrl+D as page up and page down shortcuts in the task output viewer. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Preserve MCP prompt client binding during skill activation. +- [#1069](https://github.com/PyModel/pythinker-code/pull/1069) [`d18aa16`](https://github.com/PyModel/pythinker-code/commit/d18aa1666a09b038d5a107e9a37fff1031b2e847) - Reduce streaming redraw cost for long assistant messages with code blocks. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Honor an explicit thinking off setting on OpenAI-compatible providers. +- [#1112](https://github.com/PyModel/pythinker-code/pull/1112) [`6a97d0b`](https://github.com/PyModel/pythinker-code/commit/6a97d0bf431bc7038ce801da21164a67e07422d8) - Add a copy button to user messages in the web chat. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Correct the YOLO and Auto permission mode descriptions in CLI help output and ACP session mode selectors. +- [#1035](https://github.com/PyModel/pythinker-code/pull/1035) [`ea03f30`](https://github.com/PyModel/pythinker-code/commit/ea03f30e5174825049ed4dfedebf8e43fbe751a4) - Render LaTeX display math (`$$…$$`) in the web chat via KaTeX. Single `$` is intentionally left as literal text, so prices, env vars, and shell paths (e.g. `$PATH`, `$5/$10`, `$HOME/bin`) are never swallowed as a formula. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Correct the YOLO and Auto permission mode descriptions in the web slash command list and mobile permission sheet. +- [#1084](https://github.com/PyModel/pythinker-code/pull/1084) [`d6e5246`](https://github.com/PyModel/pythinker-code/commit/d6e524682d9fb95460fceb86e17632ed858f7fcb) - Page the web session list per workspace so the first screen no longer fetches every session up front. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Clarify that YOLO auto-approves tool actions while Auto runs fully autonomously without asking questions. +- [#1113](https://github.com/PyModel/pythinker-code/pull/1113) [`6194d3f`](https://github.com/PyModel/pythinker-code/commit/6194d3fad3b53e6c2b80c422fe98043145494655) - Keep the web session sidebar from re-rendering on every streaming token. The + event reducer now reuses the `sessions` array reference for events that do not + change sessions, so the sidebar computeds (`sessionsForView` / `workspaceGroups` + / `mergedWorkspaces`) are no longer dirtied by unrelated high-frequency events. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix TypeScript errors in the TUI welcome/logo components and their tests (index-signature env access, possibly-undefined logo rows). +- [#1087](https://github.com/PyModel/pythinker-code/pull/1087) [`884b65a`](https://github.com/PyModel/pythinker-code/commit/884b65a04014be8d68ffd406f89fc2d26af6e62c) - Fix duplicate session snapshot reloads in the bundled web UI during resync. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Prevent silent exits from clipboard image failures and report unhandled promise rejections in crash telemetry. +- [#1109](https://github.com/PyModel/pythinker-code/pull/1109) [`d554f9a`](https://github.com/PyModel/pythinker-code/commit/d554f9ac8771be09b5c9a56943167dd45108dc4f) - Show the full accumulated progress of a subagent in its detail panel, with concise tool-call summaries instead of raw JSON. + +- [#1065](https://github.com/PyModel/pythinker-code/pull/1065) [`4b837d6`](https://github.com/PyModel/pythinker-code/commit/4b837d6bfbf3850807b5f88ccdd10f31e69b019c) - Create missing parent directories automatically when writing a file. + +## 0.19.2 + +### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Preserve extended Unicode characters when normalizing replacement quotes. +- [#999](https://github.com/PyModel/pythinker-code/pull/999) [`6b68aa8`](https://github.com/PyModel/pythinker-code/commit/6b68aa85e2a58cfdaacba5580f66a6a74550ccf6) - Add `-c` as a shorthand for `--continue`. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix sessions getting stuck after a provider records an assistant message with no sendable content. +- [#1028](https://github.com/PyModel/pythinker-code/pull/1028) [`be77d5d`](https://github.com/PyModel/pythinker-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Show a transient footer hint when an image is detected in the clipboard, displaying the platform-appropriate paste shortcut. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix duplicate workspace groups on Windows when the same folder is opened with different path spellings, keeping all of the folder's sessions in one merged group. +- [#1004](https://github.com/PyModel/pythinker-code/pull/1004) [`d70c3a8`](https://github.com/PyModel/pythinker-code/commit/d70c3a8c0121f55e5f29f9a2ad01b17df449467a) - Show the command in running Bash tool cards and allow expanding it with Ctrl+O before the result arrives. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Correct the YOLO mode notice shown when replaying a session. +- [#1009](https://github.com/PyModel/pythinker-code/pull/1009) [`e47de61`](https://github.com/PyModel/pythinker-code/commit/e47de610e4de9b11ccd182c0c16387f9d3fb0de4) - Add a Ctrl+T shortcut to expand and collapse a truncated todo list. + +- [#1028](https://github.com/PyModel/pythinker-code/pull/1028) [`be77d5d`](https://github.com/PyModel/pythinker-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix stale rows occasionally leaving duplicate input boxes after tall content shrinks. + +- [#1028](https://github.com/PyModel/pythinker-code/pull/1028) [`be77d5d`](https://github.com/PyModel/pythinker-code/commit/be77d5da03b96ebc24169ef563be1dc1c545590f) - Fix inline images being rendered as broken escape sequences in the transcript. + +- [#1027](https://github.com/PyModel/pythinker-code/pull/1027) [`c240bfa`](https://github.com/PyModel/pythinker-code/commit/c240bfab7d2b00d41b993f681be612f2db45baa7) - Fix resume not realigning a tool call that was interrupted mid-history. The synthetic interrupted result is now closed in place at the next step boundary, so later turns and deferred messages keep their recorded order instead of only the trailing exchange being repaired. The `/messages` wire transcript reducer mirrors the same closure so its folded length stays aligned with live history, preventing the later turn from being duplicated/reordered. Replay also drops a tool result whose call is no longer awaiting one, so a stale interrupted result left at the log tail by an older resume of a damaged session is not re-applied as a duplicate. + +- [#1012](https://github.com/PyModel/pythinker-code/pull/1012) [`fd16ffb`](https://github.com/PyModel/pythinker-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Show subcommand suggestions after Tab-completing a slash command name. + +- [#1012](https://github.com/PyModel/pythinker-code/pull/1012) [`fd16ffb`](https://github.com/PyModel/pythinker-code/commit/fd16ffb80a90fda8a611a27a158b9b7a33e13303) - Fix the Tab key unexpectedly opening the file completion list. + +- [#1044](https://github.com/PyModel/pythinker-code/pull/1044) [`9d197e0`](https://github.com/PyModel/pythinker-code/commit/9d197e0f67c879306b9d7659d66e9295e63faa5a) - Fix clipboard copy actions in the web UI when served over plain HTTP. + +- [#1032](https://github.com/PyModel/pythinker-code/pull/1032) [`a753b05`](https://github.com/PyModel/pythinker-code/commit/a753b0535e44f624289715bd560cf1346149e786) - Fix code blocks nested inside list items rendering blank in the web chat after a turn finishes generating. + +- [#1015](https://github.com/PyModel/pythinker-code/pull/1015) [`83384ee`](https://github.com/PyModel/pythinker-code/commit/83384ee6d46b37c00b7b8f160a7c48aebbd6921e) - Fix the composer's ↑/↓ input-history recall doing nothing right after the first message of a new session. The history is now persisted to localStorage and re-read on mount, so the docked composer no longer starts empty when it takes over from the empty-session composer. Slash commands are now recorded too — both typed-and-submitted and ones picked from the slash menu — so they can be recalled like plain messages. + +- [#1003](https://github.com/PyModel/pythinker-code/pull/1003) [`e15edfd`](https://github.com/PyModel/pythinker-code/commit/e15edfd017506fde396b8b0dcf68008b61b39752) - Fix the web question prompt missing the free-text Other option. + +- [#1056](https://github.com/PyModel/pythinker-code/pull/1056) [`b93e936`](https://github.com/PyModel/pythinker-code/commit/b93e9365b68d53f8f1a148e7349a5865b1b669a8) - Fix yolo mode in the web app auto-approving plan reviews and sensitive file access. + +- [#971](https://github.com/PyModel/pythinker-code/pull/971) [`b84704b`](https://github.com/PyModel/pythinker-code/commit/b84704bff39ae5cb382d2a8dc0911db286e84ead) - Read large text files in bounded memory and read tail lines without scanning whole files. + +- [#1020](https://github.com/PyModel/pythinker-code/pull/1020) [`9c553e4`](https://github.com/PyModel/pythinker-code/commit/9c553e4bf7d0a2c09030212fe06577343ea76a60) - Add an Alt+S shortcut in the model picker to switch the model for the current session only, without saving it as the default. + +- [#1043](https://github.com/PyModel/pythinker-code/pull/1043) [`27df39c`](https://github.com/PyModel/pythinker-code/commit/27df39c7ed2b012815c380a33fe56bd37c7fc7c1) - Fix web chat stop actions so stale prompt ids fall back to cancelling the active session. + +- [#1036](https://github.com/PyModel/pythinker-code/pull/1036) [`866b91c`](https://github.com/PyModel/pythinker-code/commit/866b91c8f5dc98dfc18e5c658beaa11afea5032e) - Reorganize the web app's components into area subdirectories (chat/settings/dialogs/mobile) and refresh the component path comments. + +- [#1042](https://github.com/PyModel/pythinker-code/pull/1042) [`dc6b9ef`](https://github.com/PyModel/pythinker-code/commit/dc6b9ef02bf7583d166c8c5b001a960329c225f8) - Add a development-mode indicator to the web sidebar for local development. + +- [#1047](https://github.com/PyModel/pythinker-code/pull/1047) [`98d3e5b`](https://github.com/PyModel/pythinker-code/commit/98d3e5b71d5760475f7a5a23b2b794584d12b89b) - Keep the web sidebar's workspace order stable and let workspaces be reordered by drag-and-drop, persisted locally instead of following recent activity; sessions now also float to the top of their group as soon as a new message arrives. + +- [#1034](https://github.com/PyModel/pythinker-code/pull/1034) [`603a767`](https://github.com/PyModel/pythinker-code/commit/603a7679de91e221802a7f7b0ab7df23c7e5526c) - Extract the composer's image/video attachment handling into a reusable composable. + +- [#1031](https://github.com/PyModel/pythinker-code/pull/1031) [`2bfd686`](https://github.com/PyModel/pythinker-code/commit/2bfd6860e487f902be53fd5f52f03e66d1839ae2) - Extract the composer's text state and per-session draft persistence into a reusable composable. + +- [#1011](https://github.com/PyModel/pythinker-code/pull/1011) [`fb780fc`](https://github.com/PyModel/pythinker-code/commit/fb780fce9665e2119cee6d0bc7f85895c6970865) - Extract the composer's shell-style input-history recall into a reusable composable. + +- [#1030](https://github.com/PyModel/pythinker-code/pull/1030) [`661c1fb`](https://github.com/PyModel/pythinker-code/commit/661c1fbe5b026ec32d80696290a18313b24eafef) - Extract the composer's @-mention menu logic into a reusable composable. + +- [#1026](https://github.com/PyModel/pythinker-code/pull/1026) [`318c964`](https://github.com/PyModel/pythinker-code/commit/318c964f074123ad228cbddcf7809fa4baaa7fb2) - Extract the composer's slash-command menu logic into a reusable composable. + +- [#1045](https://github.com/PyModel/pythinker-code/pull/1045) [`ac1882f`](https://github.com/PyModel/pythinker-code/commit/ac1882fe28c906904ffaacd8434bb20e689d6677) - Persist the collapsed state of workspace groups in the web sidebar across page reloads. + +- [#1001](https://github.com/PyModel/pythinker-code/pull/1001) [`ea1b33b`](https://github.com/PyModel/pythinker-code/commit/ea1b33b6743b822aa5083dbeb2d5e84a78b0ab3d) - Extract pure turn-rendering helpers out of the chat pane into their own module. + +- [#1010](https://github.com/PyModel/pythinker-code/pull/1010) [`a2650f8`](https://github.com/PyModel/pythinker-code/commit/a2650f85d467707e7c85d22cff590f68852d33f3) - Extract the beta conversation outline (table of contents) into its own component. + +- [#998](https://github.com/PyModel/pythinker-code/pull/998) [`3e4793d`](https://github.com/PyModel/pythinker-code/commit/3e4793d6111059cbfb97159f682ed4bd7a33441d) - Extract the workspace group rendering out of the sidebar into its own component. + +- [#985](https://github.com/PyModel/pythinker-code/pull/985) [`92c2cf0`](https://github.com/PyModel/pythinker-code/commit/92c2cf0ef57f00928d337bcfeb1d7eff9b0d0f7f) - Allow the web sidebar and detail panel to be resized up to the available viewport width, keeping their resize handles reachable on narrow windows. + +- [#1033](https://github.com/PyModel/pythinker-code/pull/1033) [`b1e6b64`](https://github.com/PyModel/pythinker-code/commit/b1e6b6431903fde002fdddbdfcabfab39f3ef5c5) - Optimize the loading tips display. + +## 0.19.1 + +### Patch Changes + +- [#992](https://github.com/PyModel/pythinker-code/pull/992) [`7341fb4`](https://github.com/PyModel/pythinker-code/commit/7341fb4979523d4429ccf9177b5e3907f544d8c0) - Fix ACP editors such as Zed failing to start a new thread. + +- [#984](https://github.com/PyModel/pythinker-code/pull/984) [`da81858`](https://github.com/PyModel/pythinker-code/commit/da81858802127cb8bb8ed2deaa1989793b356adf) - Clear all per-session state when a session is archived or removed, so archived sessions no longer leave orphaned data behind. + +- [#978](https://github.com/PyModel/pythinker-code/pull/978) [`d4ae02d`](https://github.com/PyModel/pythinker-code/commit/d4ae02d82e9da0d163ea4235a54d6535c591172e) - Fix the web sidebar's unread dots getting out of sync across browser tabs. + +- [#979](https://github.com/PyModel/pythinker-code/pull/979) [`8c6cade`](https://github.com/PyModel/pythinker-code/commit/8c6cade69efa42fdcc280f51a283ea6f717d62fc) - Consolidate web client localStorage access and split the root state store and app shell into focused composables. + +## 0.19.0 + +### Minor Changes + +- [#812](https://github.com/PyModel/pythinker-code/pull/812) [`c0eeca2`](https://github.com/PyModel/pythinker-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Added the ability to add extra workspace directories: + + - Use the `/add-dir ` command to add extra working directories to the current session, or remember them for the project. + - Use `pythinker --add-dir ` to add them on startup. + - Project-level local config is now managed in `.pythinker-code/local.toml`; we recommend adding it to your `.gitignore`. + +- [#975](https://github.com/PyModel/pythinker-code/pull/975) [`c5c1834`](https://github.com/PyModel/pythinker-code/commit/c5c18347251221fab74e4f452ac4910116c4224d) - Speed up session snapshot loading with a direct disk reader and a request timeout safeguard, keeping the previous path as a legacy fallback. + +### Patch Changes + +- [#910](https://github.com/PyModel/pythinker-code/pull/910) [`7644f10`](https://github.com/PyModel/pythinker-code/commit/7644f1036ca1079e4527c0b1c825ec5384d6d8da) - Fix provider requests failing when restored conversation history contains empty text content blocks. + +- [#963](https://github.com/PyModel/pythinker-code/pull/963) [`4292ae9`](https://github.com/PyModel/pythinker-code/commit/4292ae9f9bc49e9edaaaeae50dbddabbd4b9bb25) - Surface provider safety-policy blocks instead of silently treating them as completed turns, and prevent the context token count from dropping to zero after a filtered response. + +- [#970](https://github.com/PyModel/pythinker-code/pull/970) [`2730079`](https://github.com/PyModel/pythinker-code/commit/27300797f2149900219b05dda49dce65e71fa85a) - Detect the real image format from file contents when reading media, so a mismatched filename extension no longer produces a data URL the model API rejects. + +- [#977](https://github.com/PyModel/pythinker-code/pull/977) [`d521932`](https://github.com/PyModel/pythinker-code/commit/d521932c3e99a0c5fa1d5d658cf1cd64f0306a75) - Stop showing unread dots on cancelled or failed sessions in the web sidebar. + +- [#957](https://github.com/PyModel/pythinker-code/pull/957) [`b57fc90`](https://github.com/PyModel/pythinker-code/commit/b57fc905fe480aac07839dd0213768dbeb2a8002) - Fix commands flashing an empty console window on Windows. + +- [#821](https://github.com/PyModel/pythinker-code/pull/821) [`ba64072`](https://github.com/PyModel/pythinker-code/commit/ba64072559c1e9bb3447ede39991ac2e8bdb7645) - Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel. + +- [#812](https://github.com/PyModel/pythinker-code/pull/812) [`c0eeca2`](https://github.com/PyModel/pythinker-code/commit/c0eeca24692edd736eecd3c2541d7566bac9f80f) - Polish file mention UX. + +- [#974](https://github.com/PyModel/pythinker-code/pull/974) [`d434d8f`](https://github.com/PyModel/pythinker-code/commit/d434d8f0d809599f4ae7de77b58e337bfd4ebcc9) - Unify image format detection when sniffing fails. + +- [#958](https://github.com/PyModel/pythinker-code/pull/958) [`98905eb`](https://github.com/PyModel/pythinker-code/commit/98905eb409ec643fd916a13beecec85212f834bd) - Show longer branch names in the web chat header and expose the full name on hover. + +- [#964](https://github.com/PyModel/pythinker-code/pull/964) [`4223739`](https://github.com/PyModel/pythinker-code/commit/42237392ddc3a0816c045da23e77c4875cc692e5) - Keep the web page title fixed instead of changing with the session or workspace name. + +- [#973](https://github.com/PyModel/pythinker-code/pull/973) [`3b9938b`](https://github.com/PyModel/pythinker-code/commit/3b9938b4c3a386394ed4d35c7b89b48878476977) - Consolidate web client localStorage access and decouple appearance/notification state into dedicated modules. + +## 0.18.0 + +### Minor Changes + +- [#888](https://github.com/PyModel/pythinker-code/pull/888) [`58898de`](https://github.com/PyModel/pythinker-code/commit/58898de0200d6626ca634e344fe85b860abcfd1b) - Add an environment variable to cap AgentDynamicWorkflow concurrency during the initial ramp, so large dynamic workflows do not trip provider rate limits as easily. + +- [#895](https://github.com/PyModel/pythinker-code/pull/895) [`495fe8c`](https://github.com/PyModel/pythinker-code/commit/495fe8c674d654cdf87217ca4ada775507f861f6) - Add instant session search to the web sidebar, filtering by title and the last user prompt. + +### Patch Changes -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Include the underlying network cause in OAuth connection error messages instead of only reporting a generic fetch failure. +- [#896](https://github.com/PyModel/pythinker-code/pull/896) [`de610de`](https://github.com/PyModel/pythinker-code/commit/de610deb5f760606b82cc595e59c5176cc66ce82) - Fix the web workspace session count so it drops to 0 after archiving the last session instead of staying at 1. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Resolve every bare `solid-js` import to OpenTUI's client runtime so Solid signal updates reach the terminal buffer. +- [#876](https://github.com/PyModel/pythinker-code/pull/876) [`49183d8`](https://github.com/PyModel/pythinker-code/commit/49183d8729e3e7d361a253dc5c68f409e6382ba9) - Suggest `/reload` alongside `/new` in plugin-change hints. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Stop showing a status message after successful automatic keybinding reloads. +- [#867](https://github.com/PyModel/pythinker-code/pull/867) [`d1dc2a3`](https://github.com/PyModel/pythinker-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Redesign the web OAuth login dialog: lead with a single "Authorize in browser" button that opens the verification link with the device code already embedded, demote manual code entry to a clearly secondary fallback, and drop the duplicate open-browser and cancel controls so the order of steps is unambiguous. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Record and close tool calls that never ran after an interrupted model response. +- [#867](https://github.com/PyModel/pythinker-code/pull/867) [`d1dc2a3`](https://github.com/PyModel/pythinker-code/commit/d1dc2a3e77ec1422d60cb008c5520a44a2ed7c00) - Fix the web login slash command description to match the browser authorization flow. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Replay empty thinking content verbatim on preserved-thinking endpoints. +- [#893](https://github.com/PyModel/pythinker-code/pull/893) [`d7ec056`](https://github.com/PyModel/pythinker-code/commit/d7ec05686a09580f9ffd99f6ef26385aed8eb02c) - Add scroll-up lazy loading for older messages in the web chat session view, and fix the "new messages" pill overlapping the composer dock. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Scope inferred Anthropic thinking profiles to non-managed Anthropic-compatible providers. +- [#882](https://github.com/PyModel/pythinker-code/pull/882) [`8ab9e96`](https://github.com/PyModel/pythinker-code/commit/8ab9e969637ffee18b09a0b265ffa860c5a2e11c) - Fix the web app only loading the 20 most recent sessions; it now follows pagination so older sessions are reachable. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix the built-in URL fetch tool's network safeguards so crafted domains and redirect chains cannot reach loopback or internal network services. +- [#889](https://github.com/PyModel/pythinker-code/pull/889) [`23277a5`](https://github.com/PyModel/pythinker-code/commit/23277a574c7e0782c04f62e10370494247be3a66) - Show the connected server version in the web settings General tab. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Render the slash command menu below the composer and give the selected command a themed pointer and muted description lines. +- [#881](https://github.com/PyModel/pythinker-code/pull/881) [`7bc3d99`](https://github.com/PyModel/pythinker-code/commit/7bc3d99933b0bbc3f9188a2b02bcc90e81623f72) - Keep the highlighted web slash command visible while navigating a long slash menu. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Make MCP startup status lines transient in the TUI: connected/disabled rows show a success mark and disappear after 3 seconds instead of permanently cluttering the transcript, while failed and needs-auth rows stay visible. The welcome-header aggregate remains the durable indicator. +- [#878](https://github.com/PyModel/pythinker-code/pull/878) [`a74a6b7`](https://github.com/PyModel/pythinker-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Allow long web slash command names and descriptions to wrap without overflowing the slash menu. -- [`357c850`](https://github.com/PyModel/pythinker-code/commit/357c850cdaf1c8be669566ff7a88af895bcf5c4a) - Fix display-width measurement on the OpenTUI render path: strip ANSI escapes before measuring, segment by grapheme cluster so ZWJ emoji and skin-tone modifiers count once, and expand tabs to match the legacy renderer. Footer, composer, and dialog-list text no longer mis-truncate when coloured or containing emoji. +- [#878](https://github.com/PyModel/pythinker-code/pull/878) [`a74a6b7`](https://github.com/PyModel/pythinker-code/commit/a74a6b7f6b1d13d24eae356a2208c012128b180d) - Fix web slash skill selection sending immediately and allow slash search to match skill names by substring. ## 0.17.1 ### Patch Changes -- [#861](https://github.com/PythoughtsAI/pythinker-code/pull/861) [`bd09795`](https://github.com/PythoughtsAI/pythinker-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Prevent the web login dialog from closing when clicking the backdrop. +- [#861](https://github.com/PyModel/pythinker-code/pull/861) [`bd09795`](https://github.com/PyModel/pythinker-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Prevent the web login dialog from closing when clicking the backdrop. -- [#860](https://github.com/PythoughtsAI/pythinker-code/pull/860) [`0e2877b`](https://github.com/PythoughtsAI/pythinker-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Stop the background local server from locking the directory it was started in. +- [#860](https://github.com/PyModel/pythinker-code/pull/860) [`0e2877b`](https://github.com/PyModel/pythinker-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Stop the background local server from locking the directory it was started in. -- [#860](https://github.com/PythoughtsAI/pythinker-code/pull/860) [`0e2877b`](https://github.com/PythoughtsAI/pythinker-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Fix the local server failing to start in the background on the native binary. +- [#860](https://github.com/PyModel/pythinker-code/pull/860) [`0e2877b`](https://github.com/PyModel/pythinker-code/commit/0e2877bee347466ed6cc8afda9f9faf338069012) - Fix the local server failing to start in the background on the native binary. -- [#861](https://github.com/PythoughtsAI/pythinker-code/pull/861) [`bd09795`](https://github.com/PythoughtsAI/pythinker-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Group the default model dropdown in web settings by provider. +- [#861](https://github.com/PyModel/pythinker-code/pull/861) [`bd09795`](https://github.com/PyModel/pythinker-code/commit/bd0979578bcad5fe3bf989e022b7823824f3f25c) - Group the default model dropdown in web settings by provider. ## 0.17.0 ### Minor Changes -- [#625](https://github.com/PythoughtsAI/pythinker-code/pull/625) [`9a8fea5`](https://github.com/PythoughtsAI/pythinker-code/commit/9a8fea5c85177cd887896108c05ba9e174f28250) - Add the server-hosted web UI and the CLI commands that power it: +- [#625](https://github.com/PyModel/pythinker-code/pull/625) [`9a8fea5`](https://github.com/PyModel/pythinker-code/commit/9a8fea5c85177cd887896108c05ba9e174f28250) - Add the server-hosted web UI and the CLI commands that power it: - `pythinker server` to start, stop, and manage the local server. - `pythinker web` to open the server-hosted web UI in a browser. @@ -712,372 +1548,372 @@ ### Patch Changes -- [#838](https://github.com/PythoughtsAI/pythinker-code/pull/838) [`843a731`](https://github.com/PythoughtsAI/pythinker-code/commit/843a731097fc18b2e41ab0405b5fbcb6149ba55c) - Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level. +- [#838](https://github.com/PyModel/pythinker-code/pull/838) [`843a731`](https://github.com/PyModel/pythinker-code/commit/843a731097fc18b2e41ab0405b5fbcb6149ba55c) - Show the underlying connection error when OAuth token refresh fails after internal retries, instead of prompting for login. Token refresh failures are no longer re-retried at the agent loop level. -- [#849](https://github.com/PythoughtsAI/pythinker-code/pull/849) [`254f946`](https://github.com/PythoughtsAI/pythinker-code/commit/254f946a506b01df7a559ed63bd8d705e9fa7496) - Skip debug TPS when the output stream is too short to measure reliably. +- [#849](https://github.com/PyModel/pythinker-code/pull/849) [`254f946`](https://github.com/PyModel/pythinker-code/commit/254f946a506b01df7a559ed63bd8d705e9fa7496) - Skip debug TPS when the output stream is too short to measure reliably. -- [#833](https://github.com/PythoughtsAI/pythinker-code/pull/833) [`a71b2e3`](https://github.com/PythoughtsAI/pythinker-code/commit/a71b2e3123ff8454f725b3d24e8c985608c5c4f9) - Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history. +- [#833](https://github.com/PyModel/pythinker-code/pull/833) [`a71b2e3`](https://github.com/PyModel/pythinker-code/commit/a71b2e3123ff8454f725b3d24e8c985608c5c4f9) - Restore the turn counter from persisted loop events on resume so post-resume turns no longer reuse turn ids that already appear in history. -- [#853](https://github.com/PythoughtsAI/pythinker-code/pull/853) [`05fe759`](https://github.com/PythoughtsAI/pythinker-code/commit/05fe7595ab9bac8230fd9f2fe7bdbaaa157ddc9b) - Fix the web login page and no-workspace conversation startup flow. +- [#853](https://github.com/PyModel/pythinker-code/pull/853) [`05fe759`](https://github.com/PyModel/pythinker-code/commit/05fe7595ab9bac8230fd9f2fe7bdbaaa157ddc9b) - Fix the web login page and no-workspace conversation startup flow. ## 0.16.0 ### Minor Changes -- [#788](https://github.com/PythoughtsAI/pythinker-code/pull/788) [`efdf8a1`](https://github.com/PythoughtsAI/pythinker-code/commit/efdf8a1b2d4e906fbb35620083c3e7b490e0e88a) - Add a built-in `pythinker dashboard` command that launches the session dashboard in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `pythinker dashboard ` deep-links. +- [#788](https://github.com/PyModel/pythinker-code/pull/788) [`efdf8a1`](https://github.com/PyModel/pythinker-code/commit/efdf8a1b2d4e906fbb35620083c3e7b490e0e88a) - Add a built-in `pythinker vis` command that launches the session visualizer in your browser, pointed at your local sessions. Supports `--port`/`--host`, `--no-open`, and `pythinker vis ` deep-links. ### Patch Changes -- [#790](https://github.com/PythoughtsAI/pythinker-code/pull/790) [`d0d5821`](https://github.com/PythoughtsAI/pythinker-code/commit/d0d58219007cd9d7355f1ea8900e9777b66abda2) - Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers. +- [#790](https://github.com/PyModel/pythinker-code/pull/790) [`d0d5821`](https://github.com/PyModel/pythinker-code/commit/d0d58219007cd9d7355f1ea8900e9777b66abda2) - Stop Anthropic-compatible providers from reading ambient Anthropic shell credentials and custom headers. -- [#809](https://github.com/PythoughtsAI/pythinker-code/pull/809) [`6f442bd`](https://github.com/PythoughtsAI/pythinker-code/commit/6f442bd8cde29e21526fa36c9836e2d4c282b4bf) - Add configurable banner display frequencies with local display state. +- [#809](https://github.com/PyModel/pythinker-code/pull/809) [`6f442bd`](https://github.com/PyModel/pythinker-code/commit/6f442bd8cde29e21526fa36c9836e2d4c282b4bf) - Add configurable banner display frequencies with local display state. -- [#807](https://github.com/PythoughtsAI/pythinker-code/pull/807) [`b45672c`](https://github.com/PythoughtsAI/pythinker-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed. +- [#807](https://github.com/PyModel/pythinker-code/pull/807) [`b45672c`](https://github.com/PyModel/pythinker-code/commit/b45672cdaac9959024c3ae36bf35b16a423aa1dc) - Close wrapped output streams when buffered readers are destroyed. -- [#813](https://github.com/PythoughtsAI/pythinker-code/pull/813) [`7b5b818`](https://github.com/PythoughtsAI/pythinker-code/commit/7b5b8188157ec902e5cd4e73545bc5ca6c52bb76) - Fix repeated compaction handling when context remains over the blocking threshold. +- [#813](https://github.com/PyModel/pythinker-code/pull/813) [`7b5b818`](https://github.com/PyModel/pythinker-code/commit/7b5b8188157ec902e5cd4e73545bc5ca6c52bb76) - Fix repeated compaction handling when context remains over the blocking threshold. -- [#801](https://github.com/PythoughtsAI/pythinker-code/pull/801) [`ff332be`](https://github.com/PythoughtsAI/pythinker-code/commit/ff332be6d364ce3d5974133deb7c76220684181a) - Polish queue pane styling +- [#801](https://github.com/PyModel/pythinker-code/pull/801) [`ff332be`](https://github.com/PyModel/pythinker-code/commit/ff332be6d364ce3d5974133deb7c76220684181a) - Polish queue pane styling -- [#802](https://github.com/PythoughtsAI/pythinker-code/pull/802) [`aa1896c`](https://github.com/PythoughtsAI/pythinker-code/commit/aa1896ca749e41a67d7c4b655dcc8be830cbec82) - Reduce the maximum height of the /btw side panel from half to one-third of the terminal. +- [#802](https://github.com/PyModel/pythinker-code/pull/802) [`aa1896c`](https://github.com/PyModel/pythinker-code/commit/aa1896ca749e41a67d7c4b655dcc8be830cbec82) - Reduce the maximum height of the /btw side panel from half to one-third of the terminal. -- [#805](https://github.com/PythoughtsAI/pythinker-code/pull/805) [`3e6196e`](https://github.com/PythoughtsAI/pythinker-code/commit/3e6196e6b227c66860651f4335e06973865b2714) - Project session replay ranges over rendered replay records instead of raw persisted records. +- [#805](https://github.com/PyModel/pythinker-code/pull/805) [`3e6196e`](https://github.com/PyModel/pythinker-code/commit/3e6196e6b227c66860651f4335e06973865b2714) - Project session replay ranges over rendered replay records instead of raw persisted records. -- [#804](https://github.com/PythoughtsAI/pythinker-code/pull/804) [`299b9fc`](https://github.com/PythoughtsAI/pythinker-code/commit/299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c) - Prevent session shutdown from resuming the agent when stopping background tasks. +- [#804](https://github.com/PyModel/pythinker-code/pull/804) [`299b9fc`](https://github.com/PyModel/pythinker-code/commit/299b9fcad4c9c4b755fae4dfae01a1dbf60aec3c) - Prevent session shutdown from resuming the agent when stopping background tasks. -- [#823](https://github.com/PythoughtsAI/pythinker-code/pull/823) [`90fc04b`](https://github.com/PythoughtsAI/pythinker-code/commit/90fc04b7072ec20055022c50583d35286ca715a6) - Remove redundant LLM request logging context plumbing. +- [#823](https://github.com/PyModel/pythinker-code/pull/823) [`90fc04b`](https://github.com/PyModel/pythinker-code/commit/90fc04b7072ec20055022c50583d35286ca715a6) - Remove redundant LLM request logging context plumbing. ## 0.15.0 ### Minor Changes -- [#779](https://github.com/PythoughtsAI/pythinker-code/pull/779) [`2746c71`](https://github.com/PythoughtsAI/pythinker-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories. +- [#779](https://github.com/PyModel/pythinker-code/pull/779) [`2746c71`](https://github.com/PyModel/pythinker-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Add an all-sessions picker view with name search, paginated browsing, and clipboard-ready resume commands for sessions in other working directories. -- [#744](https://github.com/PythoughtsAI/pythinker-code/pull/744) [`18f299f`](https://github.com/PythoughtsAI/pythinker-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. +- [#744](https://github.com/PyModel/pythinker-code/pull/744) [`18f299f`](https://github.com/PyModel/pythinker-code/commit/18f299fd0b266545a1f7cebae9f58b83b9d9776e) - Add support for legacy SSE MCP servers alongside stdio and streamable HTTP transports. ### Patch Changes -- [#777](https://github.com/PythoughtsAI/pythinker-code/pull/777) [`4516f62`](https://github.com/PythoughtsAI/pythinker-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files. +- [#777](https://github.com/PyModel/pythinker-code/pull/777) [`4516f62`](https://github.com/PyModel/pythinker-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Clarify AGENTS.md prompt guidance and mark truncated instruction files. -- [#780](https://github.com/PythoughtsAI/pythinker-code/pull/780) [`8a92db6`](https://github.com/PythoughtsAI/pythinker-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls. +- [#780](https://github.com/PyModel/pythinker-code/pull/780) [`8a92db6`](https://github.com/PyModel/pythinker-code/commit/8a92db6a0c110a21c6e6e86622f498e836178e5f) - Prompt the CLI to show one brief same-language status sentence before non-trivial tool calls. -- [#786](https://github.com/PythoughtsAI/pythinker-code/pull/786) [`e10b25f`](https://github.com/PythoughtsAI/pythinker-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata. +- [#786](https://github.com/PyModel/pythinker-code/pull/786) [`e10b25f`](https://github.com/PyModel/pythinker-code/commit/e10b25f9be18ca64aada0d0a3cab0e02fdbd46df) - Stop writing resume version markers into persisted agent metadata. -- [#768](https://github.com/PythoughtsAI/pythinker-code/pull/768) [`c6a9967`](https://github.com/PythoughtsAI/pythinker-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded. +- [#768](https://github.com/PyModel/pythinker-code/pull/768) [`c6a9967`](https://github.com/PyModel/pythinker-code/commit/c6a996756cd8f1fb317b6eee6f4e668eebc7dc14) - Recover resumed sessions when an interrupted tool call result was not recorded. -- [#775](https://github.com/PythoughtsAI/pythinker-code/pull/775) [`3fa1b8e`](https://github.com/PythoughtsAI/pythinker-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system. +- [#775](https://github.com/PyModel/pythinker-code/pull/775) [`3fa1b8e`](https://github.com/PyModel/pythinker-code/commit/3fa1b8ea7deb558b88073b5f7b02857e52c3f60c) - Optimize the npm packaging system. -- [#343](https://github.com/PythoughtsAI/pythinker-code/pull/343) [`73be7ba`](https://github.com/PythoughtsAI/pythinker-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for Pythoughts compatibility. +- [#343](https://github.com/PyModel/pythinker-code/pull/343) [`73be7ba`](https://github.com/PyModel/pythinker-code/commit/73be7ba17d41df7999d4c1fba410994e7024eb7b) - Repair mismatched JSON Schema types emitted by Xcode 26.5 MCP server for PyModel compatibility. -- [#777](https://github.com/PythoughtsAI/pythinker-code/pull/777) [`4516f62`](https://github.com/PythoughtsAI/pythinker-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them. +- [#777](https://github.com/PyModel/pythinker-code/pull/777) [`4516f62`](https://github.com/PyModel/pythinker-code/commit/4516f62f6a7e4dd7675a3aec16b2a26c5e310d83) - Collapse hidden directories in the workspace prompt and explain how to inspect them. -- [#766](https://github.com/PythoughtsAI/pythinker-code/pull/766) [`9cef896`](https://github.com/PythoughtsAI/pythinker-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer. +- [#766](https://github.com/PyModel/pythinker-code/pull/766) [`9cef896`](https://github.com/PyModel/pythinker-code/commit/9cef89656311974a57e6675f474ea6c2adb1d8e9) - Clarify that compaction summaries must be emitted in the final answer. -- [#765](https://github.com/PythoughtsAI/pythinker-code/pull/765) [`046856b`](https://github.com/PythoughtsAI/pythinker-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions. +- [#765](https://github.com/PyModel/pythinker-code/pull/765) [`046856b`](https://github.com/PyModel/pythinker-code/commit/046856b740afb604132e914f1fc489de72394036) - Read media files using header-detected types before falling back to media extensions. -- [#779](https://github.com/PythoughtsAI/pythinker-code/pull/779) [`2746c71`](https://github.com/PythoughtsAI/pythinker-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions. +- [#779](https://github.com/PyModel/pythinker-code/pull/779) [`2746c71`](https://github.com/PyModel/pythinker-code/commit/2746c71c47058d9a3bb73e27a07ebfcf44bf4119) - Show the all-sessions toggle hint when the current working directory has no sessions. -- [#785](https://github.com/PythoughtsAI/pythinker-code/pull/785) [`4578f05`](https://github.com/PythoughtsAI/pythinker-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. +- [#785](https://github.com/PyModel/pythinker-code/pull/785) [`4578f05`](https://github.com/PyModel/pythinker-code/commit/4578f05f44101f24d45c6452e2a6993cbb52e331) - Include the skill's directory on the loaded-skill context block so the agent can locate a skill's bundled resources (scripts, templates) after it is invoked. -- [#784](https://github.com/PythoughtsAI/pythinker-code/pull/784) [`a562ef5`](https://github.com/PythoughtsAI/pythinker-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations. +- [#784](https://github.com/PyModel/pythinker-code/pull/784) [`a562ef5`](https://github.com/PyModel/pythinker-code/commit/a562ef54e537a36211c48f0fe19e9252e83397a0) - Decouple agent skill access from session-specific registry implementations. -- [#772](https://github.com/PythoughtsAI/pythinker-code/pull/772) [`d47e699`](https://github.com/PythoughtsAI/pythinker-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files. +- [#772](https://github.com/PyModel/pythinker-code/pull/772) [`d47e699`](https://github.com/PyModel/pythinker-code/commit/d47e699015f02f4f76723aa8fb17d51a74aa74ff) - Do not carry obsolete legacy loop, background, plan, yolo, or unknown experimental flags into migrated config files. -- [#783](https://github.com/PythoughtsAI/pythinker-code/pull/783) [`e2a407c`](https://github.com/PythoughtsAI/pythinker-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width. +- [#783](https://github.com/PyModel/pythinker-code/pull/783) [`e2a407c`](https://github.com/PyModel/pythinker-code/commit/e2a407ce31685220b2f891a7f6d8b89c62418c98) - Keep TUI components within narrow terminal widths by wrapping, compacting, or truncating lines that could exceed the render width. -- [#776](https://github.com/PythoughtsAI/pythinker-code/pull/776) [`ecd7a0a`](https://github.com/PythoughtsAI/pythinker-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider. +- [#776](https://github.com/PyModel/pythinker-code/pull/776) [`ecd7a0a`](https://github.com/PyModel/pythinker-code/commit/ecd7a0afb646d14a14c780a4088fd8a59da134ad) - Resolve model capabilities through a static lookup instead of instantiating a temporary provider. -- [#767](https://github.com/PythoughtsAI/pythinker-code/pull/767) [`a355f2a`](https://github.com/PythoughtsAI/pythinker-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream. +- [#767](https://github.com/PyModel/pythinker-code/pull/767) [`a355f2a`](https://github.com/PyModel/pythinker-code/commit/a355f2af2fd68ad9e2bdc72ce854cd18c8242ce8) - Prioritize clearing draft editor text before Ctrl-C cancels an active stream. -- [#787](https://github.com/PythoughtsAI/pythinker-code/pull/787) [`1eb363f`](https://github.com/PythoughtsAI/pythinker-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form. +- [#787](https://github.com/PyModel/pythinker-code/pull/787) [`1eb363f`](https://github.com/PyModel/pythinker-code/commit/1eb363f655aa44abc1e5c3af89016f00764ecc95) - Extend the same-language rule to the model's reasoning, so thinking follows the user's language while keeping code and technical terms in their original form. ## 0.14.3 ### Patch Changes -- [#713](https://github.com/PythoughtsAI/pythinker-code/pull/713) [`f874251`](https://github.com/PythoughtsAI/pythinker-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker. +- [#713](https://github.com/PyModel/pythinker-code/pull/713) [`f874251`](https://github.com/PyModel/pythinker-code/commit/f874251288927243a9b9d4bfd546e8c17754d566) - Refresh provider model metadata before opening the model picker. ## 0.14.2 ### Patch Changes -- [#683](https://github.com/PythoughtsAI/pythinker-code/pull/683) [`ad239cb`](https://github.com/PythoughtsAI/pythinker-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. +- [#683](https://github.com/PyModel/pythinker-code/pull/683) [`ad239cb`](https://github.com/PyModel/pythinker-code/commit/ad239cb1c08266a442c9ca0382fefed87bcb1fd4) - Allow `--auto`, `--yolo`, and `--plan` to be combined with `--session` or `--continue` by applying the requested mode to the resumed session. -- [#690](https://github.com/PythoughtsAI/pythinker-code/pull/690) [`7f0dde2`](https://github.com/PythoughtsAI/pythinker-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. +- [#690](https://github.com/PyModel/pythinker-code/pull/690) [`7f0dde2`](https://github.com/PyModel/pythinker-code/commit/7f0dde2ece3f9a004e934d69258dfd47c954043c) - Fix endless desktop notifications in iTerm2 by only sending terminal progress sequences to terminals that support them. -- [#651](https://github.com/PythoughtsAI/pythinker-code/pull/651) [`c39c625`](https://github.com/PythoughtsAI/pythinker-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. +- [#651](https://github.com/PyModel/pythinker-code/pull/651) [`c39c625`](https://github.com/PyModel/pythinker-code/commit/c39c62590db708fc81bd8627ea661c38f3fff9af) - Qualify sub-skill names with their parent prefix and expose sub-skills as dotted slash commands in the TUI. -- [#617](https://github.com/PythoughtsAI/pythinker-code/pull/617) [`911e7c3`](https://github.com/PythoughtsAI/pythinker-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session. +- [#617](https://github.com/PyModel/pythinker-code/pull/617) [`911e7c3`](https://github.com/PyModel/pythinker-code/commit/911e7c3fcfc8a005b1b8d90388260d1a4032f76f) - Show completed and cancelled compaction records correctly when resuming a session. -- [#676](https://github.com/PythoughtsAI/pythinker-code/pull/676) [`dcf3075`](https://github.com/PythoughtsAI/pythinker-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running. +- [#676](https://github.com/PyModel/pythinker-code/pull/676) [`dcf3075`](https://github.com/PyModel/pythinker-code/commit/dcf30754d09c7560101bc410387792194c3fe2b4) - Stream foreground Bash stdout and stderr while commands are still running. -- [#692](https://github.com/PythoughtsAI/pythinker-code/pull/692) [`7ca9bdf`](https://github.com/PythoughtsAI/pythinker-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker. +- [#692](https://github.com/PyModel/pythinker-code/pull/692) [`7ca9bdf`](https://github.com/PyModel/pythinker-code/commit/7ca9bdfed516d148b063229a9686a28f9e29aaef) - Skip re-entering plan mode when resuming a session that is already in plan mode (previously failed with "Already in plan mode"), and stop re-applying `--auto`/`--yolo`/`--plan` startup flags when switching sessions through the `/sessions` picker. -- [#675](https://github.com/PythoughtsAI/pythinker-code/pull/675) [`d1ba145`](https://github.com/PythoughtsAI/pythinker-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. +- [#675](https://github.com/PyModel/pythinker-code/pull/675) [`d1ba145`](https://github.com/PyModel/pythinker-code/commit/d1ba14562bafdb6b93c3eec1b5c453186507ed56) - Sync custom registry provider additions, removals, and rotated registry keys during startup refresh. -- [#689](https://github.com/PythoughtsAI/pythinker-code/pull/689) [`8d251f8`](https://github.com/PythoughtsAI/pythinker-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start. +- [#689](https://github.com/PyModel/pythinker-code/pull/689) [`8d251f8`](https://github.com/PyModel/pythinker-code/commit/8d251f8ab44ead65f6c1bb264980ee7d075142ad) - Drop invalid config.toml sections with a warning instead of failing to start. ## 0.14.1 ### Patch Changes -- [#643](https://github.com/PythoughtsAI/pythinker-code/pull/643) [`4e5043b`](https://github.com/PythoughtsAI/pythinker-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentSwarm tool calls to run alone in a model response. +- [#643](https://github.com/PyModel/pythinker-code/pull/643) [`4e5043b`](https://github.com/PyModel/pythinker-code/commit/4e5043b03b2fb03374550dc65d04871bc83e932a) - Require AgentDynamicWorkflow tool calls to run alone in a model response. -- [#631](https://github.com/PythoughtsAI/pythinker-code/pull/631) [`2961425`](https://github.com/PythoughtsAI/pythinker-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. +- [#631](https://github.com/PyModel/pythinker-code/pull/631) [`2961425`](https://github.com/PyModel/pythinker-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Wrap long command and skill descriptions in the autocomplete menu onto a second line instead of cutting them off. -- [#661](https://github.com/PythoughtsAI/pythinker-code/pull/661) [`0927f79`](https://github.com/PythoughtsAI/pythinker-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. +- [#661](https://github.com/PyModel/pythinker-code/pull/661) [`0927f79`](https://github.com/PyModel/pythinker-code/commit/0927f79883e036d0127d4384f60f8e486afb3b8c) - Cancel active turns during session shutdown so foreground shell commands do not outlive prompt-mode exits. -- [#604](https://github.com/PythoughtsAI/pythinker-code/pull/604) [`7ec738c`](https://github.com/PythoughtsAI/pythinker-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. +- [#604](https://github.com/PyModel/pythinker-code/pull/604) [`7ec738c`](https://github.com/PyModel/pythinker-code/commit/7ec738c4a1de41b3a042cfb48700dfaf51e9de94) - Fix premature stream close errors when shell processes time out or are killed. -- [#632](https://github.com/PythoughtsAI/pythinker-code/pull/632) [`d8cdebf`](https://github.com/PythoughtsAI/pythinker-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. +- [#632](https://github.com/PyModel/pythinker-code/pull/632) [`d8cdebf`](https://github.com/PyModel/pythinker-code/commit/d8cdebf3c03efa3a3dfa4f1deb3186a8f8f7f5ef) - Degrade unsupported audio/video to placeholder text and reattach tool result media instead of silently dropping them. -- [#628](https://github.com/PythoughtsAI/pythinker-code/pull/628) [`0ee9106`](https://github.com/PythoughtsAI/pythinker-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. +- [#628](https://github.com/PyModel/pythinker-code/pull/628) [`0ee9106`](https://github.com/PyModel/pythinker-code/commit/0ee91066eaa8ec794c8337faefc14d1b1200ce82) - Fix ACP file reads and edits for Windows workspaces opened through IDE clients. -- [#658](https://github.com/PythoughtsAI/pythinker-code/pull/658) [`0381329`](https://github.com/PythoughtsAI/pythinker-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. +- [#658](https://github.com/PyModel/pythinker-code/pull/658) [`0381329`](https://github.com/PyModel/pythinker-code/commit/0381329570d3dca9fd861761c843968cc1c5e927) - Send OpenAI Responses system prompts as request instructions. -- [#654](https://github.com/PythoughtsAI/pythinker-code/pull/654) [`ff80327`](https://github.com/PythoughtsAI/pythinker-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. +- [#654](https://github.com/PyModel/pythinker-code/pull/654) [`ff80327`](https://github.com/PyModel/pythinker-code/commit/ff803273440f3a2ff53d2c529c6fc892fde1d93f) - Propagate configured execution environment overrides across spawned processes. -- [#644](https://github.com/PythoughtsAI/pythinker-code/pull/644) [`a58b5b2`](https://github.com/PythoughtsAI/pythinker-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. +- [#644](https://github.com/PyModel/pythinker-code/pull/644) [`a58b5b2`](https://github.com/PyModel/pythinker-code/commit/a58b5b20bb42228c72277daba9fa07bb1cd539a6) - Polish builtin skills. -- [#649](https://github.com/PythoughtsAI/pythinker-code/pull/649) [`a2c5e1b`](https://github.com/PythoughtsAI/pythinker-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. +- [#649](https://github.com/PyModel/pythinker-code/pull/649) [`a2c5e1b`](https://github.com/PyModel/pythinker-code/commit/a2c5e1be25484f7c52f729e333196c485f83b84c) - Add runtime support for dynamic MCP server updates, reference skills, replay timestamps, and Node file uploads. -- [#631](https://github.com/PythoughtsAI/pythinker-code/pull/631) [`2961425`](https://github.com/PythoughtsAI/pythinker-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. +- [#631](https://github.com/PyModel/pythinker-code/pull/631) [`2961425`](https://github.com/PyModel/pythinker-code/commit/296142544ec64e93c9083a51d3a53a83496d10cb) - Find slash commands by their aliases in autocomplete — typing `/clear` now suggests `new (clear)`. -- [#648](https://github.com/PythoughtsAI/pythinker-code/pull/648) [`54302ad`](https://github.com/PythoughtsAI/pythinker-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. +- [#648](https://github.com/PyModel/pythinker-code/pull/648) [`54302ad`](https://github.com/PyModel/pythinker-code/commit/54302ad612294056a47ada74b76737f2284861b5) - Prevent overlapping interactive agent requests from using the wrong active agent. -- [#641](https://github.com/PythoughtsAI/pythinker-code/pull/641) [`30459af`](https://github.com/PythoughtsAI/pythinker-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. +- [#641](https://github.com/PyModel/pythinker-code/pull/641) [`30459af`](https://github.com/PyModel/pythinker-code/commit/30459af6abc8308e7f13822d9dbef3a5be80dd4a) - Stop background tasks by default when sessions close. -- [#645](https://github.com/PythoughtsAI/pythinker-code/pull/645) [`1b58aa8`](https://github.com/PythoughtsAI/pythinker-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting swarm tasks from Manual mode. +- [#645](https://github.com/PyModel/pythinker-code/pull/645) [`1b58aa8`](https://github.com/PyModel/pythinker-code/commit/1b58aa8cdf675e6f4c02cd083feb55debbe9b3f1) - Add a YOLO choice when starting dynamic_workflow tasks from Manual mode. -- [#655](https://github.com/PythoughtsAI/pythinker-code/pull/655) [`1e2e679`](https://github.com/PythoughtsAI/pythinker-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup. +- [#655](https://github.com/PyModel/pythinker-code/pull/655) [`1e2e679`](https://github.com/PyModel/pythinker-code/commit/1e2e679693af2fc97826078aa671555a3a900349) - Display a tips banner below the welcome panel on startup. ## 0.14.0 ### Minor Changes -- [#607](https://github.com/PythoughtsAI/pythinker-code/pull/607) [`b253a82`](https://github.com/PythoughtsAI/pythinker-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. +- [#607](https://github.com/PyModel/pythinker-code/pull/607) [`b253a82`](https://github.com/PyModel/pythinker-code/commit/b253a82a7a5f7d91883dc77a30b8b38f8b6e1470) - Add an `Interrupt` hook event that fires when the user interrupts a turn (e.g. pressing Esc), letting hooks observe the turn stopping instead of getting stuck on a working state. ### Patch Changes -- [#626](https://github.com/PythoughtsAI/pythinker-code/pull/626) [`856ec00`](https://github.com/PythoughtsAI/pythinker-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. +- [#626](https://github.com/PyModel/pythinker-code/pull/626) [`856ec00`](https://github.com/PyModel/pythinker-code/commit/856ec002906f4964086915ceb9aa616b89ab6594) - Preserve image outputs from tools when using OpenAI-compatible chat completions. ## 0.13.1 ### Patch Changes -- [#610](https://github.com/PythoughtsAI/pythinker-code/pull/610) [`b747c6a`](https://github.com/PythoughtsAI/pythinker-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. +- [#610](https://github.com/PyModel/pythinker-code/pull/610) [`b747c6a`](https://github.com/PyModel/pythinker-code/commit/b747c6a9501e208250d09cf9a2810c885c6ce91b) - Add Claude Fable 5 support to the Anthropic provider. -- [#615](https://github.com/PythoughtsAI/pythinker-code/pull/615) [`494554e`](https://github.com/PythoughtsAI/pythinker-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. +- [#615](https://github.com/PyModel/pythinker-code/pull/615) [`494554e`](https://github.com/PyModel/pythinker-code/commit/494554eac5d34d6a3c5c36b6fb2b2e5397b07f0c) - Add an interactive undo selector and clearer undo-limit messages. -- [#598](https://github.com/PythoughtsAI/pythinker-code/pull/598) [`32d7080`](https://github.com/PythoughtsAI/pythinker-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. +- [#598](https://github.com/PyModel/pythinker-code/pull/598) [`32d7080`](https://github.com/PyModel/pythinker-code/commit/32d708083730c14090f855b1fcb650e2bc713797) - Clarify active skill prompts so loaded skills are no longer represented as system reminders. -- [#595](https://github.com/PythoughtsAI/pythinker-code/pull/595) [`1580f35`](https://github.com/PythoughtsAI/pythinker-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Pythinker Datasource to use the matching OAuth credentials and service endpoint for the active Pythinker Code environment. +- [#595](https://github.com/PyModel/pythinker-code/pull/595) [`1580f35`](https://github.com/PyModel/pythinker-code/commit/1580f35136eed02331dcff6c8482247d5cf35458) - Fix Pythinker Datasource to use the matching OAuth credentials and service endpoint for the active Pythinker Code environment. -- [#619](https://github.com/PythoughtsAI/pythinker-code/pull/619) [`1fbe0e4`](https://github.com/PythoughtsAI/pythinker-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width. +- [#619](https://github.com/PyModel/pythinker-code/pull/619) [`1fbe0e4`](https://github.com/PyModel/pythinker-code/commit/1fbe0e4ee89241bee6b5b1d5a4a38b6c6de3c5bf) - Fix goal marker text overflowing terminal width. -- [#612](https://github.com/PythoughtsAI/pythinker-code/pull/612) [`4603d8a`](https://github.com/PythoughtsAI/pythinker-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. +- [#612](https://github.com/PyModel/pythinker-code/pull/612) [`4603d8a`](https://github.com/PyModel/pythinker-code/commit/4603d8ad6e92a303f396f3d79d4e4d212d1c4b14) - Prevent forking sessions during active turns and consolidate wire protocol definitions into a shared internal package. -- [#540](https://github.com/PythoughtsAI/pythinker-code/pull/540) [`2ebe387`](https://github.com/PythoughtsAI/pythinker-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. +- [#540](https://github.com/PyModel/pythinker-code/pull/540) [`2ebe387`](https://github.com/PyModel/pythinker-code/commit/2ebe38769fc50215a7c94a362cd4e943130e1143) - Tighten file tool guidance to route incremental edits through Edit. -- [#606](https://github.com/PythoughtsAI/pythinker-code/pull/606) [`a1b419a`](https://github.com/PythoughtsAI/pythinker-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. +- [#606](https://github.com/PyModel/pythinker-code/pull/606) [`a1b419a`](https://github.com/PyModel/pythinker-code/commit/a1b419ab5901d16ab9527eef62bcd468e76b27a3) - YOLO mode no longer asks before writing or editing files outside the working directory. ## 0.13.0 ### Minor Changes -- [#484](https://github.com/PythoughtsAI/pythinker-code/pull/484) [`f863127`](https://github.com/PythoughtsAI/pythinker-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.pythinker-code/themes/`, or generate one with the built-in `/custom-theme` skill command. +- [#484](https://github.com/PyModel/pythinker-code/pull/484) [`f863127`](https://github.com/PyModel/pythinker-code/commit/f863127ab7e8b8e2e9af11c54694c08900e3103a) - Add custom color themes. Define your own palette as a JSON file in `~/.pythinker-code/themes/`, or generate one with the built-in `/custom-theme` skill command. -- [#582](https://github.com/PythoughtsAI/pythinker-code/pull/582) [`d85dc0b`](https://github.com/PythoughtsAI/pythinker-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. +- [#582](https://github.com/PyModel/pythinker-code/pull/582) [`d85dc0b`](https://github.com/PyModel/pythinker-code/commit/d85dc0b96a3c98c6951b8f6e6fa8b663d4c95360) - Add `/import-from-cc-codex` to import selected Claude Code and Codex instructions, Skills, and MCP settings. -- [#593](https://github.com/PythoughtsAI/pythinker-code/pull/593) [`40506f4`](https://github.com/PythoughtsAI/pythinker-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. +- [#593](https://github.com/PyModel/pythinker-code/pull/593) [`40506f4`](https://github.com/PyModel/pythinker-code/commit/40506f49d689aaf3e920c6bc9ae2b91219ee3f7f) - Show available plugin updates in the marketplace. An installed plugin whose marketplace version is newer than the local version now renders an `update ` badge (and updates in place on Enter); up-to-date plugins show `installed · v`. The marketplace `version` served in dev and written by the CDN build is now stamped from each plugin's manifest so "latest" stays accurate. ### Patch Changes -- [#587](https://github.com/PythoughtsAI/pythinker-code/pull/587) [`0abde86`](https://github.com/PythoughtsAI/pythinker-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time. +- [#587](https://github.com/PyModel/pythinker-code/pull/587) [`0abde86`](https://github.com/PyModel/pythinker-code/commit/0abde8662a531293fc8faa7cf9089c43ad8d6d76) - Clarify grouped subagent progress with active status breakdowns and elapsed time. -- [#594](https://github.com/PythoughtsAI/pythinker-code/pull/594) [`f2863af`](https://github.com/PythoughtsAI/pythinker-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened. +- [#594](https://github.com/PyModel/pythinker-code/pull/594) [`f2863af`](https://github.com/PyModel/pythinker-code/commit/f2863af267b2e7d5ff5b99ff80c95c379a5b0272) - Fix device login to keep the URL and code visible when the browser cannot be opened. -- [#591](https://github.com/PythoughtsAI/pythinker-code/pull/591) [`e48234a`](https://github.com/PythoughtsAI/pythinker-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. +- [#591](https://github.com/PyModel/pythinker-code/pull/591) [`e48234a`](https://github.com/PyModel/pythinker-code/commit/e48234af576e41e630736450c66b690226707bc3) - Fix Windows builds and development launches that could fail when package binaries resolve to command shims. -- [#586](https://github.com/PythoughtsAI/pythinker-code/pull/586) [`7cb4a23`](https://github.com/PythoughtsAI/pythinker-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width. +- [#586](https://github.com/PyModel/pythinker-code/pull/586) [`7cb4a23`](https://github.com/PyModel/pythinker-code/commit/7cb4a23e01dfaf0e049891b90a27b36000714151) - Truncate queued message display to a single line with ellipsis when it exceeds terminal width. ## 0.12.1 ### Patch Changes -- [#584](https://github.com/PythoughtsAI/pythinker-code/pull/584) [`11bb62c`](https://github.com/PythoughtsAI/pythinker-code/commit/11bb62c12f38d380a0ca1bb89ee2df67f93300e1) - Allow obsolete experimental config entries to remain without blocking startup. +- [#584](https://github.com/PyModel/pythinker-code/pull/584) [`11bb62c`](https://github.com/PyModel/pythinker-code/commit/11bb62c12f38d380a0ca1bb89ee2df67f93300e1) - Allow obsolete experimental config entries to remain without blocking startup. -- [#581](https://github.com/PythoughtsAI/pythinker-code/pull/581) [`aa3471f`](https://github.com/PythoughtsAI/pythinker-code/commit/aa3471f5d3d2960834ba3239c0b8459144bc79fa) - Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests. +- [#581](https://github.com/PyModel/pythinker-code/pull/581) [`aa3471f`](https://github.com/PyModel/pythinker-code/commit/aa3471f5d3d2960834ba3239c0b8459144bc79fa) - Pass through xhigh reasoning effort for OpenAI-compatible chat completions requests. ## 0.12.0 ### Minor Changes -- [#569](https://github.com/PythoughtsAI/pythinker-code/pull/569) [`d7407b0`](https://github.com/PythoughtsAI/pythinker-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Enable micro compaction by default while keeping its opt-out flag. +- [#569](https://github.com/PyModel/pythinker-code/pull/569) [`d7407b0`](https://github.com/PyModel/pythinker-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Enable micro compaction by default while keeping its opt-out flag. -- [#531](https://github.com/PythoughtsAI/pythinker-code/pull/531) [`b47734c`](https://github.com/PythoughtsAI/pythinker-code/commit/b47734ca0bac84e0b2c4ff50cd3d5eedb9e0c7c1) - Detect Homebrew installations and use `brew upgrade pythinker-code` for updates instead of falling back to npm. +- [#531](https://github.com/PyModel/pythinker-code/pull/531) [`b47734c`](https://github.com/PyModel/pythinker-code/commit/b47734ca0bac84e0b2c4ff50cd3d5eedb9e0c7c1) - Detect Homebrew installations and use `brew upgrade pythinker-code` for updates instead of falling back to npm. -- [#487](https://github.com/PythoughtsAI/pythinker-code/pull/487) [`4d11394`](https://github.com/PythoughtsAI/pythinker-code/commit/4d113949c8e906c20c7188817926f44786653923) - Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables, including SOCKS proxies, for all outbound traffic. +- [#487](https://github.com/PyModel/pythinker-code/pull/487) [`4d11394`](https://github.com/PyModel/pythinker-code/commit/4d113949c8e906c20c7188817926f44786653923) - Honor the standard `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` environment variables, including SOCKS proxies, for all outbound traffic. -- [#569](https://github.com/PythoughtsAI/pythinker-code/pull/569) [`d7407b0`](https://github.com/PythoughtsAI/pythinker-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Make goals, background questions, and sub-skill discovery available without experimental opt-ins. +- [#569](https://github.com/PyModel/pythinker-code/pull/569) [`d7407b0`](https://github.com/PyModel/pythinker-code/commit/d7407b0ecfc87a3840e26ddaddb69e7f52383699) - Make goals, background questions, and sub-skill discovery available without experimental opt-ins. -- [#424](https://github.com/PythoughtsAI/pythinker-code/pull/424) [`72c4b0a`](https://github.com/PythoughtsAI/pythinker-code/commit/72c4b0adaa6ae0466875cd8e4066c42456195f21) - Add the `/swarm` command for running agent swarms with live progress and rate-limit-aware retries. +- [#424](https://github.com/PyModel/pythinker-code/pull/424) [`72c4b0a`](https://github.com/PyModel/pythinker-code/commit/72c4b0adaa6ae0466875cd8e4066c42456195f21) - Add the `/dynamic_workflow` command for running agent dynamic workflows with live progress and rate-limit-aware retries. ### Patch Changes -- [#395](https://github.com/PythoughtsAI/pythinker-code/pull/395) [`879a7ee`](https://github.com/PythoughtsAI/pythinker-code/commit/879a7eeb33a8bedf18779d74a00d78369dae3db5) - Fix ACP slash skill routing, bootstrap context reads, file and permission edge cases, subagent event handling, and stale-file edit messaging. +- [#395](https://github.com/PyModel/pythinker-code/pull/395) [`879a7ee`](https://github.com/PyModel/pythinker-code/commit/879a7eeb33a8bedf18779d74a00d78369dae3db5) - Fix ACP slash skill routing, bootstrap context reads, file and permission edge cases, subagent event handling, and stale-file edit messaging. -- [#529](https://github.com/PythoughtsAI/pythinker-code/pull/529) [`3b62b12`](https://github.com/PythoughtsAI/pythinker-code/commit/3b62b123e68cc4543bfa8fa376c7e8a24fee0afb) - Detect Git Bash installed through Scoop and other Git shims on Windows. +- [#529](https://github.com/PyModel/pythinker-code/pull/529) [`3b62b12`](https://github.com/PyModel/pythinker-code/commit/3b62b123e68cc4543bfa8fa376c7e8a24fee0afb) - Detect Git Bash installed through Scoop and other Git shims on Windows. -- [#547](https://github.com/PythoughtsAI/pythinker-code/pull/547) [`3765a49`](https://github.com/PythoughtsAI/pythinker-code/commit/3765a491636a57c0f84ba409c325df10f7613a49) - Rework file reference completion in the TUI. +- [#547](https://github.com/PyModel/pythinker-code/pull/547) [`3765a49`](https://github.com/PyModel/pythinker-code/commit/3765a491636a57c0f84ba409c325df10f7613a49) - Rework file reference completion in the TUI. -- [#537](https://github.com/PythoughtsAI/pythinker-code/pull/537) [`8d0c91f`](https://github.com/PythoughtsAI/pythinker-code/commit/8d0c91faa1c878e395bffe9bafa89e10736c2384) - Wrap long single-line shell commands in approval prompts so the full command remains visible. +- [#537](https://github.com/PyModel/pythinker-code/pull/537) [`8d0c91f`](https://github.com/PyModel/pythinker-code/commit/8d0c91faa1c878e395bffe9bafa89e10736c2384) - Wrap long single-line shell commands in approval prompts so the full command remains visible. -- [#552](https://github.com/PythoughtsAI/pythinker-code/pull/552) [`db82e33`](https://github.com/PythoughtsAI/pythinker-code/commit/db82e33a20fd1ec204672df4ba5bc38800ce8dea) - Fix goal resume behavior by restoring goal state from agent records. +- [#552](https://github.com/PyModel/pythinker-code/pull/552) [`db82e33`](https://github.com/PyModel/pythinker-code/commit/db82e33a20fd1ec204672df4ba5bc38800ce8dea) - Fix goal resume behavior by restoring goal state from agent records. -- [#521](https://github.com/PythoughtsAI/pythinker-code/pull/521) [`9aba465`](https://github.com/PythoughtsAI/pythinker-code/commit/9aba465fd8689be998fa8581d04792b3c7c54359) - Fix the `/mcp` status panel border being broken by multi-line MCP server errors, which are now folded onto a single row. +- [#521](https://github.com/PyModel/pythinker-code/pull/521) [`9aba465`](https://github.com/PyModel/pythinker-code/commit/9aba465fd8689be998fa8581d04792b3c7c54359) - Fix the `/mcp` status panel border being broken by multi-line MCP server errors, which are now folded onto a single row. -- [#543](https://github.com/PythoughtsAI/pythinker-code/pull/543) [`0c3d556`](https://github.com/PythoughtsAI/pythinker-code/commit/0c3d556778f969b3c99e69e07ecba27af8bd6c29) - Fix session workdir mismatch on Windows caused by inconsistent path separators. +- [#543](https://github.com/PyModel/pythinker-code/pull/543) [`0c3d556`](https://github.com/PyModel/pythinker-code/commit/0c3d556778f969b3c99e69e07ecba27af8bd6c29) - Fix session workdir mismatch on Windows caused by inconsistent path separators. -- [#544](https://github.com/PythoughtsAI/pythinker-code/pull/544) [`5cff6d6`](https://github.com/PythoughtsAI/pythinker-code/commit/5cff6d60273a6145ee38539b9c1306adddc66510) - Load Pythinker-specific user Skills and global agent instructions from `PYTHINKER_CODE_HOME` when it is set. +- [#544](https://github.com/PyModel/pythinker-code/pull/544) [`5cff6d6`](https://github.com/PyModel/pythinker-code/commit/5cff6d60273a6145ee38539b9c1306adddc66510) - Load Pythinker-specific user Skills and global agent instructions from `PYTHINKER_CODE_HOME` when it is set. -- [#536](https://github.com/PythoughtsAI/pythinker-code/pull/536) [`b785e26`](https://github.com/PythoughtsAI/pythinker-code/commit/b785e2698a2da7adc9ef10251a2aed9b243e3b5f) - Show full plan cards directly and remove the Plan card keyboard shortcut. +- [#536](https://github.com/PyModel/pythinker-code/pull/536) [`b785e26`](https://github.com/PyModel/pythinker-code/commit/b785e2698a2da7adc9ef10251a2aed9b243e3b5f) - Show full plan cards directly and remove the Plan card keyboard shortcut. -- [#555](https://github.com/PythoughtsAI/pythinker-code/pull/555) [`41ebe9f`](https://github.com/PythoughtsAI/pythinker-code/commit/41ebe9fb9f403e2ee6a8721640a79faa64e9210a) - Improve goal mode outcome handling with follow-up messages, safer error pauses, and clearer TUI transcript display. +- [#555](https://github.com/PyModel/pythinker-code/pull/555) [`41ebe9f`](https://github.com/PyModel/pythinker-code/commit/41ebe9fb9f403e2ee6a8721640a79faa64e9210a) - Improve goal mode outcome handling with follow-up messages, safer error pauses, and clearer TUI transcript display. -- [#506](https://github.com/PythoughtsAI/pythinker-code/pull/506) [`f09ec7b`](https://github.com/PythoughtsAI/pythinker-code/commit/f09ec7bbb59af42805a93df2993301dbd317ff2d) - Remove the per-turn auto-compaction limit so long conversations can keep compacting instead of failing early. +- [#506](https://github.com/PyModel/pythinker-code/pull/506) [`f09ec7b`](https://github.com/PyModel/pythinker-code/commit/f09ec7bbb59af42805a93df2993301dbd317ff2d) - Remove the per-turn auto-compaction limit so long conversations can keep compacting instead of failing early. -- [#473](https://github.com/PythoughtsAI/pythinker-code/pull/473) [`3787c30`](https://github.com/PythoughtsAI/pythinker-code/commit/3787c3016a12af3434072da1cb6fd0c95821ea45) - Allow the startup session picker to exit with repeated Ctrl-C or Ctrl-D. +- [#473](https://github.com/PyModel/pythinker-code/pull/473) [`3787c30`](https://github.com/PyModel/pythinker-code/commit/3787c3016a12af3434072da1cb6fd0c95821ea45) - Allow the startup session picker to exit with repeated Ctrl-C or Ctrl-D. -- [#210](https://github.com/PythoughtsAI/pythinker-code/pull/210) [`d995928`](https://github.com/PythoughtsAI/pythinker-code/commit/d995928681fa2446902a0164919cf893b81efd75) - Show the underlying error when migration fails. +- [#210](https://github.com/PyModel/pythinker-code/pull/210) [`d995928`](https://github.com/PyModel/pythinker-code/commit/d995928681fa2446902a0164919cf893b81efd75) - Show the underlying error when migration fails. -- [#541](https://github.com/PythoughtsAI/pythinker-code/pull/541) [`2db1bd9`](https://github.com/PythoughtsAI/pythinker-code/commit/2db1bd9675ef3b6adf3833f05b7b6d87a137c6eb) - Fix thinking text and tool output display for subagents. +- [#541](https://github.com/PyModel/pythinker-code/pull/541) [`2db1bd9`](https://github.com/PyModel/pythinker-code/commit/2db1bd9675ef3b6adf3833f05b7b6d87a137c6eb) - Fix thinking text and tool output display for subagents. ## 0.11.0 ### Minor Changes -- [#468](https://github.com/PythoughtsAI/pythinker-code/pull/468) [`df4f2d6`](https://github.com/PythoughtsAI/pythinker-code/commit/df4f2d6e8611074cc0b439928f27decba53d2e9a) - Add experimental sub-skill discovery gated by the `PYTHINKER_CODE_EXPERIMENTAL_SUB_SKILL` environment variable. Ships the `sub-skill` builtin bundle (`sub-skill.review`, `sub-skill.consolidate`) for inventorying and consolidating skills into hierarchical groups. +- [#468](https://github.com/PyModel/pythinker-code/pull/468) [`df4f2d6`](https://github.com/PyModel/pythinker-code/commit/df4f2d6e8611074cc0b439928f27decba53d2e9a) - Add experimental sub-skill discovery gated by the `PYTHINKER_CODE_EXPERIMENTAL_SUB_SKILL` environment variable. Ships the `sub-skill` builtin bundle (`sub-skill.review`, `sub-skill.consolidate`) for inventorying and consolidating skills into hierarchical groups. -- [#480](https://github.com/PythoughtsAI/pythinker-code/pull/480) [`f555c89`](https://github.com/PythoughtsAI/pythinker-code/commit/f555c89de79c5d7ae59521a9ed360ad1cf045fcd) - Show built-in skills as direct slash commands and group them ahead of external skill commands. +- [#480](https://github.com/PyModel/pythinker-code/pull/480) [`f555c89`](https://github.com/PyModel/pythinker-code/commit/f555c89de79c5d7ae59521a9ed360ad1cf045fcd) - Show built-in skills as direct slash commands and group them ahead of external skill commands. -- [#458](https://github.com/PythoughtsAI/pythinker-code/pull/458) [`93eb70a`](https://github.com/PythoughtsAI/pythinker-code/commit/93eb70a727c9724e19a31b0d2fbebb78b7390c78) - Migrate still-relevant environment variables from pythinker-cli: +- [#458](https://github.com/PyModel/pythinker-code/pull/458) [`93eb70a`](https://github.com/PyModel/pythinker-code/commit/93eb70a727c9724e19a31b0d2fbebb78b7390c78) - Migrate still-relevant environment variables from pythinker-cli: - `PYTHINKER_MODEL_TEMPERATURE`, `PYTHINKER_MODEL_TOP_P` — sampling parameters applied globally to any `pythinker` provider (not tied to `PYTHINKER_MODEL_NAME`). - - `PYTHINKER_MODEL_THINKING_KEEP` — Pythoughts preserved-thinking passthrough (`thinking.keep`), injected only while Thinking is on. + - `PYTHINKER_MODEL_THINKING_KEEP` — PyModel preserved-thinking passthrough (`thinking.keep`), injected only while Thinking is on. - `PYTHINKER_CODE_NO_AUTO_UPDATE` (legacy alias `PYTHINKER_CLI_NO_AUTO_UPDATE`) — fully disables the update preflight (no check, background install, or prompt). -- [#470](https://github.com/PythoughtsAI/pythinker-code/pull/470) [`aa610e2`](https://github.com/PythoughtsAI/pythinker-code/commit/aa610e247deca737101e4de848122db1c8ee9fb3) - Use a fixed 30-minute timeout for subagents and show concise resume instructions when they time out. +- [#470](https://github.com/PyModel/pythinker-code/pull/470) [`aa610e2`](https://github.com/PyModel/pythinker-code/commit/aa610e247deca737101e4de848122db1c8ee9fb3) - Use a fixed 30-minute timeout for subagents and show concise resume instructions when they time out. ### Patch Changes -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Show the upcoming-goal confirmation with the same accent treatment as goal lifecycle messages. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Show the upcoming-goal confirmation with the same accent treatment as goal lifecycle messages. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix slash command autocomplete so goal text can be submitted when the cursor is before existing text. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix slash command autocomplete so goal text can be submitted when the cursor is before existing text. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix queued goals so failed promotion attempts do not lose or duplicate queued work. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix queued goals so failed promotion attempts do not lose or duplicate queued work. -- [#456](https://github.com/PythoughtsAI/pythinker-code/pull/456) [`3a98713`](https://github.com/PythoughtsAI/pythinker-code/commit/3a987130500fe5b403b696850165735c7d0ee076) - Show concise provider filtering errors when responses are blocked before visible output. +- [#456](https://github.com/PyModel/pythinker-code/pull/456) [`3a98713`](https://github.com/PyModel/pythinker-code/commit/3a987130500fe5b403b696850165735c7d0ee076) - Show concise provider filtering errors when responses are blocked before visible output. -- [#442](https://github.com/PythoughtsAI/pythinker-code/pull/442) [`960a0e2`](https://github.com/PythoughtsAI/pythinker-code/commit/960a0e2885b5a6a32ccd62506e9dcf4e35206b6f) - Show "unknown command" instead of "too many arguments" when an invalid subcommand is entered. +- [#442](https://github.com/PyModel/pythinker-code/pull/442) [`960a0e2`](https://github.com/PyModel/pythinker-code/commit/960a0e2885b5a6a32ccd62506e9dcf4e35206b6f) - Show "unknown command" instead of "too many arguments" when an invalid subcommand is entered. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix upcoming-goal queue handling while editing or pasting queued goals. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Fix upcoming-goal queue handling while editing or pasting queued goals. -- [#457](https://github.com/PythoughtsAI/pythinker-code/pull/457) [`1fe5d55`](https://github.com/PythoughtsAI/pythinker-code/commit/1fe5d5549c84de17183c4c76a9713cd8538ca755) - Clamp OpenAI Chat Completions `xhigh` and `max` thinking effort to `high` unless the model supports `xhigh` on `v1/chat/completions`. +- [#457](https://github.com/PyModel/pythinker-code/pull/457) [`1fe5d55`](https://github.com/PyModel/pythinker-code/commit/1fe5d5549c84de17183c4c76a9713cd8538ca755) - Clamp OpenAI Chat Completions `xhigh` and `max` thinking effort to `high` unless the model supports `xhigh` on `v1/chat/completions`. -- [#464](https://github.com/PythoughtsAI/pythinker-code/pull/464) [`4f9977d`](https://github.com/PythoughtsAI/pythinker-code/commit/4f9977d4dcd2df14e6a310396c37af170b2eac50) - Preserve thinking effort when compacting long conversations. +- [#464](https://github.com/PyModel/pythinker-code/pull/464) [`4f9977d`](https://github.com/PyModel/pythinker-code/commit/4f9977d4dcd2df14e6a310396c37af170b2eac50) - Preserve thinking effort when compacting long conversations. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Ask before starting goals in YOLO mode so users can switch to Auto for unattended work. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Ask before starting goals in YOLO mode so users can switch to Auto for unattended work. -- [#461](https://github.com/PythoughtsAI/pythinker-code/pull/461) [`2af19e2`](https://github.com/PythoughtsAI/pythinker-code/commit/2af19e29b9f49163b23cade71d3bcaa6d0b11773) - Refresh provider model metadata when capabilities change without model ID changes. +- [#461](https://github.com/PyModel/pythinker-code/pull/461) [`2af19e2`](https://github.com/PyModel/pythinker-code/commit/2af19e29b9f49163b23cade71d3bcaa6d0b11773) - Refresh provider model metadata when capabilities change without model ID changes. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Start upcoming goals immediately when there is no active goal to wait for. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Start upcoming goals immediately when there is no active goal to wait for. Support multiline edits when managing upcoming goals. -- [#474](https://github.com/PythoughtsAI/pythinker-code/pull/474) [`658e465`](https://github.com/PythoughtsAI/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Highlight goal queue subcommands while typing slash commands. +- [#474](https://github.com/PyModel/pythinker-code/pull/474) [`658e465`](https://github.com/PyModel/pythinker-code/commit/658e4653fc535dad040ac3406d8ccace7a19077e) - Highlight goal queue subcommands while typing slash commands. ## 0.10.1 ### Patch Changes -- [#443](https://github.com/PythoughtsAI/pythinker-code/pull/443) [`15a4c64`](https://github.com/PythoughtsAI/pythinker-code/commit/15a4c64e5cea45c9f72d8c889f306f1f964a8ac6) - Fix a crash when starting a goal in the TUI. +- [#443](https://github.com/PyModel/pythinker-code/pull/443) [`15a4c64`](https://github.com/PyModel/pythinker-code/commit/15a4c64e5cea45c9f72d8c889f306f1f964a8ac6) - Fix a crash when starting a goal in the TUI. ## 0.10.0 ### Minor Changes -- [#433](https://github.com/PythoughtsAI/pythinker-code/pull/433) [`85338e9`](https://github.com/PythoughtsAI/pythinker-code/commit/85338e9f7df5d98234fd42891e9bf2a2e6ad767b) - Add the built-in `update-config` skill — you can now have Pythinker edit its own config files. +- [#433](https://github.com/PyModel/pythinker-code/pull/433) [`85338e9`](https://github.com/PyModel/pythinker-code/commit/85338e9f7df5d98234fd42891e9bf2a2e6ad767b) - Add the built-in `update-config` skill — you can now have Pythinker edit its own config files. -- [#420](https://github.com/PythoughtsAI/pythinker-code/pull/420) [`86a42a2`](https://github.com/PythoughtsAI/pythinker-code/commit/86a42a26a1e01f1748a937031fa76ebeaa1e28a8) - Add persistent experimental feature toggles and a TUI panel that applies confirmed changes by reloading the current session. +- [#420](https://github.com/PyModel/pythinker-code/pull/420) [`86a42a2`](https://github.com/PyModel/pythinker-code/commit/86a42a26a1e01f1748a937031fa76ebeaa1e28a8) - Add persistent experimental feature toggles and a TUI panel that applies confirmed changes by reloading the current session. -- [#383](https://github.com/PythoughtsAI/pythinker-code/pull/383) [`15d71b5`](https://github.com/PythoughtsAI/pythinker-code/commit/15d71b5130d949c35d9dc2641e807e08d72dce48) - Add /reload to reload the current session and apply updated config files, plus /reload-tui to reload only TUI preferences. +- [#383](https://github.com/PyModel/pythinker-code/pull/383) [`15d71b5`](https://github.com/PyModel/pythinker-code/commit/15d71b5130d949c35d9dc2641e807e08d72dce48) - Add /reload to reload the current session and apply updated config files, plus /reload-tui to reload only TUI preferences. -- [#393](https://github.com/PythoughtsAI/pythinker-code/pull/393) [`beb12ac`](https://github.com/PythoughtsAI/pythinker-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Users now can prepare several goals for the agent to work on sequentially. The agent will pick up the next goal from the queue once the current goal is completed. Use `/goal next ` to queue a goal and `/goal next manage` to review and change the queue interactively. +- [#393](https://github.com/PyModel/pythinker-code/pull/393) [`beb12ac`](https://github.com/PyModel/pythinker-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Users now can prepare several goals for the agent to work on sequentially. The agent will pick up the next goal from the queue once the current goal is completed. Use `/goal next ` to queue a goal and `/goal next manage` to review and change the queue interactively. -- [#431](https://github.com/PythoughtsAI/pythinker-code/pull/431) [`6a4e4c7`](https://github.com/PythoughtsAI/pythinker-code/commit/6a4e4c75d4bf6db3fefbb5c115d7a7c324bcae16) - Add a doctor command for validating Pythinker Code configuration files. +- [#431](https://github.com/PyModel/pythinker-code/pull/431) [`6a4e4c7`](https://github.com/PyModel/pythinker-code/commit/6a4e4c75d4bf6db3fefbb5c115d7a7c324bcae16) - Add a doctor command for validating Pythinker Code configuration files. ### Patch Changes -- [#393](https://github.com/PythoughtsAI/pythinker-code/pull/393) [`beb12ac`](https://github.com/PythoughtsAI/pythinker-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Stop carrying active and queued goals into forked sessions. +- [#393](https://github.com/PyModel/pythinker-code/pull/393) [`beb12ac`](https://github.com/PyModel/pythinker-code/commit/beb12ac0216818a5c5eda24fb304e4ab01792784) - Stop carrying active and queued goals into forked sessions. -- [#408](https://github.com/PythoughtsAI/pythinker-code/pull/408) [`6303bd2`](https://github.com/PythoughtsAI/pythinker-code/commit/6303bd2936ae168c674af6e685b0eed5a890c42f) - Point session error diagnostics to the `/export-debug-zip` command. +- [#408](https://github.com/PyModel/pythinker-code/pull/408) [`6303bd2`](https://github.com/PyModel/pythinker-code/commit/6303bd2936ae168c674af6e685b0eed5a890c42f) - Point session error diagnostics to the `/export-debug-zip` command. -- [#398](https://github.com/PythoughtsAI/pythinker-code/pull/398) [`b2801c4`](https://github.com/PythoughtsAI/pythinker-code/commit/b2801c4dbfe3f7e13f5468bfba1555fa12d1707c) - Set terminal tab titles without renaming the running process. +- [#398](https://github.com/PyModel/pythinker-code/pull/398) [`b2801c4`](https://github.com/PyModel/pythinker-code/commit/b2801c4dbfe3f7e13f5468bfba1555fa12d1707c) - Set terminal tab titles without renaming the running process. -- [#403](https://github.com/PythoughtsAI/pythinker-code/pull/403) [`d645d7e`](https://github.com/PythoughtsAI/pythinker-code/commit/d645d7e443857b3c974b9fd6065027c0f0cd6953) - Start automatic background updates as soon as startup's fresh update check finds a newer version. +- [#403](https://github.com/PyModel/pythinker-code/pull/403) [`d645d7e`](https://github.com/PyModel/pythinker-code/commit/d645d7e443857b3c974b9fd6065027c0f0cd6953) - Start automatic background updates as soon as startup's fresh update check finds a newer version. -- [#387](https://github.com/PythoughtsAI/pythinker-code/pull/387) [`6e74027`](https://github.com/PythoughtsAI/pythinker-code/commit/6e74027fdc48ad124b2a62465bb5fd07e84d4712) - Lowercase the stale file content message in edit tool errors. +- [#387](https://github.com/PyModel/pythinker-code/pull/387) [`6e74027`](https://github.com/PyModel/pythinker-code/commit/6e74027fdc48ad124b2a62465bb5fd07e84d4712) - Lowercase the stale file content message in edit tool errors. -- [#428](https://github.com/PythoughtsAI/pythinker-code/pull/428) [`853c5fc`](https://github.com/PythoughtsAI/pythinker-code/commit/853c5fc43741582ecbde3b4fccf82cddffe3626e) - Ensure Nix-packaged CLI builds can find ripgrep and fd. +- [#428](https://github.com/PyModel/pythinker-code/pull/428) [`853c5fc`](https://github.com/PyModel/pythinker-code/commit/853c5fc43741582ecbde3b4fccf82cddffe3626e) - Ensure Nix-packaged CLI builds can find ripgrep and fd. -- [#411](https://github.com/PythoughtsAI/pythinker-code/pull/411) [`4598262`](https://github.com/PythoughtsAI/pythinker-code/commit/459826292f855592288bcfddaa1c72529a6d8c64) - Normalize malformed Responses stream rate limit errors as provider rate limit failures. +- [#411](https://github.com/PyModel/pythinker-code/pull/411) [`4598262`](https://github.com/PyModel/pythinker-code/commit/459826292f855592288bcfddaa1c72529a6d8c64) - Normalize malformed Responses stream rate limit errors as provider rate limit failures. -- [#405](https://github.com/PythoughtsAI/pythinker-code/pull/405) [`07e2e0f`](https://github.com/PythoughtsAI/pythinker-code/commit/07e2e0f094fcbc8a6026eb53f5a70cc437bf7c52) - Refresh the update target before showing foreground update prompts so the displayed version matches the install. +- [#405](https://github.com/PyModel/pythinker-code/pull/405) [`07e2e0f`](https://github.com/PyModel/pythinker-code/commit/07e2e0f094fcbc8a6026eb53f5a70cc437bf7c52) - Refresh the update target before showing foreground update prompts so the displayed version matches the install. -- [#399](https://github.com/PythoughtsAI/pythinker-code/pull/399) [`232ed87`](https://github.com/PythoughtsAI/pythinker-code/commit/232ed874d41de777e6ff9c539ac22d830d0b5c3a) - Keep managed OAuth credentials scoped to their configured authentication and API endpoints. +- [#399](https://github.com/PyModel/pythinker-code/pull/399) [`232ed87`](https://github.com/PyModel/pythinker-code/commit/232ed874d41de777e6ff9c539ac22d830d0b5c3a) - Keep managed OAuth credentials scoped to their configured authentication and API endpoints. -- [#407](https://github.com/PythoughtsAI/pythinker-code/pull/407) [`07609b4`](https://github.com/PythoughtsAI/pythinker-code/commit/07609b41a31499bb5c7811dbab71fa427e621efc) - Set the CLI process title to pythinker-code during startup. +- [#407](https://github.com/PyModel/pythinker-code/pull/407) [`07609b4`](https://github.com/PyModel/pythinker-code/commit/07609b41a31499bb5c7811dbab71fa427e621efc) - Set the CLI process title to pythinker-code during startup. -- [#419](https://github.com/PythoughtsAI/pythinker-code/pull/419) [`d0f8e24`](https://github.com/PythoughtsAI/pythinker-code/commit/d0f8e24e9b4d2c6dd68d93bc804a4390bf661c10) - Document the Git Bash prerequisite for Windows installs. +- [#419](https://github.com/PyModel/pythinker-code/pull/419) [`d0f8e24`](https://github.com/PyModel/pythinker-code/commit/d0f8e24e9b4d2c6dd68d93bc804a4390bf661c10) - Document the Git Bash prerequisite for Windows installs. -- [#430](https://github.com/PythoughtsAI/pythinker-code/pull/430) [`be0da5f`](https://github.com/PythoughtsAI/pythinker-code/commit/be0da5ff39641e117d60045a43a7d5d2e0b85b75) - Fail early when Git Bash is missing on Windows before starting CLI sessions. +- [#430](https://github.com/PyModel/pythinker-code/pull/430) [`be0da5f`](https://github.com/PyModel/pythinker-code/commit/be0da5ff39641e117d60045a43a7d5d2e0b85b75) - Fail early when Git Bash is missing on Windows before starting CLI sessions. ## 0.9.0 ### Minor Changes -- [#368](https://github.com/PythoughtsAI/pythinker-code/pull/368) [`3eafa79`](https://github.com/PythoughtsAI/pythinker-code/commit/3eafa79f39c06b67d18bd2c1fd5321d2d889ed90) - Add `@pymodel/acp-adapter` and the `pythinker acp` subcommand: pythinker-code now speaks [Agent Client Protocol 0.23](https://agentclientprotocol.com/) over stdio so IDEs (Zed, JetBrains AI Chat, custom clients) can drive sessions directly — coverage matrix, Zed configuration and breaking pre-release notes are in [pythinker acp Subcommand Page](https://pythoughtsai.github.io/pythinker-code/en/reference/pythinker-acp.html). +- [#368](https://github.com/PyModel/pythinker-code/pull/368) [`3eafa79`](https://github.com/PyModel/pythinker-code/commit/3eafa79f39c06b67d18bd2c1fd5321d2d889ed90) - Add `@pymodel/acp-adapter` and the `pythinker acp` subcommand: pythinker-code now speaks [Agent Client Protocol 0.23](https://agentclientprotocol.com/) over stdio so IDEs (Zed, JetBrains AI Chat, custom clients) can drive sessions directly — coverage matrix, Zed configuration and breaking pre-release notes are in [pythinker acp Subcommand Page](https://code.pythinker.com/pythinker-code/en/reference/pythinker-acp.html). -- [#338](https://github.com/PythoughtsAI/pythinker-code/pull/338) [`ba7dd73`](https://github.com/PythoughtsAI/pythinker-code/commit/ba7dd736a3b295b2a29c229a944208c232d51458) - Add `/btw` for side-channel conversations without steering the active main turn. +- [#338](https://github.com/PyModel/pythinker-code/pull/338) [`ba7dd73`](https://github.com/PyModel/pythinker-code/commit/ba7dd736a3b295b2a29c229a944208c232d51458) - Add `/btw` for side-channel conversations without steering the active main turn. -- [#357](https://github.com/PythoughtsAI/pythinker-code/pull/357) [`179aecf`](https://github.com/PythoughtsAI/pythinker-code/commit/179aecf42379e8ef4091f5351c91cd460ba11bdd) - Log enabled experimental flags at startup. +- [#357](https://github.com/PyModel/pythinker-code/pull/357) [`179aecf`](https://github.com/PyModel/pythinker-code/commit/179aecf42379e8ef4091f5351c91cd460ba11bdd) - Log enabled experimental flags at startup. -- [#378](https://github.com/PythoughtsAI/pythinker-code/pull/378) [`e0d28b4`](https://github.com/PythoughtsAI/pythinker-code/commit/e0d28b4941ad6f16e69bdf56a4185655feec5320) - Allow `/btw` to open the side-channel panel before entering a question. +- [#378](https://github.com/PyModel/pythinker-code/pull/378) [`e0d28b4`](https://github.com/PyModel/pythinker-code/commit/e0d28b4941ad6f16e69bdf56a4185655feec5320) - Allow `/btw` to open the side-channel panel before entering a question. ### Patch Changes -- [#246](https://github.com/PythoughtsAI/pythinker-code/pull/246) [`7d1f889`](https://github.com/PythoughtsAI/pythinker-code/commit/7d1f889d3dc123f44a8d14543e5aaf8aeef2c752) - Fix external editor (Ctrl+G) on Windows by removing `/bin/sh` dependency and using platform-aware shell quoting for temp file paths. +- [#246](https://github.com/PyModel/pythinker-code/pull/246) [`7d1f889`](https://github.com/PyModel/pythinker-code/commit/7d1f889d3dc123f44a8d14543e5aaf8aeef2c752) - Fix external editor (Ctrl+G) on Windows by removing `/bin/sh` dependency and using platform-aware shell quoting for temp file paths. -- [#365](https://github.com/PythoughtsAI/pythinker-code/pull/365) [`6a22523`](https://github.com/PythoughtsAI/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Fix goal budget tool schemas for OpenAI-compatible providers. +- [#365](https://github.com/PyModel/pythinker-code/pull/365) [`6a22523`](https://github.com/PyModel/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Fix goal budget tool schemas for OpenAI-compatible providers. -- [#365](https://github.com/PythoughtsAI/pythinker-code/pull/365) [`6a22523`](https://github.com/PythoughtsAI/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use the OpenAI completion token field required by newer Chat Completions models. +- [#365](https://github.com/PyModel/pythinker-code/pull/365) [`6a22523`](https://github.com/PyModel/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use the OpenAI completion token field required by newer Chat Completions models. -- [#380](https://github.com/PythoughtsAI/pythinker-code/pull/380) [`8639105`](https://github.com/PythoughtsAI/pythinker-code/commit/86391053139ad4ea437afe79f472412fb1b106a1) - Resume saved subagents lazily when they are accessed. +- [#380](https://github.com/PyModel/pythinker-code/pull/380) [`8639105`](https://github.com/PyModel/pythinker-code/commit/86391053139ad4ea437afe79f472412fb1b106a1) - Resume saved subagents lazily when they are accessed. -- [#339](https://github.com/PythoughtsAI/pythinker-code/pull/339) [`a6b16ce`](https://github.com/PythoughtsAI/pythinker-code/commit/a6b16ce6b4bdc20ed33888975c7da7ff1919e22f) - Allow SDK runtime creation to use a separate RPC client while preserving local CLI startup. +- [#339](https://github.com/PyModel/pythinker-code/pull/339) [`a6b16ce`](https://github.com/PyModel/pythinker-code/commit/a6b16ce6b4bdc20ed33888975c7da7ff1919e22f) - Allow SDK runtime creation to use a separate RPC client while preserving local CLI startup. -- [#363](https://github.com/PythoughtsAI/pythinker-code/pull/363) [`90879f3`](https://github.com/PythoughtsAI/pythinker-code/commit/90879f37af2ddb941223d293a67615f8f557e3af) - Unify the interaction and visuals across TUI dialogs and selectors. +- [#363](https://github.com/PyModel/pythinker-code/pull/363) [`90879f3`](https://github.com/PyModel/pythinker-code/commit/90879f37af2ddb941223d293a67615f8f557e3af) - Unify the interaction and visuals across TUI dialogs and selectors. -- [#365](https://github.com/PythoughtsAI/pythinker-code/pull/365) [`6a22523`](https://github.com/PythoughtsAI/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use configured model output limits for completion token caps. +- [#365](https://github.com/PyModel/pythinker-code/pull/365) [`6a22523`](https://github.com/PyModel/pythinker-code/commit/6a2252343a0d624b326b2d369ec908bc8d60092d) - Use configured model output limits for completion token caps. ## 0.8.0 ### Minor Changes -- [#319](https://github.com/PythoughtsAI/pythinker-code/pull/319) [`fe7db4a`](https://github.com/PythoughtsAI/pythinker-code/commit/fe7db4a7e361b83194eb1ebb52d27daed53be532) - Append the current todo list as markdown to compaction summaries before writing them to history. +- [#319](https://github.com/PyModel/pythinker-code/pull/319) [`fe7db4a`](https://github.com/PyModel/pythinker-code/commit/fe7db4a7e361b83194eb1ebb52d27daed53be532) - Append the current todo list as markdown to compaction summaries before writing them to history. -- [#334](https://github.com/PythoughtsAI/pythinker-code/pull/334) [`eeefa98`](https://github.com/PythoughtsAI/pythinker-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add background automatic upgrades, which can be disabled in tui.toml. +- [#334](https://github.com/PyModel/pythinker-code/pull/334) [`eeefa98`](https://github.com/PyModel/pythinker-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add background automatic upgrades, which can be disabled in tui.toml. -- [#270](https://github.com/PythoughtsAI/pythinker-code/pull/270) [`ac37d74`](https://github.com/PythoughtsAI/pythinker-code/commit/ac37d7448458fdb73fbe00e35856dcf44a13f734) - Add experimental goal mode for longer tasks that need more than one turn. Turn it on with `PYTHINKER_CODE_EXPERIMENTAL_GOAL_COMMAND=1` before you start Pythinker. +- [#270](https://github.com/PyModel/pythinker-code/pull/270) [`ac37d74`](https://github.com/PyModel/pythinker-code/commit/ac37d7448458fdb73fbe00e35856dcf44a13f734) - Add experimental goal mode for longer tasks that need more than one turn. Turn it on with `PYTHINKER_CODE_EXPERIMENTAL_GOAL_COMMAND=1` before you start Pythinker. Use `/goal ` in the TUI when you want Pythinker to keep working on one task across turns. For example: @@ -1087,129 +1923,129 @@ Pythinker shows the goal in the TUI and keeps progress visible while it works. Use `/goal status`, `/goal pause`, `/goal resume`, `/goal cancel`, and `/goal replace ` to manage the goal. This feature is still experimental. Try it and tell us what would make it more useful. -- [#315](https://github.com/PythoughtsAI/pythinker-code/pull/315) [`191059d`](https://github.com/PythoughtsAI/pythinker-code/commit/191059d40049d3bfd07661ac03bb961eac1407f7) - Add background structured questions so agents can continue while waiting for user answers. +- [#315](https://github.com/PyModel/pythinker-code/pull/315) [`191059d`](https://github.com/PyModel/pythinker-code/commit/191059d40049d3bfd07661ac03bb961eac1407f7) - Add background structured questions so agents can continue while waiting for user answers. -- [#313](https://github.com/PythoughtsAI/pythinker-code/pull/313) [`3c5dee8`](https://github.com/PythoughtsAI/pythinker-code/commit/3c5dee8836ac823fce01707f60b9c095a963060e) - Add `pythinker provider` CLI subcommand with `add`, `remove`, `list`, and `catalog list` / `catalog add` actions, so providers from a custom registry (api.json) or the public models.dev catalog can be imported and managed without launching the TUI. +- [#313](https://github.com/PyModel/pythinker-code/pull/313) [`3c5dee8`](https://github.com/PyModel/pythinker-code/commit/3c5dee8836ac823fce01707f60b9c095a963060e) - Add `pythinker provider` CLI subcommand with `add`, `remove`, `list`, and `catalog list` / `catalog add` actions, so providers from a custom registry (api.json) or the public models.dev catalog can be imported and managed without launching the TUI. -- [#277](https://github.com/PythoughtsAI/pythinker-code/pull/277) [`a217ff0`](https://github.com/PythoughtsAI/pythinker-code/commit/a217ff09aad0665b1501b156c2cc1f186b876087) - Add `/undo` slash command to withdraw the last prompt from conversation history, and keep replay records in sync when a prompt is undone. +- [#277](https://github.com/PyModel/pythinker-code/pull/277) [`a217ff0`](https://github.com/PyModel/pythinker-code/commit/a217ff09aad0665b1501b156c2cc1f186b876087) - Add `/undo` slash command to withdraw the last prompt from conversation history, and keep replay records in sync when a prompt is undone. -- [#334](https://github.com/PythoughtsAI/pythinker-code/pull/334) [`eeefa98`](https://github.com/PythoughtsAI/pythinker-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add a `pythinker upgrade` command for manually checking and upgrade Pythinker Code CLI. +- [#334](https://github.com/PyModel/pythinker-code/pull/334) [`eeefa98`](https://github.com/PyModel/pythinker-code/commit/eeefa98083e9d037d2ba7c59de9e5eb51b19fdd7) - Add a `pythinker upgrade` command for manually checking and upgrade Pythinker Code CLI. -- [#336](https://github.com/PythoughtsAI/pythinker-code/pull/336) [`7cda9c3`](https://github.com/PythoughtsAI/pythinker-code/commit/7cda9c3866bad6b3ce8f95c383a111e1ee5e9325) - Add approval lifecycle hook events for observing pending and completed permission prompts. +- [#336](https://github.com/PyModel/pythinker-code/pull/336) [`7cda9c3`](https://github.com/PyModel/pythinker-code/commit/7cda9c3866bad6b3ce8f95c383a111e1ee5e9325) - Add approval lifecycle hook events for observing pending and completed permission prompts. ### Patch Changes -- [#285](https://github.com/PythoughtsAI/pythinker-code/pull/285) [`573c56e`](https://github.com/PythoughtsAI/pythinker-code/commit/573c56e829a10e8a45738a37250d8c15f4ab8d8d) - Consolidate background task management under the agent background runtime. +- [#285](https://github.com/PyModel/pythinker-code/pull/285) [`573c56e`](https://github.com/PyModel/pythinker-code/commit/573c56e829a10e8a45738a37250d8c15f4ab8d8d) - Consolidate background task management under the agent background runtime. -- [#314](https://github.com/PythoughtsAI/pythinker-code/pull/314) [`6de3d97`](https://github.com/PythoughtsAI/pythinker-code/commit/6de3d97d82e2c585035d1d7f969a3504f712df21) - Prevent modified keyboard release sequences from appearing after exiting the CLI. +- [#314](https://github.com/PyModel/pythinker-code/pull/314) [`6de3d97`](https://github.com/PyModel/pythinker-code/commit/6de3d97d82e2c585035d1d7f969a3504f712df21) - Prevent modified keyboard release sequences from appearing after exiting the CLI. -- [#335](https://github.com/PythoughtsAI/pythinker-code/pull/335) [`7284f30`](https://github.com/PythoughtsAI/pythinker-code/commit/7284f30479142fd66b1e8a731fd00198b1e8684f) - Fix custom registry provider handling during re-import. Prevent loss of multi-provider entries and remove stale providers along with their model aliases and default model references. +- [#335](https://github.com/PyModel/pythinker-code/pull/335) [`7284f30`](https://github.com/PyModel/pythinker-code/commit/7284f30479142fd66b1e8a731fd00198b1e8684f) - Fix custom registry provider handling during re-import. Prevent loss of multi-provider entries and remove stale providers along with their model aliases and default model references. -- [#311](https://github.com/PythoughtsAI/pythinker-code/pull/311) [`80164c2`](https://github.com/PythoughtsAI/pythinker-code/commit/80164c2e975ba82f7c915dc3fce6cb00b9d29f6e) - Normalize glob patterns before brace expansion to prevent incorrect path matching. +- [#311](https://github.com/PyModel/pythinker-code/pull/311) [`80164c2`](https://github.com/PyModel/pythinker-code/commit/80164c2e975ba82f7c915dc3fce6cb00b9d29f6e) - Normalize glob patterns before brace expansion to prevent incorrect path matching. -- [#247](https://github.com/PythoughtsAI/pythinker-code/pull/247) [`58e2915`](https://github.com/PythoughtsAI/pythinker-code/commit/58e2915c0f726747a94a8dc5a9eda001ef0d4009) - Fix a crash in the `/sessions` picker on very narrow terminals by clamping every rendered line to the terminal width. +- [#247](https://github.com/PyModel/pythinker-code/pull/247) [`58e2915`](https://github.com/PyModel/pythinker-code/commit/58e2915c0f726747a94a8dc5a9eda001ef0d4009) - Fix a crash in the `/sessions` picker on very narrow terminals by clamping every rendered line to the terminal width. -- [#317](https://github.com/PythoughtsAI/pythinker-code/pull/317) [`1f8c36a`](https://github.com/PythoughtsAI/pythinker-code/commit/1f8c36af288ca6120d620f3944c921bc4f0f77ce) - Fix tool output preview rendering: trim trailing empty lines, append ellipsis to multi-line Bash command headers, and truncate long single-line output by visual wrapped lines instead of raw newline count. +- [#317](https://github.com/PyModel/pythinker-code/pull/317) [`1f8c36a`](https://github.com/PyModel/pythinker-code/commit/1f8c36af288ca6120d620f3944c921bc4f0f77ce) - Fix tool output preview rendering: trim trailing empty lines, append ellipsis to multi-line Bash command headers, and truncate long single-line output by visual wrapped lines instead of raw newline count. -- [#145](https://github.com/PythoughtsAI/pythinker-code/pull/145) [`d912053`](https://github.com/PythoughtsAI/pythinker-code/commit/d912053b0d3983f4e67450c347616086cfbd1fe7) - Fix Git Bash path detection on Windows by also searching `usr\bin\bash.exe` locations, which is where bash lives in many Git for Windows installations where `bin\bash.exe` does not exist. +- [#145](https://github.com/PyModel/pythinker-code/pull/145) [`d912053`](https://github.com/PyModel/pythinker-code/commit/d912053b0d3983f4e67450c347616086cfbd1fe7) - Fix Git Bash path detection on Windows by also searching `usr\bin\bash.exe` locations, which is where bash lives in many Git for Windows installations where `bin\bash.exe` does not exist. -- [#310](https://github.com/PythoughtsAI/pythinker-code/pull/310) [`a4511ff`](https://github.com/PythoughtsAI/pythinker-code/commit/a4511ffc87a1414cb8a5295eeef1103b9ed59645) - Show the full model name in the footer status bar instead of truncating the provider prefix. +- [#310](https://github.com/PyModel/pythinker-code/pull/310) [`a4511ff`](https://github.com/PyModel/pythinker-code/commit/a4511ffc87a1414cb8a5295eeef1103b9ed59645) - Show the full model name in the footer status bar instead of truncating the provider prefix. -- [#283](https://github.com/PythoughtsAI/pythinker-code/pull/283) [`91b292e`](https://github.com/PythoughtsAI/pythinker-code/commit/91b292e898e9d97b0501cf787919d7f1a90c89d8) - Allow glob searches to target explicit absolute paths outside the workspace. +- [#283](https://github.com/PyModel/pythinker-code/pull/283) [`91b292e`](https://github.com/PyModel/pythinker-code/commit/91b292e898e9d97b0501cf787919d7f1a90c89d8) - Allow glob searches to target explicit absolute paths outside the workspace. -- [#223](https://github.com/PythoughtsAI/pythinker-code/pull/223) [`811f252`](https://github.com/PythoughtsAI/pythinker-code/commit/811f252625bc20a27687b11754b18cc68c7d50dc) - Show MCP server summary in the welcome panel and add configuration hints in the /mcp command output. +- [#223](https://github.com/PyModel/pythinker-code/pull/223) [`811f252`](https://github.com/PyModel/pythinker-code/commit/811f252625bc20a27687b11754b18cc68c7d50dc) - Show MCP server summary in the welcome panel and add configuration hints in the /mcp command output. -- [#229](https://github.com/PythoughtsAI/pythinker-code/pull/229) [`fb35bca`](https://github.com/PythoughtsAI/pythinker-code/commit/fb35bca032486eaefb7b9d7b612d353033e0922c) - Replace chalk named color with theme-aware hex in session-directory warning. +- [#229](https://github.com/PyModel/pythinker-code/pull/229) [`fb35bca`](https://github.com/PyModel/pythinker-code/commit/fb35bca032486eaefb7b9d7b612d353033e0922c) - Replace chalk named color with theme-aware hex in session-directory warning. -- [#303](https://github.com/PythoughtsAI/pythinker-code/pull/303) [`3d7e20e`](https://github.com/PythoughtsAI/pythinker-code/commit/3d7e20e6978cb35787738e12f6f352fbc2733582) - Point users to `/provider` instead of the removed `/connect` command in the welcome screen and the no-models-configured hint. +- [#303](https://github.com/PyModel/pythinker-code/pull/303) [`3d7e20e`](https://github.com/PyModel/pythinker-code/commit/3d7e20e6978cb35787738e12f6f352fbc2733582) - Point users to `/provider` instead of the removed `/connect` command in the welcome screen and the no-models-configured hint. -- [#135](https://github.com/PythoughtsAI/pythinker-code/pull/135) [`0071b63`](https://github.com/PythoughtsAI/pythinker-code/commit/0071b63fc83821430472e11db3c6aa613c0bdf7e) - Fix slash-activated skills not being recognized by the model due to missing system reminder wrapper. +- [#135](https://github.com/PyModel/pythinker-code/pull/135) [`0071b63`](https://github.com/PyModel/pythinker-code/commit/0071b63fc83821430472e11db3c6aa613c0bdf7e) - Fix slash-activated skills not being recognized by the model due to missing system reminder wrapper. -- [#330](https://github.com/PythoughtsAI/pythinker-code/pull/330) [`7a47045`](https://github.com/PythoughtsAI/pythinker-code/commit/7a47045af2790eba0e68d5406c670ac759b21755) - Allow subagents to use custom tools registered on their parent agent. +- [#330](https://github.com/PyModel/pythinker-code/pull/330) [`7a47045`](https://github.com/PyModel/pythinker-code/commit/7a47045af2790eba0e68d5406c670ac759b21755) - Allow subagents to use custom tools registered on their parent agent. -- [#333](https://github.com/PythoughtsAI/pythinker-code/pull/333) [`1178c5c`](https://github.com/PythoughtsAI/pythinker-code/commit/1178c5cd148d9d5851574afaafb986be1dfe9b63) - Remind the model to refresh TodoList during long-running tasks and strengthen TodoList progress-tracking guidance. +- [#333](https://github.com/PyModel/pythinker-code/pull/333) [`1178c5c`](https://github.com/PyModel/pythinker-code/commit/1178c5cd148d9d5851574afaafb986be1dfe9b63) - Remind the model to refresh TodoList during long-running tasks and strengthen TodoList progress-tracking guidance. -- [#327](https://github.com/PythoughtsAI/pythinker-code/pull/327) [`8809f3e`](https://github.com/PythoughtsAI/pythinker-code/commit/8809f3eb114172ac64cefe43bbf9b9257c5245c0) - Fix cross-provider replay failures from incompatible tool call IDs and unsigned Claude thinking history. +- [#327](https://github.com/PyModel/pythinker-code/pull/327) [`8809f3e`](https://github.com/PyModel/pythinker-code/commit/8809f3eb114172ac64cefe43bbf9b9257c5245c0) - Fix cross-provider replay failures from incompatible tool call IDs and unsigned Claude thinking history. ## 0.7.0 ### Minor Changes -- [#232](https://github.com/PythoughtsAI/pythinker-code/pull/232) [`a24bfb1`](https://github.com/PythoughtsAI/pythinker-code/commit/a24bfb1df38e58120827a1d8ed881724af2e7b23) - Add `PYTHINKER_MODEL_ADAPTIVE_THINKING` (and a matching `adaptive_thinking` model-alias field) to force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the Anthropic model-name version inference. This lets custom-named compatible endpoints that back an adaptive-capable model opt in even when the model name does not encode a parseable Claude version. +- [#232](https://github.com/PyModel/pythinker-code/pull/232) [`a24bfb1`](https://github.com/PyModel/pythinker-code/commit/a24bfb1df38e58120827a1d8ed881724af2e7b23) - Add `PYTHINKER_MODEL_ADAPTIVE_THINKING` (and a matching `adaptive_thinking` model-alias field) to force adaptive thinking (`thinking: { type: 'adaptive' }`) on or off, overriding the Anthropic model-name version inference. This lets custom-named compatible endpoints that back an adaptive-capable model opt in even when the model name does not encode a parseable Claude version. -- [#264](https://github.com/PythoughtsAI/pythinker-code/pull/264) [`42bb914`](https://github.com/PythoughtsAI/pythinker-code/commit/42bb9141d8ee7023639f943dd4c6a0f6c8fa8945) - Add `/provider` command for managing AI providers, support custom registry imports, and introduce a tabbed model selector. +- [#264](https://github.com/PyModel/pythinker-code/pull/264) [`42bb914`](https://github.com/PyModel/pythinker-code/commit/42bb9141d8ee7023639f943dd4c6a0f6c8fa8945) - Add `/provider` command for managing AI providers, support custom registry imports, and introduce a tabbed model selector. -- [#204](https://github.com/PythoughtsAI/pythinker-code/pull/204) [`ee69d0a`](https://github.com/PythoughtsAI/pythinker-code/commit/ee69d0ac29f56bde4957c14767d7ca436697d9cf) - Render scheduled reminders distinctly in the TUI, expose cron fired events to SDK clients, and report cron fire times with local timezone offsets. +- [#204](https://github.com/PyModel/pythinker-code/pull/204) [`ee69d0a`](https://github.com/PyModel/pythinker-code/commit/ee69d0ac29f56bde4957c14767d7ca436697d9cf) - Render scheduled reminders distinctly in the TUI, expose cron fired events to SDK clients, and report cron fire times with local timezone offsets. ### Patch Changes -- [#282](https://github.com/PythoughtsAI/pythinker-code/pull/282) [`a580cd3`](https://github.com/PythoughtsAI/pythinker-code/commit/a580cd3a98664e18642e0e856aeaa9b71ba93516) - Fix glob pattern backslash escaping and include match count in truncation messages. +- [#282](https://github.com/PyModel/pythinker-code/pull/282) [`a580cd3`](https://github.com/PyModel/pythinker-code/commit/a580cd3a98664e18642e0e856aeaa9b71ba93516) - Fix glob pattern backslash escaping and include match count in truncation messages. -- [#260](https://github.com/PythoughtsAI/pythinker-code/pull/260) [`178827d`](https://github.com/PythoughtsAI/pythinker-code/commit/178827db47f183df783ba63bf8f1c338f2cbd7e6) - Polish a small TUI visual interaction. +- [#260](https://github.com/PyModel/pythinker-code/pull/260) [`178827d`](https://github.com/PyModel/pythinker-code/commit/178827db47f183df783ba63bf8f1c338f2cbd7e6) - Polish a small TUI visual interaction. -- [#267](https://github.com/PythoughtsAI/pythinker-code/pull/267) [`e2e1728`](https://github.com/PythoughtsAI/pythinker-code/commit/e2e17289fca9bcb23f05cd77f7bcb9cba5db0325) - Report truncated compaction summaries clearly and apply valid completion token budgets across supported providers. +- [#267](https://github.com/PyModel/pythinker-code/pull/267) [`e2e1728`](https://github.com/PyModel/pythinker-code/commit/e2e17289fca9bcb23f05cd77f7bcb9cba5db0325) - Report truncated compaction summaries clearly and apply valid completion token budgets across supported providers. -- [#274](https://github.com/PythoughtsAI/pythinker-code/pull/274) [`a1dfbfe`](https://github.com/PythoughtsAI/pythinker-code/commit/a1dfbfeb16bcad0c2c8faa232d6d1ce4a2681d57) - Clarify Pythinker Platform API key login labels and prompt details. +- [#274](https://github.com/PyModel/pythinker-code/pull/274) [`a1dfbfe`](https://github.com/PyModel/pythinker-code/commit/a1dfbfeb16bcad0c2c8faa232d6d1ce4a2681d57) - Clarify Kimi Platform API key login labels and prompt details. ## 0.6.0 ### Minor Changes -- [#212](https://github.com/PythoughtsAI/pythinker-code/pull/212) [`2bbea75`](https://github.com/PythoughtsAI/pythinker-code/commit/2bbea75ee4c0b11f12d2921061774426df40479a) - Add a `PYTHINKER_MODEL_*` environment-variable channel that lets you run Pythinker Code against a specific model (provider type, base URL, API key, context size, capabilities, and thinking settings) without editing `config.toml`. +- [#212](https://github.com/PyModel/pythinker-code/pull/212) [`2bbea75`](https://github.com/PyModel/pythinker-code/commit/2bbea75ee4c0b11f12d2921061774426df40479a) - Add a `PYTHINKER_MODEL_*` environment-variable channel that lets you run Pythinker Code against a specific model (provider type, base URL, API key, context size, capabilities, and thinking settings) without editing `config.toml`. -- [#221](https://github.com/PythoughtsAI/pythinker-code/pull/221) [`bab2da7`](https://github.com/PythoughtsAI/pythinker-code/commit/bab2da7b1c785d6deba25decb1411f8f5a70de8c) - Install plugins directly from GitHub repository URLs, and surface each install's origin and trust level (pythinker-official, curated, third-party) in the plugin manager. +- [#221](https://github.com/PyModel/pythinker-code/pull/221) [`bab2da7`](https://github.com/PyModel/pythinker-code/commit/bab2da7b1c785d6deba25decb1411f8f5a70de8c) - Install plugins directly from GitHub repository URLs, and surface each install's origin and trust level (pythinker-official, curated, third-party) in the plugin manager. -- [#118](https://github.com/PythoughtsAI/pythinker-code/pull/118) [`8913440`](https://github.com/PythoughtsAI/pythinker-code/commit/891344054111a05171963cfa524ef749c2855321) - Support querying sessions by sessionId or workDir in listSessions, and show a helpful cd command when resuming a session from a different working directory. +- [#118](https://github.com/PyModel/pythinker-code/pull/118) [`8913440`](https://github.com/PyModel/pythinker-code/commit/891344054111a05171963cfa524ef749c2855321) - Support querying sessions by sessionId or workDir in listSessions, and show a helpful cd command when resuming a session from a different working directory. -- [#186](https://github.com/PythoughtsAI/pythinker-code/pull/186) [`537cf20`](https://github.com/PythoughtsAI/pythinker-code/commit/537cf20d18b26d4238f963f793f8a8ef085ac97e) - Remove the default per-turn step limit of 1000. Users can still set `max_steps_per_turn` in config to enforce a custom limit. +- [#186](https://github.com/PyModel/pythinker-code/pull/186) [`537cf20`](https://github.com/PyModel/pythinker-code/commit/537cf20d18b26d4238f963f793f8a8ef085ac97e) - Remove the default per-turn step limit of 1000. Users can still set `max_steps_per_turn` in config to enforce a custom limit. ### Patch Changes -- [#197](https://github.com/PythoughtsAI/pythinker-code/pull/197) [`f3269ea`](https://github.com/PythoughtsAI/pythinker-code/commit/f3269eacb9da9a6b66f578a864d0b9bdfb1d6d81) - Show the real terminal status of background agents in the transcript so lost, failed, and killed ones no longer appear as completed, and include the resume agent id and recovery instructions in the failure notification so the model can resume reliably. +- [#197](https://github.com/PyModel/pythinker-code/pull/197) [`f3269ea`](https://github.com/PyModel/pythinker-code/commit/f3269eacb9da9a6b66f578a864d0b9bdfb1d6d81) - Show the real terminal status of background agents in the transcript so lost, failed, and killed ones no longer appear as completed, and include the resume agent id and recovery instructions in the failure notification so the model can resume reliably. -- [#211](https://github.com/PythoughtsAI/pythinker-code/pull/211) [`54590d3`](https://github.com/PythoughtsAI/pythinker-code/commit/54590d3d464b05eed0837a725b37f3aa491c09af) - Back off failed compaction retries by a fixed slice of the model context window. +- [#211](https://github.com/PyModel/pythinker-code/pull/211) [`54590d3`](https://github.com/PyModel/pythinker-code/commit/54590d3d464b05eed0837a725b37f3aa491c09af) - Back off failed compaction retries by a fixed slice of the model context window. -- [#167](https://github.com/PythoughtsAI/pythinker-code/pull/167) [`b5981a5`](https://github.com/PythoughtsAI/pythinker-code/commit/b5981a523b66ff2fd5f09a7e66075628b94683c8) - Introduce `ModelProvider` interface and `SingleModelProvider` to decouple `Agent` from `ProviderManager`. +- [#167](https://github.com/PyModel/pythinker-code/pull/167) [`b5981a5`](https://github.com/PyModel/pythinker-code/commit/b5981a523b66ff2fd5f09a7e66075628b94683c8) - Introduce `ModelProvider` interface and `SingleModelProvider` to decouple `Agent` from `ProviderManager`. -- [#213](https://github.com/PythoughtsAI/pythinker-code/pull/213) [`2388f20`](https://github.com/PythoughtsAI/pythinker-code/commit/2388f20bb3d039e89caefca159801059b90dc64a) - Handle context overflow errors consistently across provider responses. +- [#213](https://github.com/PyModel/pythinker-code/pull/213) [`2388f20`](https://github.com/PyModel/pythinker-code/commit/2388f20bb3d039e89caefca159801059b90dc64a) - Handle context overflow errors consistently across provider responses. -- [#214](https://github.com/PythoughtsAI/pythinker-code/pull/214) [`caaa6d8`](https://github.com/PythoughtsAI/pythinker-code/commit/caaa6d83ee262ba4c954386458ee13aacdb26e1a) - Fix the native self-updater reporting a successful update when the install command actually failed. +- [#214](https://github.com/PyModel/pythinker-code/pull/214) [`caaa6d8`](https://github.com/PyModel/pythinker-code/commit/caaa6d83ee262ba4c954386458ee13aacdb26e1a) - Fix the native self-updater reporting a successful update when the install command actually failed. -- [#202](https://github.com/PythoughtsAI/pythinker-code/pull/202) [`14a0348`](https://github.com/PythoughtsAI/pythinker-code/commit/14a03488555682dde4bcd74aadf79f60a9827304) - Fix footer leaking onto the terminal when resuming a non-existent session. +- [#202](https://github.com/PyModel/pythinker-code/pull/202) [`14a0348`](https://github.com/PyModel/pythinker-code/commit/14a03488555682dde4bcd74aadf79f60a9827304) - Fix footer leaking onto the terminal when resuming a non-existent session. -- [#198](https://github.com/PythoughtsAI/pythinker-code/pull/198) [`8c77cfa`](https://github.com/PythoughtsAI/pythinker-code/commit/8c77cfab62617e07b38f8514a8ef7cddfd9f1069) - Fix automatic ripgrep installation when temporary files are on another filesystem. +- [#198](https://github.com/PyModel/pythinker-code/pull/198) [`8c77cfa`](https://github.com/PyModel/pythinker-code/commit/8c77cfab62617e07b38f8514a8ef7cddfd9f1069) - Fix automatic ripgrep installation when temporary files are on another filesystem. -- [#199](https://github.com/PythoughtsAI/pythinker-code/pull/199) [`588145d`](https://github.com/PythoughtsAI/pythinker-code/commit/588145dc9b266456bdb1d739975a5b9cf33d70ae) - Expand the footer's rotating tips to surface more commands and shortcuts, featuring newer and important ones more prominently. +- [#199](https://github.com/PyModel/pythinker-code/pull/199) [`588145d`](https://github.com/PyModel/pythinker-code/commit/588145dc9b266456bdb1d739975a5b9cf33d70ae) - Expand the footer's rotating tips to surface more commands and shortcuts, featuring newer and important ones more prominently. -- [#192](https://github.com/PythoughtsAI/pythinker-code/pull/192) [`64964a0`](https://github.com/PythoughtsAI/pythinker-code/commit/64964a0dda98fc2db5e15ba923ea9414c78e0009) - Improve the usage information display in the TUI. +- [#192](https://github.com/PyModel/pythinker-code/pull/192) [`64964a0`](https://github.com/PyModel/pythinker-code/commit/64964a0dda98fc2db5e15ba923ea9414c78e0009) - Improve the usage information display in the TUI. -- [#195](https://github.com/PythoughtsAI/pythinker-code/pull/195) [`3a0e060`](https://github.com/PythoughtsAI/pythinker-code/commit/3a0e06031ac6dfde148f64906a06cfe820ad9c63) - Project persisted hook and blocked prompt messages into model context. +- [#195](https://github.com/PyModel/pythinker-code/pull/195) [`3a0e060`](https://github.com/PyModel/pythinker-code/commit/3a0e06031ac6dfde148f64906a06cfe820ad9c63) - Project persisted hook and blocked prompt messages into model context. -- [#221](https://github.com/PythoughtsAI/pythinker-code/pull/221) [`bab2da7`](https://github.com/PythoughtsAI/pythinker-code/commit/bab2da7b1c785d6deba25decb1411f8f5a70de8c) - Restrict plugin trust badges to Pythinker-hosted plugin CDN URL patterns. +- [#221](https://github.com/PyModel/pythinker-code/pull/221) [`bab2da7`](https://github.com/PyModel/pythinker-code/commit/bab2da7b1c785d6deba25decb1411f8f5a70de8c) - Restrict plugin trust badges to Pythinker-hosted plugin CDN URL patterns. -- [#207](https://github.com/PythoughtsAI/pythinker-code/pull/207) [`e280f33`](https://github.com/PythoughtsAI/pythinker-code/commit/e280f33daf7fbf1271c872dcb224737ec9518f73) - Recover from provider model token limit errors during long conversations. +- [#207](https://github.com/PyModel/pythinker-code/pull/207) [`e280f33`](https://github.com/PyModel/pythinker-code/commit/e280f33daf7fbf1271c872dcb224737ec9518f73) - Recover from provider model token limit errors during long conversations. -- [#201](https://github.com/PythoughtsAI/pythinker-code/pull/201) [`3da4dae`](https://github.com/PythoughtsAI/pythinker-code/commit/3da4daeadee39573c7eeede30fa9465b411be3e2) - Automatically retry when a model response stream is dropped mid-flight (a `terminated` error) instead of failing the turn. +- [#201](https://github.com/PyModel/pythinker-code/pull/201) [`3da4dae`](https://github.com/PyModel/pythinker-code/commit/3da4daeadee39573c7eeede30fa9465b411be3e2) - Automatically retry when a model response stream is dropped mid-flight (a `terminated` error) instead of failing the turn. -- [#190](https://github.com/PythoughtsAI/pythinker-code/pull/190) [`1873859`](https://github.com/PythoughtsAI/pythinker-code/commit/1873859b0ef093a956dfd19e1530e920e7118160) - Slim the LLM diagnostic logs with fewer, more compact fields. +- [#190](https://github.com/PyModel/pythinker-code/pull/190) [`1873859`](https://github.com/PyModel/pythinker-code/commit/1873859b0ef093a956dfd19e1530e920e7118160) - Slim the LLM diagnostic logs with fewer, more compact fields. -- [#185](https://github.com/PythoughtsAI/pythinker-code/pull/185) [`114777e`](https://github.com/PythoughtsAI/pythinker-code/commit/114777e859680f807375760271533e2dc396af5d) - Split `RuntimeConfig` into `Kaos` and `ToolServices` and update all references accordingly. +- [#185](https://github.com/PyModel/pythinker-code/pull/185) [`114777e`](https://github.com/PyModel/pythinker-code/commit/114777e859680f807375760271533e2dc396af5d) - Split `RuntimeConfig` into `Kaos` and `ToolServices` and update all references accordingly. -- [#189](https://github.com/PythoughtsAI/pythinker-code/pull/189) [`564721f`](https://github.com/PythoughtsAI/pythinker-code/commit/564721fe16e582b2774835b01dec799cbb1d0122) - Clarify subagent and background task stop messages as user-initiated. +- [#189](https://github.com/PyModel/pythinker-code/pull/189) [`564721f`](https://github.com/PyModel/pythinker-code/commit/564721fe16e582b2774835b01dec799cbb1d0122) - Clarify subagent and background task stop messages as user-initiated. -- [#206](https://github.com/PythoughtsAI/pythinker-code/pull/206) [`07d51e4`](https://github.com/PythoughtsAI/pythinker-code/commit/07d51e4add6ee23a56fb8745aa7754f05f3d6d36) - Relocate shared tool service typing to the tool support layer. +- [#206](https://github.com/PyModel/pythinker-code/pull/206) [`07d51e4`](https://github.com/PyModel/pythinker-code/commit/07d51e4add6ee23a56fb8745aa7754f05f3d6d36) - Relocate shared tool service typing to the tool support layer. -- [#215](https://github.com/PythoughtsAI/pythinker-code/pull/215) [`b9860e9`](https://github.com/PythoughtsAI/pythinker-code/commit/b9860e9f6ec65eb5dfdabbad54f1a87d69f4f00a) - Align the datasource plugin with the generic two-tool workflow. +- [#215](https://github.com/PyModel/pythinker-code/pull/215) [`b9860e9`](https://github.com/PyModel/pythinker-code/commit/b9860e9f6ec65eb5dfdabbad54f1a87d69f4f00a) - Align the datasource plugin with the generic two-tool workflow. -- [#200](https://github.com/PythoughtsAI/pythinker-code/pull/200) [`5159af3`](https://github.com/PythoughtsAI/pythinker-code/commit/5159af341c7d388a158e41afb470a2281333f329) - Keep blocked prompt hook conversations available to subsequent model turns. +- [#200](https://github.com/PyModel/pythinker-code/pull/200) [`5159af3`](https://github.com/PyModel/pythinker-code/commit/5159af341c7d388a158e41afb470a2281333f329) - Keep blocked prompt hook conversations available to subsequent model turns. ## 0.5.0 ### Minor Changes -- [#163](https://github.com/PythoughtsAI/pythinker-code/pull/163) [`07dd604`](https://github.com/PythoughtsAI/pythinker-code/commit/07dd604c3c7f453dfb0c0a601bb1c44a8114bb3b) - Add `/auto` slash command and `--auto` CLI flag for auto permission mode. +- [#163](https://github.com/PyModel/pythinker-code/pull/163) [`07dd604`](https://github.com/PyModel/pythinker-code/commit/07dd604c3c7f453dfb0c0a601bb1c44a8114bb3b) - Add `/auto` slash command and `--auto` CLI flag for auto permission mode. -- [#157](https://github.com/PythoughtsAI/pythinker-code/pull/157) [`971fce6`](https://github.com/PythoughtsAI/pythinker-code/commit/971fce6e528c2b210df1852d7cd12bcda71014fd) - Add scheduled tasks: +- [#157](https://github.com/PyModel/pythinker-code/pull/157) [`971fce6`](https://github.com/PyModel/pythinker-code/commit/971fce6e528c2b210df1852d7cd12bcda71014fd) - Add scheduled tasks: You can now ask the agent to remind you at a specific time, run a task on a recurring cron schedule (for example, check a deploy every 5 minutes or run a daily report every weekday at 9am), or come back on its own in a few minutes to continue what it was doing. @@ -1217,142 +2053,142 @@ ### Patch Changes -- [#162](https://github.com/PythoughtsAI/pythinker-code/pull/162) [`f3c1015`](https://github.com/PythoughtsAI/pythinker-code/commit/f3c1015b677d40fb94957ab121da5e14480a890f) - Add a clickable changelog link to the update prompt. +- [#162](https://github.com/PyModel/pythinker-code/pull/162) [`f3c1015`](https://github.com/PyModel/pythinker-code/commit/f3c1015b677d40fb94957ab121da5e14480a890f) - Add a clickable changelog link to the update prompt. -- [#150](https://github.com/PythoughtsAI/pythinker-code/pull/150) [`8b5a251`](https://github.com/PythoughtsAI/pythinker-code/commit/8b5a25161ceac02894d1a09c78a5aa883e460c8e) - Show the full Bash command when expanding a Bash tool card with `ctrl+o`. The header still truncates long commands at 60 chars, but the expanded view now reveals the complete multi-line command above the output. +- [#150](https://github.com/PyModel/pythinker-code/pull/150) [`8b5a251`](https://github.com/PyModel/pythinker-code/commit/8b5a25161ceac02894d1a09c78a5aa883e460c8e) - Show the full Bash command when expanding a Bash tool card with `ctrl+o`. The header still truncates long commands at 60 chars, but the expanded view now reveals the complete multi-line command above the output. -- [#158](https://github.com/PythoughtsAI/pythinker-code/pull/158) [`d1f9a83`](https://github.com/PythoughtsAI/pythinker-code/commit/d1f9a83d7af16ab78b7da571b3de146767864f3a) - Shorten the session title written to the terminal window/tab from 80 to 32 characters so long first messages and pasted content no longer stretch the tab bar past readable width. +- [#158](https://github.com/PyModel/pythinker-code/pull/158) [`d1f9a83`](https://github.com/PyModel/pythinker-code/commit/d1f9a83d7af16ab78b7da571b3de146767864f3a) - Shorten the session title written to the terminal window/tab from 80 to 32 characters so long first messages and pasted content no longer stretch the tab bar past readable width. -- [#146](https://github.com/PythoughtsAI/pythinker-code/pull/146) [`76cbf86`](https://github.com/PythoughtsAI/pythinker-code/commit/76cbf86e2035f905242d30009052254eee52bcf8) - Cap the inline todo panel at five rows and show a `+N more` indicator so long task lists no longer fill the screen. +- [#146](https://github.com/PyModel/pythinker-code/pull/146) [`76cbf86`](https://github.com/PyModel/pythinker-code/commit/76cbf86e2035f905242d30009052254eee52bcf8) - Cap the inline todo panel at five rows and show a `+N more` indicator so long task lists no longer fill the screen. -- [#120](https://github.com/PythoughtsAI/pythinker-code/pull/120) [`8515472`](https://github.com/PythoughtsAI/pythinker-code/commit/85154724764a3478bfc0ef40d8b5a1def5063ec7) - Fix compaction to handle edge cases where no messages are compactable and improve retry logic. +- [#120](https://github.com/PyModel/pythinker-code/pull/120) [`8515472`](https://github.com/PyModel/pythinker-code/commit/85154724764a3478bfc0ef40d8b5a1def5063ec7) - Fix compaction to handle edge cases where no messages are compactable and improve retry logic. -- [#159](https://github.com/PythoughtsAI/pythinker-code/pull/159) [`c88b7bf`](https://github.com/PythoughtsAI/pythinker-code/commit/c88b7bf0efcf6f0e5f904c20471ab865cb912e40) - Fix official datasource tools to preserve complete responses and write returned result files. +- [#159](https://github.com/PyModel/pythinker-code/pull/159) [`c88b7bf`](https://github.com/PyModel/pythinker-code/commit/c88b7bf0efcf6f0e5f904c20471ab865cb912e40) - Fix official datasource tools to preserve complete responses and write returned result files. -- [#124](https://github.com/PythoughtsAI/pythinker-code/pull/124) [`3e72f25`](https://github.com/PythoughtsAI/pythinker-code/commit/3e72f25ad93dac02456ebb1e29d80cf904258c14) - Fix migration mapping the legacy `default_yolo` key to the dead `yolo` field instead of `default_permission_mode`. +- [#124](https://github.com/PyModel/pythinker-code/pull/124) [`3e72f25`](https://github.com/PyModel/pythinker-code/commit/3e72f25ad93dac02456ebb1e29d80cf904258c14) - Fix migration mapping the legacy `default_yolo` key to the dead `yolo` field instead of `default_permission_mode`. -- [#164](https://github.com/PythoughtsAI/pythinker-code/pull/164) [`0a76658`](https://github.com/PythoughtsAI/pythinker-code/commit/0a766584cba68b2e906a5528c286a8481bd47ed3) - Clarify plugin manager keyboard shortcuts and show plugin state changes inline. +- [#164](https://github.com/PyModel/pythinker-code/pull/164) [`0a76658`](https://github.com/PyModel/pythinker-code/commit/0a766584cba68b2e906a5528c286a8481bd47ed3) - Clarify plugin manager keyboard shortcuts and show plugin state changes inline. -- [#142](https://github.com/PythoughtsAI/pythinker-code/pull/142) [`dad2b87`](https://github.com/PythoughtsAI/pythinker-code/commit/dad2b87ceeb054204027709751f72baadf04b708) - Refactor TUI code structure. +- [#142](https://github.com/PyModel/pythinker-code/pull/142) [`dad2b87`](https://github.com/PyModel/pythinker-code/commit/dad2b87ceeb054204027709751f72baadf04b708) - Refactor TUI code structure. -- [#166](https://github.com/PythoughtsAI/pythinker-code/pull/166) [`92e1d8c`](https://github.com/PythoughtsAI/pythinker-code/commit/92e1d8c72bfb1ab31a46608120670698bbf582b8) - Report discovered plugin skills in plugin manager summaries. +- [#166](https://github.com/PyModel/pythinker-code/pull/166) [`92e1d8c`](https://github.com/PyModel/pythinker-code/commit/92e1d8c72bfb1ab31a46608120670698bbf582b8) - Report discovered plugin skills in plugin manager summaries. -- [#139](https://github.com/PythoughtsAI/pythinker-code/pull/139) [`50251a1`](https://github.com/PythoughtsAI/pythinker-code/commit/50251a136093c27c0d69a730b267b746dea47468) - Show file content and diff in Write and Edit approval prompts, and open them in a dedicated full-screen viewer on ctrl+e instead of expanding inline. +- [#139](https://github.com/PyModel/pythinker-code/pull/139) [`50251a1`](https://github.com/PyModel/pythinker-code/commit/50251a136093c27c0d69a730b267b746dea47468) - Show file content and diff in Write and Edit approval prompts, and open them in a dedicated full-screen viewer on ctrl+e instead of expanding inline. -- [#117](https://github.com/PythoughtsAI/pythinker-code/pull/117) [`a6d379b`](https://github.com/PythoughtsAI/pythinker-code/commit/a6d379b2ceea4bf988517bdf357d1931a1fb1f05) - Offload large base64 media payloads from wire.jsonl into external blob files to reduce wire size and memory pressure during session replay. Includes an in-memory read-through cache on `BlobStore` so repeated rehydration avoids redundant disk reads. +- [#117](https://github.com/PyModel/pythinker-code/pull/117) [`a6d379b`](https://github.com/PyModel/pythinker-code/commit/a6d379b2ceea4bf988517bdf357d1931a1fb1f05) - Offload large base64 media payloads from wire.jsonl into external blob files to reduce wire size and memory pressure during session replay. Includes an in-memory read-through cache on `BlobStore` so repeated rehydration avoids redundant disk reads. -- [#150](https://github.com/PythoughtsAI/pythinker-code/pull/150) [`8b5a251`](https://github.com/PythoughtsAI/pythinker-code/commit/8b5a25161ceac02894d1a09c78a5aa883e460c8e) - Wrap long question, body, and option text in the AskUserQuestion dialog instead of truncating with an ellipsis. The question prompt, body description, option label, option description, and submit-tab review entries now flow onto multiple lines with a hanging indent. +- [#150](https://github.com/PyModel/pythinker-code/pull/150) [`8b5a251`](https://github.com/PyModel/pythinker-code/commit/8b5a25161ceac02894d1a09c78a5aa883e460c8e) - Wrap long question, body, and option text in the AskUserQuestion dialog instead of truncating with an ellipsis. The question prompt, body description, option label, option description, and submit-tab review entries now flow onto multiple lines with a hanging indent. ## 0.4.0 ### Minor Changes -- [#116](https://github.com/PythoughtsAI/pythinker-code/pull/116) [`2c7a8cc`](https://github.com/PythoughtsAI/pythinker-code/commit/2c7a8cc010a7b8134c5f16185e031a6de4585165) - Expand folded paste markers on second paste. When the cursor is on a paste marker (e.g. `[paste [#1](https://github.com/PythoughtsAI/pythinker-code/issues/1) +15 lines]`) and the user pastes again, the marker expands back to the original content instead of inserting new clipboard data. +- [#116](https://github.com/PyModel/pythinker-code/pull/116) [`2c7a8cc`](https://github.com/PyModel/pythinker-code/commit/2c7a8cc010a7b8134c5f16185e031a6de4585165) - Expand folded paste markers on second paste. When the cursor is on a paste marker (e.g. `[paste [#1](https://github.com/PyModel/pythinker-code/issues/1) +15 lines]`) and the user pastes again, the marker expands back to the original content instead of inserting new clipboard data. -- [#26](https://github.com/PythoughtsAI/pythinker-code/pull/26) [`2b74025`](https://github.com/PythoughtsAI/pythinker-code/commit/2b74025302be9b42e68a15f33333c55d64a6c9e7) - Rework tool permissions: reads outside cwd no longer prompt, session approvals match the exact call, and path-based rules are case-insensitive. +- [#26](https://github.com/PyModel/pythinker-code/pull/26) [`2b74025`](https://github.com/PyModel/pythinker-code/commit/2b74025302be9b42e68a15f33333c55d64a6c9e7) - Rework tool permissions: reads outside cwd no longer prompt, session approvals match the exact call, and path-based rules are case-insensitive. -- [#119](https://github.com/PythoughtsAI/pythinker-code/pull/119) [`ebf6e81`](https://github.com/PythoughtsAI/pythinker-code/commit/ebf6e8181ea20a0fcf6a609195ccf5b6cc2a665a) - Add user-global plugin installation, interactive plugin management, plugin-provided skills, and plugin-owned MCP servers. +- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`ebf6e81`](https://github.com/PyModel/pythinker-code/commit/ebf6e8181ea20a0fcf6a609195ccf5b6cc2a665a) - Add user-global plugin installation, interactive plugin management, plugin-provided skills, and plugin-owned MCP servers. -- [#112](https://github.com/PythoughtsAI/pythinker-code/pull/112) [`d03f6f4`](https://github.com/PythoughtsAI/pythinker-code/commit/d03f6f4fa582314a4330d0049fed6a0baae7271a) - Add `/export-debug-zip` slash command to export the current session as a debug ZIP archive directly from the TUI. +- [#112](https://github.com/PyModel/pythinker-code/pull/112) [`d03f6f4`](https://github.com/PyModel/pythinker-code/commit/d03f6f4fa582314a4330d0049fed6a0baae7271a) - Add `/export-debug-zip` slash command to export the current session as a debug ZIP archive directly from the TUI. -- [#113](https://github.com/PythoughtsAI/pythinker-code/pull/113) [`028d069`](https://github.com/PythoughtsAI/pythinker-code/commit/028d069b12d8377c5c307b94f11f02233d9c0a26) - Add `/export-md` slash command to export the current session as a Markdown file. +- [#113](https://github.com/PyModel/pythinker-code/pull/113) [`028d069`](https://github.com/PyModel/pythinker-code/commit/028d069b12d8377c5c307b94f11f02233d9c0a26) - Add `/export-md` slash command to export the current session as a Markdown file. ### Patch Changes -- [#105](https://github.com/PythoughtsAI/pythinker-code/pull/105) [`d599183`](https://github.com/PythoughtsAI/pythinker-code/commit/d599183c8eccea813d7aa5ddd974e72139cbb63c) - Enhance `pythinker export` to include more diagnostic information in the manifest. +- [#105](https://github.com/PyModel/pythinker-code/pull/105) [`d599183`](https://github.com/PyModel/pythinker-code/commit/d599183c8eccea813d7aa5ddd974e72139cbb63c) - Enhance `pythinker export` to include more diagnostic information in the manifest. -- [#89](https://github.com/PythoughtsAI/pythinker-code/pull/89) [`61cae59`](https://github.com/PythoughtsAI/pythinker-code/commit/61cae592fac0f1d824ee28263375937452f719ff) - Prevent the TUI from crashing when pull request lookup fails during startup. +- [#89](https://github.com/PyModel/pythinker-code/pull/89) [`61cae59`](https://github.com/PyModel/pythinker-code/commit/61cae592fac0f1d824ee28263375937452f719ff) - Prevent the TUI from crashing when pull request lookup fails during startup. -- [#97](https://github.com/PythoughtsAI/pythinker-code/pull/97) [`2e8c417`](https://github.com/PythoughtsAI/pythinker-code/commit/2e8c417818bb68a71789e4966f18c2be6d39d835) - Fix thinking spinner leaking past turn end when an empty thinking delta creates an orphaned thinking component. +- [#97](https://github.com/PyModel/pythinker-code/pull/97) [`2e8c417`](https://github.com/PyModel/pythinker-code/commit/2e8c417818bb68a71789e4966f18c2be6d39d835) - Fix thinking spinner leaking past turn end when an empty thinking delta creates an orphaned thinking component. -- [#103](https://github.com/PythoughtsAI/pythinker-code/pull/103) [`73c4232`](https://github.com/PythoughtsAI/pythinker-code/commit/73c4232e711c8e7c701d21a07c7b6aace3476360) - Show the original session resume command after forking a session. +- [#103](https://github.com/PyModel/pythinker-code/pull/103) [`73c4232`](https://github.com/PyModel/pythinker-code/commit/73c4232e711c8e7c701d21a07c7b6aace3476360) - Show the original session resume command after forking a session. -- [#88](https://github.com/PythoughtsAI/pythinker-code/pull/88) [`ce420bf`](https://github.com/PythoughtsAI/pythinker-code/commit/ce420bf1c6825080d4c7ec9e155f96039d3376e7) - Refactor TUI resume replay logic. +- [#88](https://github.com/PyModel/pythinker-code/pull/88) [`ce420bf`](https://github.com/PyModel/pythinker-code/commit/ce420bf1c6825080d4c7ec9e155f96039d3376e7) - Refactor TUI resume replay logic. -- [#119](https://github.com/PythoughtsAI/pythinker-code/pull/119) [`ebf6e81`](https://github.com/PythoughtsAI/pythinker-code/commit/ebf6e8181ea20a0fcf6a609195ccf5b6cc2a665a) - Restrict plugin zip installs to manifests at the archive root or a single wrapper directory. +- [#119](https://github.com/PyModel/pythinker-code/pull/119) [`ebf6e81`](https://github.com/PyModel/pythinker-code/commit/ebf6e8181ea20a0fcf6a609195ccf5b6cc2a665a) - Restrict plugin zip installs to manifests at the archive root or a single wrapper directory. -- [#102](https://github.com/PythoughtsAI/pythinker-code/pull/102) [`6f55f1d`](https://github.com/PythoughtsAI/pythinker-code/commit/6f55f1d0aff12ce13cea616a1f37e6242beb2ff8) - Route session-tagged log entries exclusively to the session sink instead of duplicating them to the global sink. Consistently omit stable main-agent context keys from all session log lines that carry `agentId=main`. +- [#102](https://github.com/PyModel/pythinker-code/pull/102) [`6f55f1d`](https://github.com/PyModel/pythinker-code/commit/6f55f1d0aff12ce13cea616a1f37e6242beb2ff8) - Route session-tagged log entries exclusively to the session sink instead of duplicating them to the global sink. Consistently omit stable main-agent context keys from all session log lines that carry `agentId=main`. -- [#92](https://github.com/PythoughtsAI/pythinker-code/pull/92) [`4e458d6`](https://github.com/PythoughtsAI/pythinker-code/commit/4e458d63643a56a2fb1ba9f908c774e56eef1c75) - Use one retry classification for transient LLM failures across regular turns and compaction. +- [#92](https://github.com/PyModel/pythinker-code/pull/92) [`4e458d6`](https://github.com/PyModel/pythinker-code/commit/4e458d63643a56a2fb1ba9f908c774e56eef1c75) - Use one retry classification for transient LLM failures across regular turns and compaction. ## 0.3.0 ### Minor Changes -- [#76](https://github.com/PythoughtsAI/pythinker-code/pull/76) [`6f22ae4`](https://github.com/PythoughtsAI/pythinker-code/commit/6f22ae48f84a062a65dcaa9510ffe96f40ab503b) - /logout now opens a picker so you can choose which provider to log out of, instead of always logging out the one tied to the current model. The current provider is highlighted by default, so pressing Enter matches the previous behavior. The command is also available as /disconnect. +- [#76](https://github.com/PyModel/pythinker-code/pull/76) [`6f22ae4`](https://github.com/PyModel/pythinker-code/commit/6f22ae48f84a062a65dcaa9510ffe96f40ab503b) - /logout now opens a picker so you can choose which provider to log out of, instead of always logging out the one tied to the current model. The current provider is highlighted by default, so pressing Enter matches the previous behavior. The command is also available as /disconnect. ### Patch Changes -- [#62](https://github.com/PythoughtsAI/pythinker-code/pull/62) [`e2b2b46`](https://github.com/PythoughtsAI/pythinker-code/commit/e2b2b46fc9c1d6a0ada67c590b8aa56e77c9c513) - Make `AgentRecords` hold the `Agent` instance directly and inline the restore dispatch logic. +- [#62](https://github.com/PyModel/pythinker-code/pull/62) [`e2b2b46`](https://github.com/PyModel/pythinker-code/commit/e2b2b46fc9c1d6a0ada67c590b8aa56e77c9c513) - Make `AgentRecords` hold the `Agent` instance directly and inline the restore dispatch logic. -- [#73](https://github.com/PythoughtsAI/pythinker-code/pull/73) [`bddc60f`](https://github.com/PythoughtsAI/pythinker-code/commit/bddc60f0e9af44d326dc0759a60bce93187f8a7b) - Prevent running the `/model` and `/sessions` slash commands while streaming or compacting context. +- [#73](https://github.com/PyModel/pythinker-code/pull/73) [`bddc60f`](https://github.com/PyModel/pythinker-code/commit/bddc60f0e9af44d326dc0759a60bce93187f8a7b) - Prevent running the `/model` and `/sessions` slash commands while streaming or compacting context. -- [#70](https://github.com/PythoughtsAI/pythinker-code/pull/70) [`d95b013`](https://github.com/PythoughtsAI/pythinker-code/commit/d95b01342a7921f0863ceb37abad7984d0245509) - Preserve catalog-declared interleaved reasoning fields for OpenAI-compatible models configured through `/connect`. +- [#70](https://github.com/PyModel/pythinker-code/pull/70) [`d95b013`](https://github.com/PyModel/pythinker-code/commit/d95b01342a7921f0863ceb37abad7984d0245509) - Preserve catalog-declared interleaved reasoning fields for OpenAI-compatible models configured through `/connect`. -- [#78](https://github.com/PythoughtsAI/pythinker-code/pull/78) [`61f7d0e`](https://github.com/PythoughtsAI/pythinker-code/commit/61f7d0e7a2b9933bdbe7eef9177e67e7386154a2) - Make OpenAI-compatible reasoner models work out of the box for hand-written provider configs. The `openai` provider now auto-detects thinking on incoming responses by scanning the de facto field set (`reasoning_content`, `reasoning_details`, `reasoning`), serializes thinking back as `reasoning_content` by default, and auto-injects `reasoning_effort` whenever the conversation history contains prior thinking — so DeepSeek, Qwen, One API and other gateway-fronted services no longer require a hand-set `reasoning_key`. The `reasoning_key` model-alias field remains available as an explicit override for non-standard gateways. +- [#78](https://github.com/PyModel/pythinker-code/pull/78) [`61f7d0e`](https://github.com/PyModel/pythinker-code/commit/61f7d0e7a2b9933bdbe7eef9177e67e7386154a2) - Make OpenAI-compatible reasoner models work out of the box for hand-written provider configs. The `openai` provider now auto-detects thinking on incoming responses by scanning the de facto field set (`reasoning_content`, `reasoning_details`, `reasoning`), serializes thinking back as `reasoning_content` by default, and auto-injects `reasoning_effort` whenever the conversation history contains prior thinking — so DeepSeek, Qwen, One API and other gateway-fronted services no longer require a hand-set `reasoning_key`. The `reasoning_key` model-alias field remains available as an explicit override for non-standard gateways. -- [#66](https://github.com/PythoughtsAI/pythinker-code/pull/66) [`8ddfc04`](https://github.com/PythoughtsAI/pythinker-code/commit/8ddfc0433e3a3a51f326116607d28b0f409e7d93) - Fix API key input dialog showing a masked dot in empty state. +- [#66](https://github.com/PyModel/pythinker-code/pull/66) [`8ddfc04`](https://github.com/PyModel/pythinker-code/commit/8ddfc0433e3a3a51f326116607d28b0f409e7d93) - Fix API key input dialog showing a masked dot in empty state. -- [#72](https://github.com/PythoughtsAI/pythinker-code/pull/72) [`0ce0072`](https://github.com/PythoughtsAI/pythinker-code/commit/0ce0072cb44ea2bd3a7ca9c54d141c150f0bbb77) - Fix user skills in ~/.agents/ not being loaded. +- [#72](https://github.com/PyModel/pythinker-code/pull/72) [`0ce0072`](https://github.com/PyModel/pythinker-code/commit/0ce0072cb44ea2bd3a7ca9c54d141c150f0bbb77) - Fix user skills in ~/.agents/ not being loaded. -- [#86](https://github.com/PythoughtsAI/pythinker-code/pull/86) [`5e354d0`](https://github.com/PythoughtsAI/pythinker-code/commit/5e354d0cc89816228d08c3ded17e75201fb300de) - Restore real-time token display for running subagents in the TUI. +- [#86](https://github.com/PyModel/pythinker-code/pull/86) [`5e354d0`](https://github.com/PyModel/pythinker-code/commit/5e354d0cc89816228d08c3ded17e75201fb300de) - Restore real-time token display for running subagents in the TUI. -- [#57](https://github.com/PythoughtsAI/pythinker-code/pull/57) [`8fb61f9`](https://github.com/PythoughtsAI/pythinker-code/commit/8fb61f9a3ead02bbd79f3a5ab605aba26e1cb847) - Hide the todo panel on resume when all todos are already completed. +- [#57](https://github.com/PyModel/pythinker-code/pull/57) [`8fb61f9`](https://github.com/PyModel/pythinker-code/commit/8fb61f9a3ead02bbd79f3a5ab605aba26e1cb847) - Hide the todo panel on resume when all todos are already completed. -- [#83](https://github.com/PythoughtsAI/pythinker-code/pull/83) [`7d9216d`](https://github.com/PythoughtsAI/pythinker-code/commit/7d9216d5aa1e96734c46c8d5d810ec7ed27b2275) - Always emit a paired tool result when a tool returns a malformed or missing result, preventing the next request from failing with a missing tool_call_id error. +- [#83](https://github.com/PyModel/pythinker-code/pull/83) [`7d9216d`](https://github.com/PyModel/pythinker-code/commit/7d9216d5aa1e96734c46c8d5d810ec7ed27b2275) - Always emit a paired tool result when a tool returns a malformed or missing result, preventing the next request from failing with a missing tool_call_id error. -- [#81](https://github.com/PythoughtsAI/pythinker-code/pull/81) [`1fbefc9`](https://github.com/PythoughtsAI/pythinker-code/commit/1fbefc99398d4a8ebebb377ff7ca2846483d1a9a) - Improve the Write tool UX. +- [#81](https://github.com/PyModel/pythinker-code/pull/81) [`1fbefc9`](https://github.com/PyModel/pythinker-code/commit/1fbefc99398d4a8ebebb377ff7ca2846483d1a9a) - Improve the Write tool UX. -- [#79](https://github.com/PythoughtsAI/pythinker-code/pull/79) [`5a90b53`](https://github.com/PythoughtsAI/pythinker-code/commit/5a90b53b045099ecb582a36d546e90a3978f0a75) - Fix Plan mode session resets so new sessions no longer fail after plan review rejection and continue receiving events after setup errors. +- [#79](https://github.com/PyModel/pythinker-code/pull/79) [`5a90b53`](https://github.com/PyModel/pythinker-code/commit/5a90b53b045099ecb582a36d546e90a3978f0a75) - Fix Plan mode session resets so new sessions no longer fail after plan review rejection and continue receiving events after setup errors. -- [#77](https://github.com/PythoughtsAI/pythinker-code/pull/77) [`fe60c21`](https://github.com/PythoughtsAI/pythinker-code/commit/fe60c215be8979f6abc8258e5255c66dd73d5a19) - Exit promptly when the controlling terminal goes away. The TUI now handles `SIGHUP` / `SIGTERM` and stdout/stderr `EIO` / `EPIPE` / `ENOTCONN` errors, preventing leftover `pythinker` processes that pin a CPU core after the parent shell or multiplexer dies unexpectedly. +- [#77](https://github.com/PyModel/pythinker-code/pull/77) [`fe60c21`](https://github.com/PyModel/pythinker-code/commit/fe60c215be8979f6abc8258e5255c66dd73d5a19) - Exit promptly when the controlling terminal goes away. The TUI now handles `SIGHUP` / `SIGTERM` and stdout/stderr `EIO` / `EPIPE` / `ENOTCONN` errors, preventing leftover `pythinker` processes that pin a CPU core after the parent shell or multiplexer dies unexpectedly. -- [#85](https://github.com/PythoughtsAI/pythinker-code/pull/85) [`2bb50a3`](https://github.com/PythoughtsAI/pythinker-code/commit/2bb50a38d8379e2fac57547b1a563722f713c8fd) - Avoid overly small local completion caps that can truncate reasoning before summaries are produced. +- [#85](https://github.com/PyModel/pythinker-code/pull/85) [`2bb50a3`](https://github.com/PyModel/pythinker-code/commit/2bb50a38d8379e2fac57547b1a563722f713c8fd) - Avoid overly small local completion caps that can truncate reasoning before summaries are produced. ## 0.2.0 ### Minor Changes -- [#30](https://github.com/PythoughtsAI/pythinker-code/pull/30) [`a200a29`](https://github.com/PythoughtsAI/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - Add a `/connect` command that configures a provider and model from a model catalog. +- [#30](https://github.com/PyModel/pythinker-code/pull/30) [`a200a29`](https://github.com/PyModel/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - Add a `/connect` command that configures a provider and model from a model catalog. -- [#30](https://github.com/PythoughtsAI/pythinker-code/pull/30) [`a200a29`](https://github.com/PythoughtsAI/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - The `/connect` provider and model pickers now support type-to-search filtering, and long lists are paginated. The `/model` picker is also paginated when many models are configured. +- [#30](https://github.com/PyModel/pythinker-code/pull/30) [`a200a29`](https://github.com/PyModel/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - The `/connect` provider and model pickers now support type-to-search filtering, and long lists are paginated. The `/model` picker is also paginated when many models are configured. -- [#25](https://github.com/PythoughtsAI/pythinker-code/pull/25) [`c4dd1c7`](https://github.com/PythoughtsAI/pythinker-code/commit/c4dd1c7ff298290ee17d4a6676f93284621f32e8) - Flatten tool call data by inlining tool names and arguments at the top level, and limit legacy record migration so it only rewrites matching tool call payloads. +- [#25](https://github.com/PyModel/pythinker-code/pull/25) [`c4dd1c7`](https://github.com/PyModel/pythinker-code/commit/c4dd1c7ff298290ee17d4a6676f93284621f32e8) - Flatten tool call data by inlining tool names and arguments at the top level, and limit legacy record migration so it only rewrites matching tool call payloads. ### Patch Changes -- [#9](https://github.com/PythoughtsAI/pythinker-code/pull/9) [`e503e69`](https://github.com/PythoughtsAI/pythinker-code/commit/e503e6963ab6cc6b4ed98c89389dbbb525fc6e9e) - Add `Ctrl-J` as an additional shortcut for inserting new lines in the TUI prompt. +- [#9](https://github.com/PyModel/pythinker-code/pull/9) [`e503e69`](https://github.com/PyModel/pythinker-code/commit/e503e6963ab6cc6b4ed98c89389dbbb525fc6e9e) - Add `Ctrl-J` as an additional shortcut for inserting new lines in the TUI prompt. -- [#22](https://github.com/PythoughtsAI/pythinker-code/pull/22) [`2004aed`](https://github.com/PythoughtsAI/pythinker-code/commit/2004aedfe1d4e5e17762108bf48b7b9aa6d4e25b) - Add wire record migration handling during session replay. +- [#22](https://github.com/PyModel/pythinker-code/pull/22) [`2004aed`](https://github.com/PyModel/pythinker-code/commit/2004aedfe1d4e5e17762108bf48b7b9aa6d4e25b) - Add wire record migration handling during session replay. -- [#33](https://github.com/PythoughtsAI/pythinker-code/pull/33) [`ab4bd09`](https://github.com/PythoughtsAI/pythinker-code/commit/ab4bd090825cffbd7ab656b47840b0060d6cf601) - Report the macOS product version in OAuth device information instead of the Darwin kernel version. +- [#33](https://github.com/PyModel/pythinker-code/pull/33) [`ab4bd09`](https://github.com/PyModel/pythinker-code/commit/ab4bd090825cffbd7ab656b47840b0060d6cf601) - Report the macOS product version in OAuth device information instead of the Darwin kernel version. -- [#52](https://github.com/PythoughtsAI/pythinker-code/pull/52) [`064343a`](https://github.com/PythoughtsAI/pythinker-code/commit/064343a6e565a525fbf38b3a1f70f7ff0235a5ed) - Correct the `X-Msh-Platform` header value to `pythinker_code_cli`. +- [#52](https://github.com/PyModel/pythinker-code/pull/52) [`064343a`](https://github.com/PyModel/pythinker-code/commit/064343a6e565a525fbf38b3a1f70f7ff0235a5ed) - Correct the `X-Msh-Platform` header value to `pythinker_code_cli`. -- [#38](https://github.com/PythoughtsAI/pythinker-code/pull/38) [`e9e4a48`](https://github.com/PythoughtsAI/pythinker-code/commit/e9e4a48633f2d216672e8905b0235107b5cbe34a) - Clarify the prompt-mode error when no model is configured by pointing users to the login flow. +- [#38](https://github.com/PyModel/pythinker-code/pull/38) [`e9e4a48`](https://github.com/PyModel/pythinker-code/commit/e9e4a48633f2d216672e8905b0235107b5cbe34a) - Clarify the prompt-mode error when no model is configured by pointing users to the login flow. -- [#13](https://github.com/PythoughtsAI/pythinker-code/pull/13) [`35726d7`](https://github.com/PythoughtsAI/pythinker-code/commit/35726d7a41d54a0e6cb19a21d16980fd462132e1) - Hide the empty current session from the sessions picker while keeping other empty sessions visible. +- [#13](https://github.com/PyModel/pythinker-code/pull/13) [`35726d7`](https://github.com/PyModel/pythinker-code/commit/35726d7a41d54a0e6cb19a21d16980fd462132e1) - Hide the empty current session from the sessions picker while keeping other empty sessions visible. -- [#31](https://github.com/PythoughtsAI/pythinker-code/pull/31) [`475ebad`](https://github.com/PythoughtsAI/pythinker-code/commit/475ebadc2070e3b878789f6a89ce191b1bd957a9) - Stop mentioning OAuth credentials in the migration UI — they are never migrated, so the previous "needs /login" notice misread as a failure. OAuth-only installs no longer trigger the migration screen. +- [#31](https://github.com/PyModel/pythinker-code/pull/31) [`475ebad`](https://github.com/PyModel/pythinker-code/commit/475ebadc2070e3b878789f6a89ce191b1bd957a9) - Stop mentioning OAuth credentials in the migration UI — they are never migrated, so the previous "needs /login" notice misread as a failure. OAuth-only installs no longer trigger the migration screen. -- [#31](https://github.com/PythoughtsAI/pythinker-code/pull/31) [`475ebad`](https://github.com/PythoughtsAI/pythinker-code/commit/475ebadc2070e3b878789f6a89ce191b1bd957a9) - Migrate user skills from `~/.pythinker/skills/` to `~/.pythinker-code/skills/` during the first-launch migration; existing target skills are kept. +- [#31](https://github.com/PyModel/pythinker-code/pull/31) [`475ebad`](https://github.com/PyModel/pythinker-code/commit/475ebadc2070e3b878789f6a89ce191b1bd957a9) - Migrate user skills from `~/.pythinker/skills/` to `~/.pythinker-code/skills/` during the first-launch migration; existing target skills are kept. -- [#30](https://github.com/PythoughtsAI/pythinker-code/pull/30) [`a200a29`](https://github.com/PythoughtsAI/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - When no models are configured, `/model` and the welcome panel now point users to `/login` (for Pythinker) and `/connect` (for other providers). +- [#30](https://github.com/PyModel/pythinker-code/pull/30) [`a200a29`](https://github.com/PyModel/pythinker-code/commit/a200a297ac8986ec4baa8d2cdc881ef71bc3abfc) - When no models are configured, `/model` and the welcome panel now point users to `/login` (for Pythinker) and `/connect` (for other providers). -- [#11](https://github.com/PythoughtsAI/pythinker-code/pull/11) [`15b018f`](https://github.com/PythoughtsAI/pythinker-code/commit/15b018fc84a36a9ebde598970e5b44bebe5d68c6) - Surface API-provided error messages during feedback, usage, login, and model setup failures. +- [#11](https://github.com/PyModel/pythinker-code/pull/11) [`15b018f`](https://github.com/PyModel/pythinker-code/commit/15b018fc84a36a9ebde598970e5b44bebe5d68c6) - Surface API-provided error messages during feedback, usage, login, and model setup failures. -- [#24](https://github.com/PythoughtsAI/pythinker-code/pull/24) [`7858821`](https://github.com/PythoughtsAI/pythinker-code/commit/7858821f2f1fecc9de666780fc62434ca76dcc82) - Persist model selections from the terminal UI to the default configuration, and honor the configured default thinking state for new sessions. +- [#24](https://github.com/PyModel/pythinker-code/pull/24) [`7858821`](https://github.com/PyModel/pythinker-code/commit/7858821f2f1fecc9de666780fc62434ca76dcc82) - Persist model selections from the terminal UI to the default configuration, and honor the configured default thinking state for new sessions. -- [#14](https://github.com/PythoughtsAI/pythinker-code/pull/14) [`0da6073`](https://github.com/PythoughtsAI/pythinker-code/commit/0da60730b9716c39a07e8a3a0a320e3af7ad30fa) - Move wire metadata handling into the record layer and keep persistence backends limited to storage operations. +- [#14](https://github.com/PyModel/pythinker-code/pull/14) [`0da6073`](https://github.com/PyModel/pythinker-code/commit/0da60730b9716c39a07e8a3a0a320e3af7ad30fa) - Move wire metadata handling into the record layer and keep persistence backends limited to storage operations. -- [#12](https://github.com/PythoughtsAI/pythinker-code/pull/12) [`89ea895`](https://github.com/PythoughtsAI/pythinker-code/commit/89ea8959eb9419d04e63645b4d89ca0e33f20d98) - Retry compaction responses that do not contain a summary before updating conversation history. +- [#12](https://github.com/PyModel/pythinker-code/pull/12) [`89ea895`](https://github.com/PyModel/pythinker-code/commit/89ea8959eb9419d04e63645b4d89ca0e33f20d98) - Retry compaction responses that do not contain a summary before updating conversation history. -- [#29](https://github.com/PythoughtsAI/pythinker-code/pull/29) [`df7a9ca`](https://github.com/PythoughtsAI/pythinker-code/commit/df7a9cab606e0f152bc45b1d1645d76210b1e0c4) - Avoid CPU spikes from large streamed tool arguments and coalesce high-frequency streaming UI updates. +- [#29](https://github.com/PyModel/pythinker-code/pull/29) [`df7a9ca`](https://github.com/PyModel/pythinker-code/commit/df7a9cab606e0f152bc45b1d1645d76210b1e0c4) - Avoid CPU spikes from large streamed tool arguments and coalesce high-frequency streaming UI updates. -- [#47](https://github.com/PythoughtsAI/pythinker-code/pull/47) [`07ed2cf`](https://github.com/PythoughtsAI/pythinker-code/commit/07ed2cf9d4f01985c00c004b3bc0cc8d2587044b) - Emit session resume hint as a structured meta message in stream-json output format. +- [#47](https://github.com/PyModel/pythinker-code/pull/47) [`07ed2cf`](https://github.com/PyModel/pythinker-code/commit/07ed2cf9d4f01985c00c004b3bc0cc8d2587044b) - Emit session resume hint as a structured meta message in stream-json output format. -- [#49](https://github.com/PythoughtsAI/pythinker-code/pull/49) [`cf2227e`](https://github.com/PythoughtsAI/pythinker-code/commit/cf2227e8a5222ad9bd1167b573b62599d0efd906) - Resume sessions with a newer wire protocol version instead of failing. A warning is now shown in the TUI and records are replayed without migration. +- [#49](https://github.com/PyModel/pythinker-code/pull/49) [`cf2227e`](https://github.com/PyModel/pythinker-code/commit/cf2227e8a5222ad9bd1167b573b62599d0efd906) - Resume sessions with a newer wire protocol version instead of failing. A warning is now shown in the TUI and records are replayed without migration. -- [#18](https://github.com/PythoughtsAI/pythinker-code/pull/18) [`a964bd2`](https://github.com/PythoughtsAI/pythinker-code/commit/a964bd2430a583ff0364fde19eafabda03b489ed) - Warn tmux users when extended key settings may prevent modified Enter shortcuts from working. +- [#18](https://github.com/PyModel/pythinker-code/pull/18) [`a964bd2`](https://github.com/PyModel/pythinker-code/commit/a964bd2430a583ff0364fde19eafabda03b489ed) - Warn tmux users when extended key settings may prevent modified Enter shortcuts from working. -- [#17](https://github.com/PythoughtsAI/pythinker-code/pull/17) [`bfbd522`](https://github.com/PythoughtsAI/pythinker-code/commit/bfbd522a7160e597d673550f09fd4af089bfde34) - Let Pythinker requests use the remaining context window for completion tokens by default while keeping explicit environment limits as hard caps. +- [#17](https://github.com/PyModel/pythinker-code/pull/17) [`bfbd522`](https://github.com/PyModel/pythinker-code/commit/bfbd522a7160e597d673550f09fd4af089bfde34) - Let Pythinker requests use the remaining context window for completion tokens by default while keeping explicit environment limits as hard caps. diff --git a/apps/pythinker-code/README.md b/apps/pythinker-code/README.md index d223a5d9..7ec910d2 100644 --- a/apps/pythinker-code/README.md +++ b/apps/pythinker-code/README.md @@ -2,15 +2,11 @@ > The Starting Point for Next-Gen Agents -[![npm](https://img.shields.io/npm/v/@pymodel/pythinker-code)](https://www.npmjs.com/package/@pymodel/pythinker-code) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://pymodel.github.io/pythinker-code/) - -

- Pythinker Code terminal demo -

+[![npm](https://img.shields.io/npm/v/@pymodel/pythinker-code)](https://www.npmjs.com/package/@pymodel/pythinker-code) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Docs](https://img.shields.io/badge/docs-online-blue)](https://code.pythinker.com/pythinker-code/en/) ## What is Pythinker Code CLI -Pythinker Code CLI is an AI coding agent that runs in your terminal. It can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Pythoughts's Pythinker models and can also be configured to use other compatible providers. +Pythinker Code CLI is an AI coding agent that runs in your terminal. It can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with PyModel's Pythinker models and can also be configured to use other compatible providers. ## Install @@ -19,13 +15,13 @@ The recommended install path is the official script. It does not require Node.js - **macOS / Linux**: ```sh -curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash +curl -fsSL https://code.kimi.com/pythinker-code/install.sh | bash ``` - **Windows (PowerShell)**: ```powershell -irm https://code.pythinker.com/pythinker-code/install.ps1 | iex +irm https://code.kimi.com/pythinker-code/install.ps1 | iex ``` > On Windows, install [Git for Windows](https://gitforwindows.org/) before first launch because Pythinker Code CLI uses the bundled Git Bash as its shell environment. If Git Bash is installed in a custom location, set `PYTHINKER_SHELL_PATH` to the absolute path of `bash.exe`. @@ -38,7 +34,7 @@ pythinker --version ### Alternative: npm -If you prefer npm, use Node.js 26.4.0 or later: +If you prefer npm, use Node.js 22.19.0 or later: ```sh npm install -g @pymodel/pythinker-code @@ -50,7 +46,7 @@ Or with pnpm: pnpm add -g @pymodel/pythinker-code ``` -For upgrade and uninstall instructions, see the [Getting Started guide](https://pymodel.github.io/pythinker-code/guides/getting-started). +For upgrade and uninstall instructions, see the [Getting Started guide](https://code.pythinker.com/pythinker-code/en/guides/getting-started). ## Quick Start @@ -61,7 +57,7 @@ cd your-project pythinker ``` -On first launch, run `/login` inside Pythinker Code CLI and choose either Pythinker Code OAuth or a Pythinker Platform API key. After login, try a first task: +On first launch, run `/login` inside Pythinker Code CLI and choose either Pythinker Code OAuth or a Kimi Platform API key. After login, try a first task: ``` Take a look at this project and explain the main directories. @@ -79,8 +75,9 @@ Take a look at this project and explain the main directories. ## Documentation -- Full docs: https://pymodel.github.io/pythinker-code/ -- Getting Started: https://pymodel.github.io/pythinker-code/guides/getting-started +- Full docs: https://code.pythinker.com/pythinker-code/en/ +- 中文文档: https://code.pythinker.com/pythinker-code/zh/ +- Getting Started: https://code.pythinker.com/pythinker-code/en/guides/getting-started ## Repository & Issues diff --git a/apps/pythinker-code/package.json b/apps/pythinker-code/package.json index 8bdac9a5..32f36637 100644 --- a/apps/pythinker-code/package.json +++ b/apps/pythinker-code/package.json @@ -1,9 +1,9 @@ { "name": "@pymodel/pythinker-code", - "version": "0.21.2", + "version": "0.36.1", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", - "author": "Pythoughts", + "author": "PyModel", "homepage": "https://github.com/PyModel/pythinker-code/tree/main/apps/pythinker-code#readme", "repository": { "type": "git", @@ -23,11 +23,12 @@ "tui" ], "bin": { - "pythinker": "dist/launcher.mjs" + "pythinker": "dist/main.mjs" }, "files": [ "dist", "dist-web", + "native", "scripts/postinstall.mjs", "scripts/postinstall", "README.md" @@ -35,24 +36,24 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", - "#/cli/sub/server": "./src/cli/sub/server/index.ts", - "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", - "#/*": [ - "./src/*.ts", - "./src/*/index.ts", - "./src/*.d.ts" - ] + "#/tui/commands": "./src/tui/commands/index.ts", + "#/cli/sub/web": "./src/cli/sub/web/index.ts", + "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", + "#/generated/vis-web-asset": [ + "./src/generated/vis-web-asset.ts", + "./src/generated/vis-web-asset.d.ts" + ], + "#/*": "./src/*.ts" }, "publishConfig": { "access": "public", "provenance": true }, "scripts": { - "build": "pnpm -C ../pythinker-web run build && tsdown && node scripts/copy-web-assets.mjs", - "prebuild": "node scripts/build-dashboard-asset.mjs", + "build": "pnpm -C ../pythinker-web run build && tsdown && tsdown --config tsdown.dist-worker.config.ts && node scripts/copy-native-assets.mjs && node scripts/copy-web-assets.mjs", + "prebuild": "node scripts/build-vis-asset.mjs", "catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json", "smoke": "node scripts/smoke.mjs", - "test:opentui": "NODE_OPTIONS=--experimental-ffi vitest run test/tui/runtime/opentui-jsx-smoke.test.tsx test/tui/runtime/scrollback-round-trip.test.tsx test/scripts/dev-vite-runtime.test.ts test/tui/runtime/dialog-list-view.test.tsx test/tui/runtime/opentui-reactivity.test.tsx", "build:native:js": "node scripts/native/01-bundle.mjs", "build:native:sea": "node scripts/native/build.mjs --profile=local", "build:native:release": "node scripts/native/build.mjs --profile=release", @@ -61,12 +62,14 @@ "release:native:resolve": "node scripts/native/resolve-release.mjs", "test:native:smoke": "node scripts/native/smoke.mjs", "dev": "node scripts/dev.mjs", - "dev:cli-only": "node --experimental-ffi --import ../../build/register-raw-text-loader.mjs scripts/dev-vite-runtime.mjs", - "dev:server": "node --experimental-ffi --import ../../build/register-raw-text-loader.mjs scripts/dev-vite-runtime.mjs server run --foreground", + "dev:cli-only": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts", + "dev:server": "PYTHINKER_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server": "PYTHINKER_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", + "dev:kap-server:multi": "PYTHINKER_CODE_DEV_SERVER=1 tsx --tsconfig ./tsconfig.dev.json --import ../../build/register-raw-text-loader.mjs ./src/main.ts web --no-open --debug-endpoints", "dev:server:restart": "node scripts/dev-server-restart.mjs", "dev:plugin-marketplace": "node scripts/dev-plugin-marketplace-server.mjs", "build:plugin-marketplace": "node scripts/build-plugin-marketplace-cdn.mjs", - "dev:prod": "node dist/launcher.mjs", + "dev:prod": "node dist/main.mjs", "clean": "rm -rf dist", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "pnpm -w run build:packages && vitest run", @@ -76,53 +79,36 @@ }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.9", - "chalk": "^5.6.2", - "cli-highlight": "^2.1.11", - "commander": "^13.1.0", - "koffi": "^2.16.3", - "node-pty": "^1.1.0", - "pathe": "^2.0.3", - "pino-pretty": "^13.1.3", - "semver": "^7.8.5", - "smol-toml": "^1.7.1", - "zod": "^4.4.3" - }, - "dependencies": { - "@clack/prompts": "1.7.0", - "@opentui/core": "0.4.3", - "@opentui/keymap": "0.4.3", - "@opentui/solid": "0.4.3", - "solid-js": "1.9.12", - "web-tree-sitter": "0.25.10" + "node-pty": "^1.1.0" }, "devDependencies": { - "@earendil-works/pi-tui": "^0.83.0", "@pymodel/acp-adapter": "workspace:^", - "@pymodel/dashboard-server": "workspace:^", - "@pymodel/dashboard-web": "workspace:*", + "@pymodel/acp-server": "workspace:^", + "@pymodel/agent-core-v2": "workspace:^", + "@pymodel/kap-server": "workspace:^", + "@pymodel/migration-legacy": "workspace:^", + "@pymodel/minidb": "workspace:^", + "@pymodel/pi-tui": "workspace:^", "@pymodel/pythinker-code-oauth": "workspace:^", "@pymodel/pythinker-code-sdk": "workspace:^", "@pymodel/pythinker-telemetry": "workspace:^", - "@pymodel/pythinker-web": "workspace:^", - "@pymodel/server": "workspace:^", - "@types/node": "^26.1.2", - "@types/semver": "^7.8.0", + "@pymodel/vis-server": "workspace:^", + "@pymodel/vis-web": "workspace:*", + "@types/semver": "^7.7.0", "@types/yazl": "^2.4.6", - "@xterm/headless": "6.0.0", "chalk": "^5.4.1", "cli-highlight": "^2.1.11", "commander": "^13.1.0", + "jimp": "^1.6.1", "pathe": "^2.0.3", "postject": "1.0.0-alpha.6", "semver": "^7.7.4", - "smol-toml": "^1.7.1", + "smol-toml": "^1.6.1", "tsx": "^4.23.5", - "unplugin-solid": "1.0.0", - "vite": "^6.4.3", "yazl": "^3.3.1", - "zod": "^4.4.3" + "zod": "^4.3.6" }, "engines": { - "node": ">=26.4.0" + "node": ">=22.19.0" } } diff --git a/apps/pythinker-code/scripts/build-dashboard-asset.mjs b/apps/pythinker-code/scripts/build-dashboard-asset.mjs deleted file mode 100644 index cb126ada..00000000 --- a/apps/pythinker-code/scripts/build-dashboard-asset.mjs +++ /dev/null @@ -1,54 +0,0 @@ -// Builds the dashboard web single-file bundle, gzips it, and writes a generated -// TS module that embeds it as base64 so tsdown can later bundle it into -// dist/main.mjs (works identically for the npm package and the native SEA -// binary). -import { execSync } from 'node:child_process'; -import { gzipSync } from 'node:zlib'; -import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, '..', '..', '..'); -const dashboardWeb = join(repoRoot, 'apps', 'dashboard', 'web'); -const out = join(here, '..', 'src', 'generated', 'dashboard-web-asset.ts'); - -console.log('[build-dashboard-asset] building dashboard web single-file bundle…'); -try { - // Run vite with DASHBOARD_SINGLEFILE set on the spawn so the build is - // cross-platform (Node sets the env, not a POSIX-only inline-env shell - // prefix). `pnpm --filter X exec` runs in X's package dir, so vite picks up - // dashboard-web's vite.config.ts, which gates the single-file output on - // `process.env.DASHBOARD_SINGLEFILE === '1'`. - // execSync runs through the platform shell, which is required on Windows: - // pnpm's launcher is `pnpm.cmd`, which a bare argv exec cannot resolve (no - // PATHEXT without a shell). The win32 native binary IS built on Windows - // runners (.github/workflows/_native-build.yml), which run this generator. - // A single command string (not an args array) avoids the args+shell - // deprecation; the command is static (no injection surface). - execSync('pnpm --filter @pymodel/dashboard-web exec vite build', { - stdio: 'inherit', - cwd: repoRoot, - env: { ...process.env, DASHBOARD_SINGLEFILE: '1' }, - }); -} catch (err) { - throw new Error( - `[build-dashboard-asset] failed to run the dashboard-web single-file build via pnpm (is pnpm on PATH?): ${err instanceof Error ? err.message : String(err)}`, - ); -} - -const html = readFileSync(join(dashboardWeb, 'dist-single', 'index.html')); -if (html.length < 1024 || !html.toString('utf8', 0, 256).toLowerCase().includes(' 0) { const inherited = process.env[MARKETPLACE_ENV]?.trim(); marketplaceServer = await startPluginMarketplaceServer(); env[MARKETPLACE_ENV] = marketplaceServer.marketplaceUrl; + // Marks the URL as the dev server's own (serving this repo's catalog), so + // the CLI can tell it apart from a user-configured marketplace override. + env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] = '1'; console.error(`Plugin marketplace dev server: ${marketplaceServer.marketplaceUrl}`); if (inherited !== undefined && inherited.length > 0 && inherited !== marketplaceServer.marketplaceUrl) { console.error( @@ -35,20 +43,25 @@ if (externalUrl !== undefined && externalUrl.length > 0) { } } -const viteRuntime = resolve(APP_ROOT, 'scripts/dev-vite-runtime.mjs'); +const tsxCli = require.resolve('tsx/cli'); const cliArgs = process.argv.slice(2); if (cliArgs[0] === '--') cliArgs.shift(); const child = spawn( process.execPath, [ - '--experimental-ffi', + tsxCli, + // Use the dev tsconfig whose `include` covers packages/*/src, so tsx's + // esbuild transform sees `experimentalDecorators: true` for DI parameter + // decorators in agent-core. Mirrors `dev:server` in package.json. + '--tsconfig', + resolve(APP_ROOT, 'tsconfig.dev.json'), '--import', - '../../build/register-raw-text-loader.mjs', - viteRuntime, + pathToFileURL(resolve(REPO_ROOT, 'build/register-raw-text-loader.mjs')).href, + resolve(APP_ROOT, 'src/main.ts'), ...cliArgs, ], { - cwd: APP_ROOT, + cwd: REPO_ROOT, env, stdio: 'inherit', }, diff --git a/apps/pythinker-code/scripts/native/01-bundle.mjs b/apps/pythinker-code/scripts/native/01-bundle.mjs index 9e98be5b..a697df9e 100644 --- a/apps/pythinker-code/scripts/native/01-bundle.mjs +++ b/apps/pythinker-code/scripts/native/01-bundle.mjs @@ -6,15 +6,22 @@ import { run } from './exec.mjs'; const requireFromScript = createRequire(import.meta.url); const tsdownCliPath = requireFromScript.resolve('tsdown/run'); const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs'); -const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-dashboard-asset.mjs'); +const buildVisAssetPath = resolve(import.meta.dirname, '..', 'build-vis-asset.mjs'); export async function runBundleStep() { - // Generate the embedded `pythinker dashboard` web asset before bundling. The native + // Generate the embedded `pythinker vis` web asset before bundling. The native // tsdown run here never goes through the npm `prebuild` lifecycle, so the // generated module must be produced explicitly first or the bundle would // miss it (npm builds get it via the `prebuild` script). await run(process.execPath, [buildVisAssetPath]); await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']); + // Bundle the off-main-thread workers (the minidb text-build worker and + // the kap-server global-search worker) into self-contained ESM files so + // they can ride the SEA blob as assets (02-sea-blob.mjs) and be spawned + // from disk at runtime — bundled binaries otherwise lack the worker + // entries and heavy index work degrades to inline main-thread cores. + // Runs after the main bundle with clean:false so all verified files remain. + await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.worker.config.ts']); await run(process.execPath, [checkBundlePath]); } diff --git a/apps/pythinker-code/scripts/native/02-sea-blob.mjs b/apps/pythinker-code/scripts/native/02-sea-blob.mjs index 8777561e..434a7861 100644 --- a/apps/pythinker-code/scripts/native/02-sea-blob.mjs +++ b/apps/pythinker-code/scripts/native/02-sea-blob.mjs @@ -26,20 +26,6 @@ async function ensureBundleExists() { } } -export function createSeaConfig(assets) { - return { - main: nativeJsBundlePath(), - mainFormat: 'module', - output: nativeBlobPath(), - assets, - disableExperimentalSEAWarning: true, - useCodeCache: false, - useSnapshot: false, - execArgv: ['--experimental-ffi'], - execArgvExtension: 'env', - }; -} - async function writeSeaConfig(target) { await mkdir(nativeIntermediatesDir(), { recursive: true }); const { manifest, manifestJson, assets } = await collectNativeAssets({ @@ -60,9 +46,16 @@ async function writeSeaConfig(target) { ...assets, ...web.assets, }; - const config = createSeaConfig( - Object.fromEntries(Object.entries(seaAssets).sort(([a], [b]) => a.localeCompare(b))), - ); + const config = { + main: nativeJsBundlePath(), + output: nativeBlobPath(), + assets: Object.fromEntries( + Object.entries(seaAssets).sort(([a], [b]) => a.localeCompare(b)), + ), + disableExperimentalSEAWarning: true, + useCodeCache: false, + useSnapshot: false, + }; await writeFile(nativeSeaConfigPath(), `${JSON.stringify(config, null, 2)}\n`); console.log(`Collected native assets for ${manifest.target}:`); diff --git a/apps/pythinker-code/scripts/native/assets.mjs b/apps/pythinker-code/scripts/native/assets.mjs index 40d28ab6..41fb600e 100644 --- a/apps/pythinker-code/scripts/native/assets.mjs +++ b/apps/pythinker-code/scripts/native/assets.mjs @@ -5,7 +5,13 @@ import { createRequire } from 'node:module'; import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs'; +import { + KAP_SEARCH_WORKER_ASSET, + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION, + buildManifestKey, + buildRuntimeAssetKey, +} from './manifest.mjs'; import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs'; export { NATIVE_ASSET_MANIFEST_VERSION }; @@ -17,9 +23,7 @@ export const NATIVE_TARGETS = Object.freeze( SUPPORTED_TARGETS.map((t) => { const deps = resolveTargetDeps(t); const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName; - const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0]; - const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null; - return [t, { clipboardPackage: clipboardTarget, koffiTriplet }]; + return [t, { clipboardPackage: clipboardTarget }]; }), ), ); @@ -65,35 +69,16 @@ async function listFiles(root) { } function resolvePackageRootGeneric(requireFromApp, packageName, parentPackageName, appRoot, target) { - function resolveFromSearchPaths(packageRequire) { - for (const searchPath of packageRequire.resolve.paths(packageName) ?? []) { - const candidate = join(searchPath, packageName); - if (existsSync(join(candidate, 'package.json'))) { - return realpathSync(candidate); - } - } - return null; - } - - const appResolved = resolveFromSearchPaths(requireFromApp); - if (appResolved !== null) return appResolved; - try { return dirname(requireFromApp.resolve(`${packageName}/package.json`)); } catch (rootError) { if (parentPackageName !== null) { try { - const parentPackageRoot = resolvePackageRootGeneric( - requireFromApp, - parentPackageName, - null, - appRoot, - target, + const parentPackageJsonPath = realpathSync( + requireFromApp.resolve(`${parentPackageName}/package.json`), ); - const parentPackageJsonPath = join(parentPackageRoot, 'package.json'); const requireFromParent = createRequire(pathToFileURL(parentPackageJsonPath)); - const parentResolved = resolveFromSearchPaths(requireFromParent); - if (parentResolved !== null) return parentResolved; + return dirname(requireFromParent.resolve(`${packageName}/package.json`)); } catch {} } fail( @@ -179,15 +164,15 @@ async function addRuntimeDependencyFiles(packageRoot, filePath, selected) { async function collectPackageFiles({ packageName, packageRoot, - includeRuntimeFiles, includeNativeFiles, + includeEntryJs = true, nativeFileRelatives = [], }) { const packageJsonPath = join(packageRoot, 'package.json'); const packageJson = await readJson(packageJsonPath); const selected = new Set([packageJsonPath]); - if (includeRuntimeFiles) { + if (includeEntryJs) { const entry = resolvePackageEntry(packageRoot, packageJson); if (entry !== null) { selected.add(entry); @@ -250,7 +235,10 @@ async function packageManifestEntries({ packageName, packageRoot, files, target export const nativeAssetManifestKey = buildManifestKey; export function nativeAssetSummary(manifest) { - return manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`); + return [ + ...manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`), + `runtime: ${manifest.runtimeFiles.length} files`, + ]; } export async function collectNativeAssets({ appRoot, target }) { @@ -271,8 +259,8 @@ export async function collectNativeAssets({ appRoot, target }) { const files = await collectPackageFiles({ packageName: dep.resolvedName, packageRoot, - includeRuntimeFiles: dep.collect !== 'explicit-files', includeNativeFiles: dep.collect === 'native-files', + includeEntryJs: dep.collect !== 'native-file-only', nativeFileRelatives: dep.nativeFileRelatives, }); const result = await packageManifestEntries({ @@ -285,10 +273,29 @@ export async function collectNativeAssets({ appRoot, target }) { Object.assign(assets, result.assets); } + const runtimeFiles = []; + for (const [fileName, asset] of [ + ['text-build-worker.mjs', MINIDB_TEXT_BUILD_WORKER_ASSET], + ['search-worker.mjs', KAP_SEARCH_WORKER_ASSET], + ]) { + const workerSource = resolve(appRoot, 'dist-native', 'intermediates', fileName); + const workerBytes = await readFile(workerSource); + const workerAssetKey = buildRuntimeAssetKey(target, asset.key); + runtimeFiles.push({ + key: asset.key, + assetKey: workerAssetKey, + relativePath: asset.relativePath, + sha256: sha256(workerBytes), + mode: asset.mode, + }); + assets[workerAssetKey] = workerSource; + } + const manifest = { version: NATIVE_ASSET_MANIFEST_VERSION, target, packages: manifestPackages, + runtimeFiles, }; return { diff --git a/apps/pythinker-code/scripts/native/build.mjs b/apps/pythinker-code/scripts/native/build.mjs index 98b21fc4..1bfd55cd 100644 --- a/apps/pythinker-code/scripts/native/build.mjs +++ b/apps/pythinker-code/scripts/native/build.mjs @@ -22,29 +22,11 @@ if (!['local', 'release'].includes(profile)) { process.exit(1); } -const MINIMUM_NODE_VERSION = [26, 4, 0]; - -function isNodeVersionBelow(version, minimumVersion) { - const match = version.match( - /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, - ); - if (match === null) { - return true; - } - - const parts = match.slice(1, 4).map(Number); - for (const [index, minimumPart] of minimumVersion.entries()) { - if (parts[index] !== minimumPart) { - return parts[index] < minimumPart; - } - } - return match[4] !== undefined; -} - function ensureNodeVersion() { - if (isNodeVersionBelow(process.versions.node, MINIMUM_NODE_VERSION)) { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major < 24 || (major === 24 && minor < 15)) { console.error( - `Pythinker Code native SEA build requires Node.js >=26.4.0, current ${process.versions.node}.`, + `Pythinker Code native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`, ); process.exit(1); } diff --git a/apps/pythinker-code/scripts/native/check-bundle.mjs b/apps/pythinker-code/scripts/native/check-bundle.mjs index df2eca08..8b3519db 100644 --- a/apps/pythinker-code/scripts/native/check-bundle.mjs +++ b/apps/pythinker-code/scripts/native/check-bundle.mjs @@ -1,10 +1,8 @@ +import { existsSync, readFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; -import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; -import { nativeJsBundlePath } from './paths.mjs'; - -const bundlePath = nativeJsBundlePath(); -const text = readFileSync(bundlePath, 'utf-8'); +import { nativeIntermediatesDir, nativeJsBundlePath } from './paths.mjs'; const builtins = new Set([ ...builtinModules, @@ -23,21 +21,8 @@ const optionalRuntimeRequires = new Set([ 'utf-8-validate', ]); const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']); -// node-pty joins koffi here: both stay external and resolve from the extracted -// native-asset node_modules at runtime (the ESM bundle reaches node-pty via a -// dynamic import instead of the old CJS require). -const handledNativeRuntimeRequires = new Set(['koffi', 'node-pty']); - -function isAllowedSpecifier(specifier) { - if (builtins.has(specifier) || specifier.startsWith('node:')) return true; - if (optionalRuntimeRequires.has(specifier)) return true; - if (handledNativeRuntimeRequires.has(specifier)) return true; - return false; -} - -const errors = []; -function executableLines() { +function executableLines(text) { return text .split('\n') .map((line) => line.trim()) @@ -48,63 +33,52 @@ function executableLines() { }); } -for (const line of executableLines()) { - for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) { - const specifier = match[1]; - if (specifier.startsWith('.') || specifier.startsWith('/')) { - if (optionalRelativeRuntimeRequires.has(specifier)) continue; - errors.push(`relative require remains: ${specifier}`); - continue; - } - if (!isAllowedSpecifier(specifier)) { - errors.push(`external require remains: ${specifier}`); - } - } +function checkBundle(bundlePath, { worker = false } = {}) { + if (!existsSync(bundlePath)) return [`bundle does not exist: ${bundlePath}`]; + const text = readFileSync(bundlePath, 'utf-8'); + const errors = []; + const allowedExternal = worker ? new Set() : optionalRuntimeRequires; + const allowedRelative = worker ? new Set() : optionalRelativeRuntimeRequires; - for (const match of line.matchAll(/(? { if (specifier.startsWith('.') || specifier.startsWith('/')) { - errors.push(`relative dynamic import remains: ${specifier}`); - continue; + if (!allowedRelative.has(specifier)) errors.push(`relative ${kind} remains: ${specifier}`); + return; } - if (!isAllowedSpecifier(specifier)) { - errors.push(`external dynamic import remains: ${specifier}`); + if (!builtins.has(specifier) && !specifier.startsWith('node:') && !allowedExternal.has(specifier)) { + errors.push(`external ${kind} remains: ${specifier}`); } - } + }; - if (line.startsWith('import ')) { - for (const match of line.matchAll( - /(?:\bfrom\s+|^import\s*)["']([^"']+)["']/g, - )) { - const specifier = match[1]; - if (specifier.startsWith('.') || specifier.startsWith('/')) { - errors.push(`relative import remains: ${specifier}`); - continue; - } - if (!isAllowedSpecifier(specifier)) { - errors.push(`external import remains: ${specifier}`); - } + for (const line of executableLines(text)) { + for (const match of line.matchAll(/(? 0) { - console.error(`Native JS bundle check failed for ${bundlePath}:`); - for (const error of errors) { - console.error(`- ${error}`); - } - process.exit(1); +const bundles = [ + { path: nativeJsBundlePath(), worker: false }, + { path: resolve(nativeIntermediatesDir(), 'text-build-worker.mjs'), worker: true }, + { path: resolve(nativeIntermediatesDir(), 'search-worker.mjs'), worker: true }, +]; +let failed = false; +for (const bundle of bundles) { + const errors = checkBundle(bundle.path, { worker: bundle.worker }); + if (errors.length === 0) continue; + failed = true; + console.error(`Native JS bundle check failed for ${bundle.path}:`); + for (const error of errors) console.error(`- ${error}`); } +if (failed) process.exit(1); diff --git a/apps/pythinker-code/scripts/native/manifest.mjs b/apps/pythinker-code/scripts/native/manifest.mjs index 30d5e9da..99f3b3a3 100644 --- a/apps/pythinker-code/scripts/native/manifest.mjs +++ b/apps/pythinker-code/scripts/native/manifest.mjs @@ -1,10 +1,26 @@ -export const NATIVE_ASSET_MANIFEST_VERSION = 1; +export const NATIVE_ASSET_MANIFEST_VERSION = 2; export const WEB_ASSET_MANIFEST_VERSION = 1; +export const MINIDB_TEXT_BUILD_WORKER_ASSET = Object.freeze({ + key: 'minidb-text-build-worker', + relativePath: 'runtime/minidb/text-build-worker.mjs', + mode: 0o644, +}); + +export const KAP_SEARCH_WORKER_ASSET = Object.freeze({ + key: 'kap-search-worker', + relativePath: 'runtime/kap-server/search-worker.mjs', + mode: 0o644, +}); + export function buildManifestKey(target) { return `native/${target}/manifest.json`; } +export function buildRuntimeAssetKey(target, key) { + return `native/${target}/runtime/${key}`; +} + export function isManifestVersionSupported(version) { return version === NATIVE_ASSET_MANIFEST_VERSION; } diff --git a/apps/pythinker-code/scripts/native/native-deps.mjs b/apps/pythinker-code/scripts/native/native-deps.mjs index d20f048f..6f43a627 100644 --- a/apps/pythinker-code/scripts/native/native-deps.mjs +++ b/apps/pythinker-code/scripts/native/native-deps.mjs @@ -9,8 +9,6 @@ * NATIVE_TARGETS table or resolvePackageRoot if/else chain. */ -import { resolveOpenTuiTarget } from './opentui-target.mjs'; - export const SUPPORTED_TARGETS = Object.freeze([ 'darwin-arm64', 'darwin-x64', @@ -29,13 +27,16 @@ const clipboardSubpackageByTarget = Object.freeze({ 'win32-x64': '@mariozechner/clipboard-win32-x64-msvc', }); -const koffiTripletByTarget = Object.freeze({ - 'darwin-arm64': 'darwin_arm64', - 'darwin-x64': 'darwin_x64', - 'linux-arm64': 'linux_arm64', - 'linux-x64': 'linux_x64', - 'win32-arm64': 'win32_arm64', - 'win32-x64': 'win32_x64', +// pi-tui ships platform-specific native helpers (no Linux build): +// - darwin: Shift-modifier detection for Terminal.app Shift+Enter +// - win32: enable ENABLE_VIRTUAL_TERMINAL_INPUT so Shift+Tab is distinguishable +const piTuiNativeFileByTarget = Object.freeze({ + 'darwin-arm64': ['native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node'], + 'darwin-x64': ['native/darwin/prebuilds/darwin-x64/darwin-modifiers.node'], + 'linux-arm64': [], + 'linux-x64': [], + 'win32-arm64': ['native/win32/prebuilds/win32-arm64/win32-console-mode.node'], + 'win32-x64': ['native/win32/prebuilds/win32-x64/win32-console-mode.node'], }); export function isSupportedTarget(target) { @@ -47,30 +48,17 @@ export function isSupportedTarget(target) { * @property {string} id — stable internal id used for parent refs * @property {(target: string) => string} name * — npm package name (may depend on target) - * @property {'js-only'|'native-files'|'js-and-native-file'|'explicit-files'|'virtual'} collect + * @property {'js-only'|'native-files'|'js-and-native-file'|'native-file-only'|'virtual'} collect * @property {string|null} parent * — id of another registered dep this nests under (for pnpm), * or null for top-level (resolvable from app root) * @property {(target: string) => string[]} [nativeFileRelatives] - * — explicit files relative to package root - * (used by 'js-and-native-file' and 'explicit-files'; - * native-files mode auto-scans *.node) + * — explicit list of .node files relative to package root + * (used by 'js-and-native-file' and 'native-file-only'; + * native-files mode auto-scans *.node). 'native-file-only' collects + * package.json + these .node files but skips the package entry JS. */ -const openTuiAssetFiles = Object.freeze([ - 'assets/javascript/highlights.scm', - 'assets/javascript/tree-sitter-javascript.wasm', - 'assets/markdown/highlights.scm', - 'assets/markdown/injections.scm', - 'assets/markdown/tree-sitter-markdown.wasm', - 'assets/markdown_inline/highlights.scm', - 'assets/markdown_inline/tree-sitter-markdown_inline.wasm', - 'assets/typescript/highlights.scm', - 'assets/typescript/tree-sitter-typescript.wasm', - 'assets/zig/highlights.scm', - 'assets/zig/tree-sitter-zig.wasm', -]); - /** @type {readonly NativeDepDescriptor[]} */ export const nativeDeps = Object.freeze([ { @@ -87,32 +75,14 @@ export const nativeDeps = Object.freeze([ }, { id: 'pi-tui', - name: () => '@earendil-works/pi-tui', - // pi-tui is bundled into main.mjs at build time — we don't collect it as - // a native dep, only register it so koffi can declare it as parent. - collect: 'virtual', - parent: null, - }, - { - id: 'koffi', - name: () => 'koffi', - collect: 'js-and-native-file', - parent: 'pi-tui', - nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`], - }, - { - id: 'opentui-core-assets', - name: () => '@opentui/core', - collect: 'explicit-files', + name: () => '@pymodel/pi-tui', + // pi-tui's JS is bundled into main.cjs, so only the platform-specific + // native helper (.node under native/) ships alongside the binary — its + // dist/ JS is intentionally NOT collected (it stays in the bundle). This + // keeps the SEA native-asset payload small. Linux has no native helper. + collect: 'native-file-only', parent: null, - nativeFileRelatives: () => [...openTuiAssetFiles], - }, - { - id: 'opentui-platform', - name: (target) => resolveOpenTuiTarget(target).packageName, - collect: 'explicit-files', - parent: 'opentui-core-assets', - nativeFileRelatives: (target) => [resolveOpenTuiTarget(target).libraryFile], + nativeFileRelatives: (target) => piTuiNativeFileByTarget[target] ?? [], }, ]); diff --git a/apps/pythinker-code/scripts/native/opentui-target.mjs b/apps/pythinker-code/scripts/native/opentui-target.mjs deleted file mode 100644 index 4ba61ea2..00000000 --- a/apps/pythinker-code/scripts/native/opentui-target.mjs +++ /dev/null @@ -1,37 +0,0 @@ -export const OPENTUI_TARGETS = Object.freeze({ - 'darwin-arm64': Object.freeze({ - packageName: '@opentui/core-darwin-arm64', - libraryFile: 'libopentui.dylib', - }), - 'darwin-x64': Object.freeze({ - packageName: '@opentui/core-darwin-x64', - libraryFile: 'libopentui.dylib', - }), - 'linux-arm64': Object.freeze({ - packageName: '@opentui/core-linux-arm64', - libraryFile: 'libopentui.so', - }), - 'linux-x64': Object.freeze({ - packageName: '@opentui/core-linux-x64', - libraryFile: 'libopentui.so', - }), - 'win32-arm64': Object.freeze({ - packageName: '@opentui/core-win32-arm64', - libraryFile: 'opentui.dll', - }), - 'win32-x64': Object.freeze({ - packageName: '@opentui/core-win32-x64', - libraryFile: 'opentui.dll', - }), -}); - -export function resolveOpenTuiTarget(target) { - if (target.startsWith('linux-') && target.endsWith('-musl')) { - throw new Error(`OpenTUI musl target is unsupported: ${target}`); - } - const entry = OPENTUI_TARGETS[target]; - if (entry === undefined) { - throw new Error(`Unsupported OpenTUI target: ${target}`); - } - return entry; -} diff --git a/apps/pythinker-code/scripts/native/paths.mjs b/apps/pythinker-code/scripts/native/paths.mjs index 47e8983b..1805e34c 100644 --- a/apps/pythinker-code/scripts/native/paths.mjs +++ b/apps/pythinker-code/scripts/native/paths.mjs @@ -27,7 +27,7 @@ export function nativeBinPath(target = targetTriple(), platform = process.platfo } export function nativeJsBundlePath() { - return resolve(nativeIntermediatesDir(), 'main.mjs'); + return resolve(nativeIntermediatesDir(), 'main.cjs'); } export function nativeBlobPath() { diff --git a/apps/pythinker-code/scripts/native/produce-manifest.mjs b/apps/pythinker-code/scripts/native/produce-manifest.mjs index ef34f691..a19b809f 100644 --- a/apps/pythinker-code/scripts/native/produce-manifest.mjs +++ b/apps/pythinker-code/scripts/native/produce-manifest.mjs @@ -22,7 +22,7 @@ if (!inputDir || !tag) { process.exit(1); } -// Tag formats `@pymodel/pythinker-code@x.y.z`, `vx.y.z`, or `x.y.z` — all normalize to x.y.z +// Normalize `@pymodel/pythinker-code@x.y.z`, `vx.y.z`, or `x.y.z` to x.y.z. const version = tag.replace(/^@pymodel\/pythinker-code@/, '').replace(/^v/, ''); const entries = await readdir(inputDir); diff --git a/apps/pythinker-code/scripts/native/smoke.mjs b/apps/pythinker-code/scripts/native/smoke.mjs index 35557940..c2aab9cc 100644 --- a/apps/pythinker-code/scripts/native/smoke.mjs +++ b/apps/pythinker-code/scripts/native/smoke.mjs @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { readFile, stat } from 'node:fs/promises'; +import { mkdir, readFile, rm, stat } from 'node:fs/promises'; import { resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -73,20 +73,20 @@ assertIncludes(helpOutput, 'Usage: pythinker', '--help'); const exportHelpOutput = await runPythinker(['export', '--help']); assertIncludes(exportHelpOutput, 'Usage: pythinker export', 'export --help'); -const nativeAssetOutput = await runPythinkerWithEnv(['--version'], { - PYTHINKER_CODE_HOME: smokeHome, - PYTHINKER_CODE_NATIVE_ASSET_SMOKE: '1', -}); -assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); - -const openTuiOutput = await runPythinkerWithEnv([], { - PYTHINKER_CODE_HOME: smokeHome, - PYTHINKER_CODE_OPENTUI_SMOKE: '1', -}); -assertIncludes( - openTuiOutput, - 'OpenTUI reactive smoke passed: BEFORE -> AFTER', - 'OpenTUI reactive smoke', -); +const smokeCache = resolve(smokeHome, 'cache'); +await rm(smokeHome, { recursive: true, force: true }); +await mkdir(smokeCache, { recursive: true }); +try { + const nativeAssetOutput = await runPythinkerWithEnv(['--version'], { + PYTHINKER_CODE_CACHE_DIR: smokeCache, + PYTHINKER_CODE_HOME: smokeHome, + PYTHINKER_CODE_NATIVE_ASSET_SMOKE: '1', + }); + assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke'); + assertIncludes(nativeAssetOutput, 'MiniDb worker build passed', 'MiniDb worker smoke'); + assertIncludes(nativeAssetOutput, 'search worker ready', 'search worker smoke'); +} finally { + await rm(smokeHome, { recursive: true, force: true }); +} console.log(`Native smoke passed: ${executablePath}`); diff --git a/apps/pythinker-code/scripts/smoke.mjs b/apps/pythinker-code/scripts/smoke.mjs index 93ebb073..096ec1e6 100644 --- a/apps/pythinker-code/scripts/smoke.mjs +++ b/apps/pythinker-code/scripts/smoke.mjs @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const bundlePath = resolve(appRoot, 'dist', 'launcher.mjs'); +const bundlePath = resolve(appRoot, 'dist', 'main.mjs'); const webIndexPath = resolve(appRoot, 'dist-web', 'index.html'); const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8')); const expectedVersion = packageJson.version; @@ -32,17 +32,12 @@ async function ensureRuntimeAssetsExist() { } } -async function runBundle(args, env) { +async function runBundle(args) { try { - const { stdout, stderr } = await execFileAsync( - process.execPath, - ['--experimental-ffi', bundlePath, ...args], - { - cwd: appRoot, - env: { ...process.env, ...env }, - maxBuffer: 1024 * 1024 * 16, - }, - ); + const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], { + cwd: appRoot, + maxBuffer: 1024 * 1024 * 16, + }); return `${stdout}${stderr}`; } catch (error) { const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message] @@ -73,6 +68,4 @@ assertIncludes(exportHelpOutput, 'Usage: pythinker export', 'export --help'); const webHelpOutput = await runBundle(['web', '--help']); assertIncludes(webHelpOutput, 'Usage: pythinker web', 'web --help'); -await runBundle([], { PYTHINKER_CODE_OPENTUI_SMOKE: '1' }); - console.log(`Bundle smoke passed: ${bundlePath}`); diff --git a/apps/pythinker-code/scripts/solid-runtime.mjs b/apps/pythinker-code/scripts/solid-runtime.mjs deleted file mode 100644 index b76a49dd..00000000 --- a/apps/pythinker-code/scripts/solid-runtime.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import { createRequire } from 'node:module'; - -const requireFromHere = createRequire(import.meta.url); -const solidRuntimeSpecifier = 'solid-js/dist/solid.js'; - -export const solidRuntimePath = requireFromHere.resolve(solidRuntimeSpecifier); -export const solidRuntimeAlias = { - find: /^solid-js$/u, - replacement: solidRuntimePath, -}; - -export function solidRuntimeAliasPlugin({ external = false } = {}) { - return { - name: 'pythinker-solid-runtime-alias', - resolveId(source) { - if (!solidRuntimeAlias.find.test(source)) return null; - return external - ? { id: solidRuntimeSpecifier, external: true } - : solidRuntimeAlias.replacement; - }, - }; -} diff --git a/apps/pythinker-code/scripts/update-catalog.mjs b/apps/pythinker-code/scripts/update-catalog.mjs index 04a04d42..7c4711f6 100644 --- a/apps/pythinker-code/scripts/update-catalog.mjs +++ b/apps/pythinker-code/scripts/update-catalog.mjs @@ -24,6 +24,10 @@ const KEEP_MODEL = new Set([ "reasoning", "interleaved", "modalities", + // Message-level tool declarations capability — kosong's + // catalogModelToCapability reads it; stripping it here would silently + // disable tool-select for catalog-imported aliases. + "dynamically_loaded_tools", ]); function resolveOutputFile(args) { diff --git a/apps/pythinker-code/src/auth/terminal-login-ui.ts b/apps/pythinker-code/src/auth/terminal-login-ui.ts deleted file mode 100644 index bbe0fc7f..00000000 --- a/apps/pythinker-code/src/auth/terminal-login-ui.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Terminal renderer for the multi-provider login flows (`runLogin` from - * `@pymodel/pythinker-code-sdk`). - * - * `pythinker login` (and `pythinker acp --login`) build a `LoginUi` backed by - * `@clack/prompts`: the provider picker, API-key / redirect-URL input, model - * and effort selection, and the OAuth device-code spinner. Catalog resolution - * mirrors the TUI's `promptPlatformSelection` — bundled catalog first, live - * fetch with a bundled fallback, so login still works offline. - * - * `--provider ` skips the picker: the option is resolved against the - * same `buildPlatformOptions` list the picker renders, and a non-matching - * value fails loudly instead of falling back to any default provider. - */ - -import { isCancel, log, password, select, spinner, text } from '@clack/prompts'; -import { - type PlatformModelInfo, - type OpenPlatformDefinition, -} from '@pymodel/pythinker-code-oauth'; -import { - buildPlatformOptions, - catalogModelToAlias, - coerceEffortForModel, - DEFAULT_CATALOG_URL, - effortLevelsForModel, - fetchCatalog, - formatErrorMessage, - loadBuiltInCatalog, - managedModelToAlias, - resolvePlatformOption, - type ApiKeyPromptOptions, - type CatalogModel, - type LoginProgressSpinnerHandle, - type LoginUi, - type ModelAlias, - type PlatformSelection, - type PythinkerHarness, -} from '@pymodel/pythinker-code-sdk'; - -import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; -import type { ColorToken } from '#/tui/theme'; -import { openUrl } from '#/utils/open-url'; - -/** Thrown when `--provider` does not match any platform id or display name. */ -export class UnknownProviderError extends Error { - constructor( - readonly input: string, - readonly validIds: readonly string[], - ) { - super(`Unknown provider "${input}"`); - this.name = 'UnknownProviderError'; - } -} - -export type TerminalLoginUi = LoginUi; - -export function createTerminalLoginUi( - harness: PythinkerHarness, - options: { readonly provider?: string | undefined } = {}, -): TerminalLoginUi { - const { provider } = options; - let cancelInFlight: (() => void) | undefined; - - function showStatus(message: string, level?: ColorToken): void { - if (level === 'error') { - log.error(message); - } else if (level === 'warning') { - log.warn(message); - } else { - log.info(message); - } - } - - function showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle { - const clackSpinner = spinner(); - clackSpinner.start(label); - return { - stop({ ok, label: finalLabel }) { - if (ok) { - // clack's spinner.stop renders the success tone. - clackSpinner.stop(finalLabel); - } else if (clackSpinner.isCancelled) { - // SIGINT already cancelled the spinner — clack rendered its own - // "Canceled" line — so keep the flow's label visible to preserve - // the "Login cancelled." / "Login failed." message style. - log.error(finalLabel); - } else { - clackSpinner.error(finalLabel); - } - }, - }; - } - - - async function promptPlatformSelection(): Promise { - let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {}; - const controller = new AbortController(); - const cancel = (): void => { - controller.abort(); - }; - cancelInFlight = cancel; - const catalogSpinner = showLoginProgressSpinner('Loading provider catalog'); - try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); - catalogSpinner.stop({ ok: true, label: 'Provider catalog loaded.' }); - } catch (error) { - if (controller.signal.aborted) { - catalogSpinner.stop({ ok: false, label: 'Aborted.' }); - log.info('Login cancelled.'); - return undefined; - } - catalogSpinner.stop({ ok: false, label: 'Using bundled provider catalog.' }); - showStatus(`Live provider catalog unavailable: ${formatErrorMessage(error)}`, 'warning'); - } finally { - if (cancelInFlight === cancel) cancelInFlight = undefined; - } - - const options = buildPlatformOptions(catalog); - if (provider !== undefined) { - const resolved = resolvePlatformOption(options, provider); - if (resolved === undefined) { - throw new UnknownProviderError(provider, options.map((option) => option.value)); - } - return { platformId: resolved.value, catalog }; - } - - const selected = await select({ - message: 'Select a provider', - options: options.map((option) => ({ - value: option.value, - label: option.label, - hint: option.description, - })), - }); - if (isCancel(selected)) { - log.info('Login cancelled.'); - return undefined; - } - return { platformId: selected, catalog }; - } - - async function promptApiKey( - platformName: string, - subtitleLines?: readonly string[], - promptOptions: ApiKeyPromptOptions = {}, - ): Promise { - for (const line of subtitleLines ?? []) { - log.info(line); - } - const message = promptOptions.title ?? `Enter API key for ${platformName}`; - const emptyMessage = promptOptions.emptyMessage ?? 'API key cannot be empty.'; - const validate = (value: string | undefined): string | undefined => - value === undefined || value.length === 0 ? emptyMessage : undefined; - const result = - promptOptions.secret === false - ? await text({ message, validate }) - : await password({ message, validate }); - if (isCancel(result)) { - log.info('Login cancelled.'); - return undefined; - } - return result; - } - - async function selectModelAndEffort( - modelDict: Record, - ): Promise<{ alias: string; effort: string } | undefined> { - const aliases = Object.keys(modelDict); - const selected = await select({ - message: 'Select a model', - options: aliases.map((alias) => ({ - value: alias, - label: modelDict[alias]?.displayName ?? modelDict[alias]?.model ?? alias, - })), - }); - if (isCancel(selected)) { - log.info('Login cancelled.'); - return undefined; - } - // Same default the TUI's model selector starts from: the first supported - // level, coerced against what the model actually supports. - const model = modelDict[selected]; - const levels = effortLevelsForModel(model); - const initialEffort = coerceEffortForModel( - model, - levels.find((level) => level !== 'off') ?? 'off', - ); - const effort = await select({ - message: 'Select effort level', - options: levels.map((level) => ({ value: level, label: level })), - initialValue: initialEffort, - }); - if (isCancel(effort)) { - log.info('Login cancelled.'); - return undefined; - } - return { alias: selected, effort }; - } - - async function promptModelSelectionForOpenPlatform( - models: readonly PlatformModelInfo[], - platform: OpenPlatformDefinition, - ): Promise<{ model: PlatformModelInfo; effort: string } | undefined> { - const modelDict: Record = {}; - for (const m of models) { - modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m); - } - const selection = await selectModelAndEffort(modelDict); - if (selection === undefined) return undefined; - const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias); - return model === undefined ? undefined : { model, effort: selection.effort }; - } - - async function promptModelSelectionForCatalog( - providerId: string, - models: readonly CatalogModel[], - ): Promise<{ model: CatalogModel; effort: string } | undefined> { - const modelDict: Record = {}; - for (const m of models) { - modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); - } - const selection = await selectModelAndEffort(modelDict); - if (selection === undefined) return undefined; - const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); - return model === undefined ? undefined : { model, effort: selection.effort }; - } - - const ui: TerminalLoginUi = { - harness, - get cancelInFlight() { - return cancelInFlight; - }, - set cancelInFlight(value: (() => void) | undefined) { - cancelInFlight = value; - }, - openBrowser(url: string): void { - openUrl(url); - }, - showStatus, - showError(message: string): void { - log.error(message); - }, - showLoginProgressSpinner, - promptPlatformSelection, - promptApiKey, - promptModelSelectionForOpenPlatform, - promptModelSelectionForCatalog, - async refreshConfigAfterLogin() { - // No live TUI session to refresh: the config on disk is already current. - }, - track() { - // No CLI telemetry sink yet. Success is reported by runLogin's return - // value, never inferred from a telemetry call. - }, - }; - return ui; -} diff --git a/apps/pythinker-code/src/cli/agent-selection.ts b/apps/pythinker-code/src/cli/agent-selection.ts new file mode 100644 index 00000000..2f80a5a5 --- /dev/null +++ b/apps/pythinker-code/src/cli/agent-selection.ts @@ -0,0 +1,42 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; + +import { parseAgentFileText, resolveAgentPath } from '@pymodel/pythinker-code-sdk'; + +import type { CLIOptions } from './options'; + +/** + * Resolve which agent profile the launch flags select. + * + * `--agent` carries the profile name directly; `--agent-file` implicitly + * selects the profile the file defines, so the file is parsed here (fatal on + * error) so a bad file fails before any session work. Returns undefined when + * neither flag is present. + */ +export async function resolveAgentProfileSelection( + opts: Pick, + workDir: string, +): Promise { + if (opts.agent !== undefined) return opts.agent; + const agentFile = opts.agentFiles?.[0]; + if (agentFile === undefined) return undefined; + + const path = resolveAgentPath(agentFile, workDir, homedir()); + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + return parseAgentFileText({ path, source: 'explicit', text }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${path}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} diff --git a/apps/pythinker-code/src/cli/commands.ts b/apps/pythinker-code/src/cli/commands.ts index 7e45e83d..fadfdcad 100644 --- a/apps/pythinker-code/src/cli/commands.ts +++ b/apps/pythinker-code/src/cli/commands.ts @@ -1,23 +1,25 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; -import { Command, Option } from 'commander'; +import { registerMigrateCommand } from '#/migration/index'; +import { Command, InvalidArgumentError, Option } from 'commander'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; -import { registerMcpCommand } from './sub/mcp'; import { registerProviderCommand } from './sub/provider'; -import { registerServerCommand } from './sub/server'; -import { registerDashboardCommand } from './sub/dashboard'; +import { registerVisCommand } from './sub/vis'; +import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; +export type MigrateCommandHandler = () => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; export type UpgradeCommandHandler = () => void | Promise; export function createProgram( version: string, onMain: MainCommandHandler, + onMigrate: MigrateCommandHandler, onPluginNodeRunner: PluginNodeRunnerHandler = () => {}, onUpgrade: UpgradeCommandHandler = () => {}, ): Command { @@ -28,7 +30,7 @@ export function createProgram( .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') .usage('[options] [command]') - .addHelpText('after', '\nDocumentation: https://pymodel.github.io/pythinker-code/\n'); + .addHelpText('after', '\nDocumentation: https://code.pythinker.com/pythinker-code/\n'); program .addOption( @@ -42,13 +44,8 @@ export function createProgram( .hideHelp() .argParser((val: string | boolean) => (val === true ? '' : (val as string))), ) - .option('-C, --continue', 'Continue the previous session for the working directory.', false) - .addOption( - new Option( - '--rewind-files ', - 'Restore files to a persisted checkpoint.', - ).hideHelp(), - ) + .option('-c, --continue', 'Continue the previous session for the working directory.', false) + .addOption(new Option('-C').hideHelp().default(false)) .option('-y, --yolo', 'Auto-approve regular tool calls; the agent may still ask questions.', false) .option('--auto', 'Start in auto permission mode: fully autonomous, the agent will not ask questions.', false) .addOption( @@ -67,13 +64,7 @@ export function createProgram( new Option( '--output-format ', 'Output format for prompt mode. Defaults to text.', - ).choices(['text', 'json', 'stream-json']), - ) - .addOption( - new Option( - '--json-schema ', - 'JSON Schema for structured output validation.', - ), + ).choices(['text', 'stream-json']), ) .addOption( new Option( @@ -85,25 +76,51 @@ export function createProgram( ) .addOption( new Option( - '--add-dir ', - 'Additional directories to allow file-tool access to.', - ).default([]), + '--agent ', + 'Agent profile to start the new session with. Custom profiles are discovered from agent directories or loaded via --agent-file. Cannot be combined with --session/--continue.', + ) + .argParser((value: string, previous: string | undefined) => { + if (previous !== undefined) { + throw new InvalidArgumentError('--agent may only be specified once.'); + } + return value; + }) + .conflicts('agentFile'), + ) + .addOption( + new Option( + '--agent-file ', + 'Load an agent definition from a Markdown file and select it for the new session. Cannot be combined with --session/--continue.', + ) + .argParser((value: string, previous: string[] | undefined) => { + if ((previous?.length ?? 0) > 0) { + throw new InvalidArgumentError('--agent-file may only be specified once.'); + } + return [value]; + }) + .conflicts('agent') + .default([]), + ) + .addOption( + new Option( + '--add-dir ', + 'Add an additional workspace directory for this session. Can be repeated.', + ) + .argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value]) + .default([]), ) .addOption(new Option('--yes').hideHelp().default(false)) .addOption(new Option('--auto-approve').hideHelp().default(false)) - .addOption(new Option('--init').hideHelp().default(false)) - .addOption(new Option('--init-only').hideHelp().default(false)) - .addOption(new Option('--maintenance').hideHelp().default(false)) .option('--plan', 'Start in plan mode.', false); registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); - registerMcpCommand(program); - registerServerCommand(program); + registerWebCommand(program); registerLoginCommand(program); registerDoctorCommand(program); - registerDashboardCommand(program); + registerVisCommand(program); + registerMigrateCommand(program, onMigrate); program .command('upgrade') .alias('update') @@ -126,11 +143,7 @@ export function createProgram( program.error(`unknown command '${args[0]}'. See '${CLI_COMMAND_NAME} --help'.`); } - // `--resume` is the legacy alias of `--session`; supplying both is a - // conflict the parser must surface instead of silently picking one. const raw = program.opts>(); - const sessionSelectorConflict = - raw['session'] !== undefined && raw['resume'] !== undefined; const rawSession = raw['session'] ?? raw['resume']; const sessionValue = rawSession === true ? '' : (rawSession as string | undefined); @@ -139,21 +152,17 @@ export function createProgram( const opts: CLIOptions = { session: sessionValue, - sessionSelectorConflict, - continue: raw['continue'] as boolean, + continue: raw['continue'] === true || raw['C'] === true, yolo: yoloValue, auto: autoValue, - init: raw['init'] as boolean, - initOnly: raw['initOnly'] as boolean, - maintenance: raw['maintenance'] as boolean, plan: raw['plan'] as boolean, model: raw['model'] as string | undefined, outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'], - jsonSchema: raw['jsonSchema'] as string | undefined, prompt: raw['prompt'] as string | undefined, - rewindFiles: raw['rewindFiles'] as string | undefined, skillsDirs: raw['skillsDir'] as string[], - additionalDirs: raw['addDir'] as string[], + agent: raw['agent'] as string | undefined, + agentFiles: raw['agentFile'] as string[], + addDirs: raw['addDir'] as string[], }; onMain(opts); diff --git a/apps/pythinker-code/src/cli/experimental-v2.ts b/apps/pythinker-code/src/cli/experimental-v2.ts new file mode 100644 index 00000000..b423a29e --- /dev/null +++ b/apps/pythinker-code/src/cli/experimental-v2.ts @@ -0,0 +1,35 @@ +/** + * Agent engine routing gates for the CLI surfaces. + * + * `pythinker -p`, the interactive TUI, and `pythinker doctor` use the native + * agent-core-v2 path by default. A truthy `PYTHINKER_CODE_LEGACY_FLAG` selects the + * legacy agent-core-backed path instead. `PYTHINKER_CODE_EXPERIMENTAL_FLAG` remains + * the master switch for experimental features within either engine; it does + * not select the engine. + * + * Note: `pythinker web` always boots kap-server (the agent-core-v2 engine + * server) — it does not consult this switch. + */ + +export const PYTHINKER_LEGACY_ENV = 'PYTHINKER_CODE_LEGACY_FLAG'; + +const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); + +function isTruthyEnv( + key: string, + env: Readonly>, +): boolean { + return TRUTHY_VALUES.has((env[key] ?? '').trim().toLowerCase()); +} + +export function isLegacyEnabled( + env: Readonly> = process.env, +): boolean { + return isTruthyEnv(PYTHINKER_LEGACY_ENV, env); +} + +export function isPythinkerV2Enabled( + env: Readonly> = process.env, +): boolean { + return !isLegacyEnabled(env); +} diff --git a/apps/pythinker-code/src/cli/goal-prompt.ts b/apps/pythinker-code/src/cli/goal-prompt.ts index 1a107ecb..7f318090 100644 --- a/apps/pythinker-code/src/cli/goal-prompt.ts +++ b/apps/pythinker-code/src/cli/goal-prompt.ts @@ -46,13 +46,18 @@ const GOAL_PREFIX = /^\/goal(\s|$)/; * Parses a headless prompt into a goal-create request, or `undefined` when the * prompt is not a `/goal` create command (so the caller runs it as a normal * prompt). Non-create goal subcommands are not supported headless and fall - * through to normal prompt handling. + * through to normal prompt handling. Malformed create commands throw instead of + * falling through, so validation errors are reported before anything is sent to + * the model. */ export function parseHeadlessGoalCreate(prompt: string): HeadlessGoalCreate | undefined { const trimmed = prompt.trim(); if (!GOAL_PREFIX.test(trimmed)) return undefined; const args = trimmed.replace(/^\/goal/, '').trim(); const parsed = parseGoalCommand(args); + if (parsed.kind === 'error') { + throw new Error(parsed.message); + } if (parsed.kind !== 'create') return undefined; return { objective: parsed.objective, replace: parsed.replace }; } diff --git a/apps/pythinker-code/src/cli/headless-exit.ts b/apps/pythinker-code/src/cli/headless-exit.ts new file mode 100644 index 00000000..008b2b11 --- /dev/null +++ b/apps/pythinker-code/src/cli/headless-exit.ts @@ -0,0 +1,96 @@ +import type { Writable } from 'node:stream'; + +import { HEADLESS_FORCE_EXIT_GRACE_MS, HEADLESS_STDIO_DRAIN_TIMEOUT_MS } from '#/constant/app'; + +/** Minimal process surface needed to force a headless run to terminate. */ +export interface ExitableProcess { + exit(code?: number): void; +} + +/** + * Schedule a best-effort force-exit for a completed headless (`pythinker -p`) run. + * + * Print mode does not call `process.exit()`; it relies on the Node event loop + * draining once the run is done. If a stray ref'd handle survives shutdown — a + * lingering socket (e.g. a connection blackholed by a restrictive firewall, or + * an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose + * pipes stay open — the loop never empties and the process hangs until an + * external timeout kills it. + * + * This arms an **unref'd** fallback timer: a healthy run drains and exits + * naturally before it fires (so behaviour is unchanged), and the timer itself + * never keeps the loop alive. It only force-exits a run whose loop is already + * wedged. The exit code is read lazily at fire time so callers may set + * `process.exitCode` after scheduling (e.g. a goal turn mapping its terminal + * status to a non-zero code). + * + * Returns the timer handle so callers/tests can `clearTimeout` it. + */ +export function scheduleHeadlessForceExit( + proc: ExitableProcess, + getExitCode: () => number, + graceMs: number = HEADLESS_FORCE_EXIT_GRACE_MS, +): NodeJS.Timeout { + const timer = setTimeout(() => { + proc.exit(getExitCode()); + }, graceMs); + timer.unref?.(); + return timer; +} + +/** Resolve once a stream's currently-buffered writes have flushed to its sink. */ +function flushStream(stream: Writable): Promise { + return new Promise((resolve) => { + try { + // An empty write's callback fires after all previously-queued writes have + // been flushed (writes are ordered), which is the documented way to know a + // stream's buffer has drained. + stream.write('', () => resolve()); + } catch { + resolve(); + } + }); +} + +/** + * Wait for buffered output on the given streams to flush, bounded by `timeoutMs`. + * + * A slow or piped consumer that hasn't read all of stdout/stderr yet leaves the + * pipe as a legitimate ref'd handle keeping the loop alive. Flushing before any + * force-exit prevents truncating output from an otherwise-successful run. The + * wait is bounded so a permanently-stuck consumer can't re-introduce the hang. + */ +export async function drainStdio( + streams: readonly Writable[], + timeoutMs: number = HEADLESS_STDIO_DRAIN_TIMEOUT_MS, +): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs); + timer.unref?.(); + }); + try { + await Promise.race([Promise.all(streams.map(flushStream)).then(() => undefined), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + +/** + * Finalize a completed headless run: flush stdio, then arm the force-exit + * backstop. + * + * Draining first means in-flight legitimate output is fully written before the + * backstop can fire, and — since drained stdio no longer holds the loop — only a + * genuinely leaked handle can keep it alive afterwards, which is exactly what + * the backstop is for. + */ +export async function finalizeHeadlessRun( + proc: ExitableProcess, + streams: readonly Writable[], + getExitCode: () => number, + options: { drainTimeoutMs?: number; graceMs?: number } = {}, +): Promise { + await drainStdio(streams, options.drainTimeoutMs ?? HEADLESS_STDIO_DRAIN_TIMEOUT_MS); + scheduleHeadlessForceExit(proc, getExitCode, options.graceMs); +} diff --git a/apps/pythinker-code/src/cli/options.ts b/apps/pythinker-code/src/cli/options.ts index b99b912a..8834e0b7 100644 --- a/apps/pythinker-code/src/cli/options.ts +++ b/apps/pythinker-code/src/cli/options.ts @@ -1,24 +1,52 @@ export type UIMode = 'shell' | 'print'; -export type PromptOutputFormat = 'text' | 'json' | 'stream-json'; +export type PromptOutputFormat = 'text' | 'stream-json'; + +/** Environment variable that sets the default `-p` output format (flag wins). */ +export const OUTPUT_FORMAT_ENV = 'PYTHINKER_MODEL_OUTPUT_FORMAT'; + +const OUTPUT_FORMATS = ['text', 'stream-json'] as const; + +function isOutputFormat(value: string): value is PromptOutputFormat { + return (OUTPUT_FORMATS as readonly string[]).includes(value); +} + +/** + * Resolve the effective `-p` output format. + * + * Precedence: explicit `--output-format` flag → `PYTHINKER_MODEL_OUTPUT_FORMAT` env + * (prompt mode only) → `text`. The env var is ignored outside prompt mode so an + * ambient value never affects interactive `pythinker`. An invalid env value fails + * fast via `OptionConflictError`. + */ +export function resolveOutputFormat( + opts: Pick, + env: Readonly> = process.env, +): PromptOutputFormat { + if (opts.outputFormat !== undefined) return opts.outputFormat; + if (opts.prompt === undefined) return 'text'; + const raw = (env[OUTPUT_FORMAT_ENV] ?? '').trim(); + if (raw.length === 0) return 'text'; + if (!isOutputFormat(raw)) { + throw new OptionConflictError( + `Invalid ${OUTPUT_FORMAT_ENV} value "${raw}". Expected one of: text, stream-json.`, + ); + } + return raw; +} export interface CLIOptions { session: string | undefined; - /** Set by the parser when canonical and legacy session selectors are both present. */ - sessionSelectorConflict?: boolean; continue: boolean; yolo: boolean; auto: boolean; - init?: boolean; - initOnly?: boolean; - maintenance?: boolean; plan: boolean; model: string | undefined; outputFormat: PromptOutputFormat | undefined; - jsonSchema?: string; prompt: string | undefined; - rewindFiles: string | undefined; skillsDirs: string[]; - additionalDirs?: string[]; + agent: string | undefined; + agentFiles: string[]; + addDirs?: string[]; } export interface ValidatedOptions { @@ -33,44 +61,21 @@ export class OptionConflictError extends Error { } } -export function validateOptions(opts: CLIOptions): ValidatedOptions { - if (opts.sessionSelectorConflict === true) { - throw new OptionConflictError('Cannot combine --session with --resume.'); - } +export function validateOptions( + opts: CLIOptions, + env: Readonly> = process.env, +): ValidatedOptions { const prompt = opts.prompt; const promptMode = prompt !== undefined; - const rewindFiles = opts.rewindFiles; - const rewindMode = rewindFiles !== undefined; if (promptMode && prompt.trim().length === 0) { throw new OptionConflictError('Prompt cannot be empty.'); } if (opts.model !== undefined && opts.model.trim().length === 0) { throw new OptionConflictError('Model cannot be empty.'); } - if (rewindFiles !== undefined && rewindFiles.trim().length === 0) { - throw new OptionConflictError( - 'Checkpoint ID for --rewind-files cannot be empty.', - ); - } - if ( - rewindMode && - (opts.session === undefined || - opts.session.trim().length === 0 || - opts.continue) - ) { - throw new OptionConflictError( - '--rewind-files requires --resume with a session ID.', - ); - } - if (rewindMode && promptMode) { - throw new OptionConflictError('Cannot combine --rewind-files with --prompt.'); - } if (!promptMode && opts.outputFormat !== undefined) { throw new OptionConflictError('Output format is only supported in prompt mode.'); } - if (!promptMode && opts.jsonSchema !== undefined) { - throw new OptionConflictError('JSON Schema is only supported in prompt mode.'); - } if (promptMode && opts.yolo) { throw new OptionConflictError('Cannot combine --prompt with --yolo.'); } @@ -80,6 +85,26 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (promptMode && opts.plan) { throw new OptionConflictError('Cannot combine --prompt with --plan.'); } + if (opts.agent !== undefined && opts.agent.trim().length === 0) { + throw new OptionConflictError('Agent cannot be empty.'); + } + if (opts.agentFiles.length > 1) { + throw new OptionConflictError('--agent-file may only be specified once.'); + } + if (opts.agentFiles.some((file) => file.trim().length === 0)) { + throw new OptionConflictError('Agent file path cannot be empty.'); + } + if (opts.agent !== undefined && opts.agentFiles.length > 0) { + throw new OptionConflictError('Cannot combine --agent with --agent-file.'); + } + if ( + (opts.agent !== undefined || opts.agentFiles.length > 0) && + (opts.session !== undefined || opts.continue) + ) { + throw new OptionConflictError( + 'Cannot combine --agent/--agent-file with --session/--continue: the agent is bound at session creation and the bound agent is restored automatically on resume.', + ); + } if (promptMode && opts.session === '') { throw new OptionConflictError('Cannot use --session without an id in prompt mode.'); } @@ -89,5 +114,8 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions { if (opts.yolo && opts.auto) { throw new OptionConflictError('Cannot combine --yolo with --auto.'); } - return { options: opts, uiMode: promptMode || rewindMode ? 'print' : 'shell' }; + // Validate `PYTHINKER_MODEL_OUTPUT_FORMAT` eagerly in prompt mode so a typo fails + // fast through the friendly `error:` path instead of mid-run. + if (promptMode) resolveOutputFormat(opts, env); + return { options: opts, uiMode: promptMode ? 'print' : 'shell' }; } diff --git a/apps/pythinker-code/src/cli/output.ts b/apps/pythinker-code/src/cli/output.ts deleted file mode 100644 index f7075400..00000000 --- a/apps/pythinker-code/src/cli/output.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Writable } from 'node:stream'; - -/** - * A write failed because the reader closed the stream (e.g. the CLI piped into - * `head`). EPIPE is a normal end of output, not a crash — callers may swallow - * it and exit with their regular success code. - */ -export function isBrokenPipeError(error: unknown): boolean { - return ( - typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPIPE' - ); -} - -/** - * Serialize one write and wait until it is handed off to the OS, so output is - * never truncated by an immediately following `process.exit`. Resolves on - * EPIPE; rejects on any other stream error. - */ -async function writeBarrier(stream: Readonly, content: string): Promise { - await new Promise((resolve, reject) => { - let settled = false; - let fallback: NodeJS.Immediate | undefined; - - const finish = (error?: unknown): void => { - if (settled) return; - settled = true; - if (fallback !== undefined) clearImmediate(fallback); - stream.off('error', onError); - if (error === undefined || isBrokenPipeError(error)) { - resolve(); - return; - } - reject(error); - }; - const deferFinish = (error?: unknown): void => { - // Some broken streams never invoke the write callback; the immediate - // guarantees the promise still settles even then. - fallback ??= setImmediate(() => { - finish(error); - }); - }; - const onError = (error: Error): void => { - finish(error); - }; - - stream.once('error', onError); - try { - stream.write(content, (error) => { - deferFinish(error ?? undefined); - }); - } catch (error) { - finish(error); - } - }); -} - -/** Write `content` and wait until it is flushed, then resolve. */ -export async function writeAndDrain(stream: Readonly, content: string): Promise { - await writeBarrier(stream, content); -} - -/** Wait until output already queued on the stream is flushed. */ -export async function drainWritable(stream: Readonly): Promise { - // A zero-length write is an ordering barrier for output already queued below - // the stream's high-water mark, where writableNeedDrain remains false. - await writeBarrier(stream, ''); -} diff --git a/apps/pythinker-code/src/cli/prompt-render.ts b/apps/pythinker-code/src/cli/prompt-render.ts new file mode 100644 index 00000000..e5b934ad --- /dev/null +++ b/apps/pythinker-code/src/cli/prompt-render.ts @@ -0,0 +1,410 @@ +/** + * Output rendering for `pythinker -p` (print mode) — shared by the v1 driver + * (`run-prompt.ts`) and the native v2 runner (`v2/run-v2-print.ts`). + * + * Both engines feed the same writer classes: v1 via the SDK `Event` stream, v2 + * via the main agent's native `IEventBus` (whose `DomainEvent` payloads are + * already v1-protocol-shaped). Keeping the writers here lets v2 reuse them + * without re-implementing rendering, while v1's `runPromptTurn` keeps its own + * event-filtering / completion flow intact. + */ + +import type { PromptOutputFormat } from './options'; + +/** + * Structural hook-result shape the renderer reads. Both the v1 SDK + * `HookResultEvent` and the v2 native `hook.result` `DomainEvent` satisfy it, + * so the renderer stays engine-agnostic without depending on either event + * definition. + */ +interface HookResultEventLike { + readonly hookEvent: string; + readonly content: string; + readonly blocked?: boolean; +} + +/** + * Structural retry shape the renderer reads. Mirrors the v1 SDK + * `turn.step.retrying` event fields the stream-json meta line surfaces. Both + * drivers forward retries to `writeRetrying`: v1 from its SDK event stream, + * v2 from the native `turn.step.retrying` `DomainEvent` (same field names), + * after discarding the failed attempt's partial output. + */ +interface RetryingEventLike { + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export interface PromptOutput { + readonly columns?: number | undefined; + write(chunk: string): boolean; +} + +const PROMPT_BLOCK_BULLET = '• '; +const PROMPT_BLOCK_INDENT = ' '; + +export interface PromptTurnWriter { + writeAssistantDelta(delta: string): void; + writeHookResult(event: HookResultEventLike): void; + writeThinkingDelta(delta: string): void; + writeToolCall(toolCallId: string, name: string, args: unknown): void; + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void; + writeToolResult(toolCallId: string, output: unknown): void; + writeRetrying(event: RetryingEventLike): void; + flushAssistant(): void; + discardAssistant(): void; + finish(): void; +} + +interface PromptJsonToolCall { + type: 'function'; + id: string; + function: { + name: string; + arguments: string; + }; +} + +interface PromptJsonAssistantMessage { + role: 'assistant'; + content?: string; + tool_calls?: PromptJsonToolCall[]; +} + +interface PromptJsonToolMessage { + role: 'tool'; + tool_call_id: string; + content: string; +} + +interface PromptJsonRetryMetaMessage { + role: 'meta'; + type: 'turn.step.retrying'; + failed_attempt: number; + next_attempt: number; + max_attempts: number; + delay_ms: number; + error_name: string; + error_message: string; + status_code?: number; +} + +export class PromptTranscriptWriter implements PromptTurnWriter { + private readonly assistantWriter: PromptBlockWriter; + private readonly thinkingWriter: PromptBlockWriter; + + constructor(stdout: PromptOutput, stderr: PromptOutput) { + this.assistantWriter = new PromptBlockWriter(stdout); + this.thinkingWriter = new PromptBlockWriter(stderr); + } + + writeAssistantDelta(delta: string): void { + this.thinkingWriter.finish(); + this.assistantWriter.write(delta); + } + + writeHookResult(event: HookResultEventLike): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + this.assistantWriter.write(formatHookResultPlain(event)); + this.assistantWriter.finish(); + } + + writeThinkingDelta(delta: string): void { + this.thinkingWriter.write(delta); + } + + writeToolCall(): void {} + + writeToolCallDelta(): void {} + + writeToolResult(): void {} + + // Text `-p` keeps retries silent: only the failed attempt's partial assistant + // text is discarded (handled by the caller). No human-readable retry line is + // emitted, matching the prior behavior. + writeRetrying(): void {} + + flushAssistant(): void { + this.assistantWriter.finish(); + } + + discardAssistant(): void {} + + finish(): void { + this.thinkingWriter.finish(); + this.assistantWriter.finish(); + } +} + +export class PromptJsonWriter implements PromptTurnWriter { + private assistantText = ''; + private readonly toolCalls: PromptJsonToolCall[] = []; + + constructor(private readonly stdout: PromptOutput) {} + + writeAssistantDelta(delta: string): void { + this.assistantText += delta; + } + + writeHookResult(event: HookResultEventLike): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'assistant', + content: formatHookResultPlain(event), + }); + } + + writeThinkingDelta(): void {} + + writeToolCall(toolCallId: string, name: string, args: unknown): void { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) { + existing.function.name = name; + existing.function.arguments = stringifyJsonValue(args); + return; + } + this.toolCalls.push({ + type: 'function', + id: toolCallId, + function: { + name, + arguments: stringifyJsonValue(args), + }, + }); + } + + writeToolCallDelta( + toolCallId: string, + name: string | undefined, + argumentsPart: string | undefined, + ): void { + const toolCall = this.findOrCreateToolCall(toolCallId, name ?? ''); + if (name !== undefined) { + toolCall.function.name = name; + } + if (argumentsPart !== undefined) { + toolCall.function.arguments += argumentsPart; + } + } + + writeToolResult(toolCallId: string, output: unknown): void { + this.flushAssistant(); + this.writeJsonLine({ + role: 'tool', + tool_call_id: toolCallId, + content: stringifyToolOutput(output), + }); + } + + writeRetrying(event: RetryingEventLike): void { + // Emit a machine-readable meta line so stream-json consumers can observe + // provider retries. The failed attempt's partial assistant text was already + // discarded by the caller, so no half-formed assistant message leaks. + const message: PromptJsonRetryMetaMessage = { + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: event.failedAttempt, + next_attempt: event.nextAttempt, + max_attempts: event.maxAttempts, + delay_ms: event.delayMs, + error_name: event.errorName, + error_message: event.errorMessage, + status_code: event.statusCode, + }; + this.writeJsonLine(message); + } + + flushAssistant(): void { + if (this.assistantText.length === 0 && this.toolCalls.length === 0) return; + const message: PromptJsonAssistantMessage = { + role: 'assistant', + content: this.assistantText.length > 0 ? this.assistantText : undefined, + tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined, + }; + this.writeJsonLine(message); + this.discardAssistant(); + } + + discardAssistant(): void { + this.assistantText = ''; + this.toolCalls.length = 0; + } + + finish(): void { + this.flushAssistant(); + } + + private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall { + const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); + if (existing !== undefined) return existing; + const toolCall: PromptJsonToolCall = { + type: 'function', + id: toolCallId, + function: { + name, + arguments: '', + }, + }; + this.toolCalls.push(toolCall); + return toolCall; + } + + private writeJsonLine( + message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonRetryMetaMessage, + ): void { + this.stdout.write(`${JSON.stringify(message)}\n`); + } +} + +class PromptBlockWriter { + private started = false; + private atLineStart = false; + private lineWidth = 0; + private readonly wrapWidth: number | undefined; + + constructor(private readonly output: PromptOutput) { + this.wrapWidth = + typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1 + ? output.columns + : undefined; + } + + write(chunk: string): void { + if (chunk.length === 0) return; + let rendered = this.start(); + for (const char of chunk) { + if (this.atLineStart && char !== '\n') { + rendered += PROMPT_BLOCK_INDENT; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + const charWidth = visibleCharWidth(char); + if ( + this.wrapWidth !== undefined && + !this.atLineStart && + char !== '\n' && + this.lineWidth + charWidth > this.wrapWidth + ) { + rendered += `\n${PROMPT_BLOCK_INDENT}`; + this.lineWidth = PROMPT_BLOCK_INDENT.length; + } + rendered += char; + if (char === '\n') { + this.atLineStart = true; + this.lineWidth = 0; + } else { + this.lineWidth += charWidth; + } + } + this.output.write(rendered); + } + + finish(): void { + if (!this.started) return; + this.output.write(this.atLineStart ? '\n' : '\n\n'); + this.started = false; + this.atLineStart = false; + this.lineWidth = 0; + } + + private start(): string { + if (this.started) return ''; + this.started = true; + this.atLineStart = false; + this.lineWidth = PROMPT_BLOCK_BULLET.length; + return PROMPT_BLOCK_BULLET; + } +} + +function visibleCharWidth(char: string): number { + return char === '\t' ? 4 : 1; +} + +function formatHookResultPlain(event: HookResultEventLike): string { + return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; +} + +function formatHookResultTitle(event: HookResultEventLike): string { + return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; +} + +function formatHookResultBody(event: HookResultEventLike): string { + const content = event.content.trim(); + return content.length === 0 ? '(empty)' : content; +} + +function stringifyJsonValue(value: unknown): string { + if (typeof value === 'string') return value; + const json = JSON.stringify(value); + return json ?? ''; +} + +function stringifyToolOutput(output: unknown): string { + if (typeof output === 'string') return output; + const json = JSON.stringify(output); + return json ?? String(output); +} + +interface PromptJsonResumeMetaMessage { + role: 'meta'; + type: 'session.resume_hint'; + session_id: string; + command: string; + content: string; +} + +interface PromptJsonVersionMetaMessage { + role: 'meta'; + type: 'system.version'; + version: string; +} + +export function writeExperimentalVersion( + version: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): void { + if (outputFormat === 'stream-json') { + const message: PromptJsonVersionMetaMessage = { + role: 'meta', + type: 'system.version', + version, + }; + stdout.write(`${JSON.stringify(message)}\n`); + return; + } + stderr.write(`pythinker version ${version}\n`); +} + +export function writeResumeHint( + sessionId: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): void { + const command = `pythinker -r ${sessionId}`; + const content = `To resume this session: ${command}`; + if (outputFormat === 'stream-json') { + const message: PromptJsonResumeMetaMessage = { + role: 'meta', + type: 'session.resume_hint', + session_id: sessionId, + command, + content, + }; + stdout.write(`${JSON.stringify(message)}\n`); + return; + } + stderr.write(`${content}\n`); +} diff --git a/apps/pythinker-code/src/cli/prompt-session.ts b/apps/pythinker-code/src/cli/prompt-session.ts new file mode 100644 index 00000000..a4b978fb --- /dev/null +++ b/apps/pythinker-code/src/cli/prompt-session.ts @@ -0,0 +1,66 @@ +/** + * Minimal harness/session surface consumed by `pythinker -p` (print mode). + * + * `run-prompt.ts` only needs a small subset of the SDK `PythinkerHarness` / `Session` + * API. Coding the print-mode driver against these narrow interfaces — instead of + * the concrete SDK classes — lets the same driver run on either the legacy + * engine (`createPythinkerHarness`) or the default agent-core-v2 engine + * (`createPromptHarnessV2`, selected unless `PYTHINKER_CODE_LEGACY_FLAG` is truthy). + * Both the legacy `PythinkerHarness` / `Session` and the v2 harness structurally + * satisfy these interfaces, so no adapter wrappers are needed on the legacy path. + */ + +import type { + ApprovalHandler, + ConfigDiagnostics, + CreateGoalInput, + CreateSessionOptions, + Event, + GetCronTasksResult, + GoalSnapshot, + GoalToolResult, + PythinkerAuthFacade, + PythinkerConfig, + ListSessionsOptions, + PermissionMode, + PromptInput, + QuestionHandler, + ResumeSessionInput, + SessionStatus, + SessionSummary, + TelemetryProperties, + Unsubscribe, +} from '@pymodel/pythinker-code-sdk'; + +export interface PromptHarness { + readonly homeDir: string; + readonly auth: PythinkerAuthFacade; + + track(event: string, properties?: TelemetryProperties): void; + + ensureConfigFile(): Promise; + getConfig(): Promise>; + getConfigDiagnostics(): Promise; + listSessions(options: ListSessionsOptions): Promise; + createSession(options: CreateSessionOptions): Promise; + resumeSession(input: ResumeSessionInput): Promise; + close(): Promise; +} + +export interface PromptSession { + readonly id: string; + readonly workDir: string; + + getStatus(): Promise; + setModel(model: string): Promise; + setPermission(mode: PermissionMode): Promise; + setApprovalHandler(handler: ApprovalHandler | undefined): void; + setQuestionHandler(handler: QuestionHandler | undefined): void; + onEvent(listener: (event: Event) => void): Unsubscribe; + prompt(input: string | PromptInput): Promise; + waitForBackgroundTasksOnPrint(): Promise; + handlePrintMainTurnCompleted?(): Promise<'finish' | 'continue'>; + createGoal(input: CreateGoalInput): Promise; + getGoal(): Promise; + getCronTasks(): Promise; +} diff --git a/apps/pythinker-code/src/cli/run-prompt.ts b/apps/pythinker-code/src/cli/run-prompt.ts index cd415082..f3c36ef6 100644 --- a/apps/pythinker-code/src/cli/run-prompt.ts +++ b/apps/pythinker-code/src/cli/run-prompt.ts @@ -11,19 +11,17 @@ import { log, type Event, type GoalSnapshot, - type HookResultEvent, - type JsonObject, - type PythinkerHarness, - type Session, type SessionStatus, type TelemetryClient, } from '@pymodel/pythinker-code-sdk'; import { resolve } from 'pathe'; -import { CLI_SHUTDOWN_TIMEOUT_MS } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; +import { resolveAgentProfileSelection } from './agent-selection'; +import { isPythinkerV2Enabled } from './experimental-v2'; +import { resolveOutputFormat } from './options'; import type { CLIOptions, PromptOutputFormat } from './options'; -import { drainWritable } from './output'; import { formatGoalSummaryText, goalExitCode, @@ -31,42 +29,91 @@ import { parseHeadlessGoalCreate, type HeadlessGoalCreate, } from './goal-prompt'; +import type { PromptHarness, PromptSession } from './prompt-session'; +import { PromptJsonWriter, PromptTranscriptWriter, writeResumeHint } from './prompt-render'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createPythinkerCodeHostIdentity } from './version'; +/** + * Await `promise`, but stop waiting after `timeoutMs`. + * + * The timeout only bounds how long we WAIT — it does not change the outcome: + * - if `promise` settles first, its result is propagated (a rejection throws), + * so a cleanup step that actually fails in time still surfaces; + * - if the timeout wins, we resolve (give up waiting) and swallow the abandoned + * promise's eventual late rejection so it can't surface as an unhandled + * rejection. + * + * Used to bound shutdown so a wedged cleanup step can't keep a completed + * headless run alive, without silently swallowing a cleanup that fails fast. The + * timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g. + * telemetry's retry backoff when the network is blocked) can't drain the event + * loop and exit 0 before the rejection propagates — the timer keeps the loop + * alive until it fires, then gives the rejection a chance to surface. A wedged + * cleanup is still bounded by `timeoutMs`, so this can't hang the run forever. + */ +export async function raceWithTimeout(promise: Promise, timeoutMs: number): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + // Attach the catch eagerly (synchronously) so `promise` is always consumed and + // a late rejection can never become an unhandled rejection. Before the timeout + // wins, the handler rethrows so a real cleanup failure still propagates. + const guarded = promise.catch((error: unknown) => { + if (timedOut) return; + throw error; + }); + const timedOutSignal = new Promise((resolve) => { + timer = setTimeout(() => { + timedOut = true; + resolve(); + }, timeoutMs); + }); + try { + await Promise.race([guarded, timedOutSignal]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} + interface PromptOutput { readonly columns?: number | undefined; write(chunk: string): boolean; - flush?(): Promise; } -interface PromptRunIO { +export interface PromptRunIO { readonly stdout?: PromptOutput; readonly stderr?: PromptOutput; readonly process?: PromptProcess; } -interface PromptProcess { - on(signal: NodeJS.Signals, listener: () => Promise): unknown; +export interface PromptProcess { + once(signal: NodeJS.Signals, listener: () => Promise): unknown; off(signal: NodeJS.Signals, listener: () => Promise): unknown; exit(code?: number): never | void; } const PROMPT_UI_MODE = 'print'; const PROMPT_MAIN_AGENT_ID = 'main'; -const PROMPT_BLOCK_BULLET = '• '; -const PROMPT_BLOCK_INDENT = ' '; export async function runPrompt( opts: CLIOptions, version: string, io: PromptRunIO = {}, ): Promise { - const outputSchema = parseOutputSchema(opts.jsonSchema); + if (isPythinkerV2Enabled()) { + // The agent-core-v2 engine runs on its own native DI service runtime (see + // v2/run-v2-print.ts); it does not share the v1 PromptHarness path below. + // Loaded lazily so the v2 module graph stays off the legacy path. + const { runV2Print } = await import('./v2/run-v2-print'); + await runV2Print(opts, version, io); + return; + } + const startedAt = Date.now(); const stdout = io.stdout ?? process.stdout; const stderr = io.stderr ?? process.stderr; const promptProcess = io.process ?? process; + const outputFormat = resolveOutputFormat(opts); const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { @@ -74,12 +121,20 @@ export async function runPrompt( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createPythinkerHarness({ + const harness = await createPromptHarness({ homeDir: telemetryBootstrap.homeDir, identity: createPythinkerCodeHostIdentity(version), uiMode: PROMPT_UI_MODE, skillDirs: opts.skillsDirs, telemetry: telemetryClient, + onOAuthRefresh: (outcome) => { + if (outcome.success) { + track('oauth_refresh', { outcome: 'success' }); + return; + } + track('oauth_refresh', { outcome: 'error', reason: outcome.reason }); + }, + sessionStartedProperties: { yolo: false, plan: false, afk: true }, }); log.info('pythinker-code starting', { version, @@ -89,32 +144,27 @@ export async function runPrompt( workDir, }); let restorePromptSessionPermission = async (): Promise => {}; + let removeTerminationCleanup: (() => void) | undefined; let cleanupPromise: Promise | undefined; const cleanupPromptRun = async (): Promise => { - cleanupPromise ??= (async () => { + const pending = (cleanupPromise ??= (async () => { + removeTerminationCleanup?.(); setCrashPhase('shutdown'); try { await restorePromptSessionPermission(); } finally { - try { - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); - } finally { - await harness.close(); - } + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + await harness.close(); } - })(); - await cleanupPromise; + })()); + // Bound cleanup so a wedged shutdown step (e.g. a SessionEnd hook, MCP + // shutdown, or a connection blackholed by a restrictive firewall) cannot + // keep a completed headless run alive forever. The cleanup keeps running in + // the background if it overruns; the caller (`pythinker -p`) force-exits shortly + // after, so any straggling work is torn down with the process. + await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); }; - const terminationHandlers = installPromptTerminationCleanup( - promptProcess, - cleanupPromptRun, - async () => { - await Promise.all([ - flushPromptOutput(stdout), - flushPromptOutput(stderr), - ]); - }, - ); + removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun); try { await harness.ensureConfigFile(); @@ -122,7 +172,7 @@ export async function runPrompt( for (const warning of (await harness.getConfigDiagnostics()).warnings) { stderr.write(`Warning: ${warning}\n`); } - const { session, resumed, restorePermission, telemetryModel, goalModel } = + const { session, restorePermission, telemetryModel, goalModel } = await resolvePromptSession( harness, opts, @@ -134,24 +184,6 @@ export async function runPrompt( }, ); restorePromptSessionPermission = restorePermission; - if (opts.rewindFiles !== undefined) { - const result = await session.restoreFileCheckpoint(opts.rewindFiles); - stdout.write(`Files rewound to checkpoint ${result.checkpointId}.\n`); - stdout.write(`Recovery checkpoint: ${result.recoveryCheckpointId}.\n`); - stdout.write( - `Restored: ${String(result.restoredPaths.length)}. Deleted: ${String(result.deletedPaths.length)}.\n`, - ); - return; - } - for (const directory of opts.additionalDirs ?? []) { - try { - await session.addWorkspaceDirectory(directory); - } catch (error) { - stderr.write( - `Warning: could not add working directory "${directory}": ${formatPromptError(error)}\n`, - ); - } - } initializeCliTelemetry({ harness, @@ -160,59 +192,46 @@ export async function runPrompt( version, uiMode: PROMPT_UI_MODE, model: telemetryModel, + sessionId: session.id, }); setCrashPhase('runtime'); - withTelemetryContext({ sessionId: session.id }).track('started', { - resumed, - yolo: false, - plan: false, - afk: true, - }); - - const outputFormat = opts.outputFormat ?? 'text'; // Headless goal mode: `pythinker -p "/goal "`. The goal driver keeps // the turn-run alive across continuation turns, so the normal prompt-turn // waiter blocks until the goal is terminal; we then emit a summary and set a // distinct exit code. const goalCreate = parseHeadlessGoalCreate(opts.prompt!); - if (goalCreate !== undefined && outputSchema !== undefined) { - throw new Error('Cannot combine --json-schema with a headless goal prompt.'); - } if (goalCreate !== undefined) { await runHeadlessGoal(session, goalCreate, goalModel, outputFormat, stdout, stderr); } else { await runPromptTurn( - session, + session as PrintTurnSession, opts.prompt!, outputFormat, stdout, stderr, - outputSchema, ); } writeResumeHint(session.id, outputFormat, stdout, stderr); withTelemetryContext({ sessionId: session.id }).track('exit', { - duration_s: (Date.now() - startedAt) / 1000, + duration_ms: Date.now() - startedAt, }); } finally { - try { - await cleanupPromptRun(); - } finally { - // Keep the handlers armed while a signal-triggered cleanup is in flight so - // a second signal can still force an immediate exit. - if (!terminationHandlers.isTerminating()) terminationHandlers.remove(); - } + await cleanupPromptRun(); } } -function formatPromptError(error: unknown): string { - return error instanceof Error ? error.message : String(error); +async function createPromptHarness( + options: Parameters[0], +): Promise { + // The v2 engine is dispatched earlier in `runPrompt` (see the + // `isPythinkerV2Enabled()` branch) and never reaches here; this is the v1 path. + return createPythinkerHarness(options); } async function runHeadlessGoal( - session: Session, + session: PromptSession, goal: HeadlessGoalCreate, model: string | undefined, outputFormat: PromptOutputFormat, @@ -238,7 +257,13 @@ async function runHeadlessGoal( try { // The objective is sent as the normal prompt; goal continuation keeps the // turn alive until a terminal state is reached. - await runPromptTurn(session, goal.objective, outputFormat, stdout, stderr); + await runPromptTurn( + session as PrintTurnSession, + goal.objective, + outputFormat, + stdout, + stderr, + ); } finally { unsubscribeGoalEvents(); const snapshot = completedSnapshot ?? (await session.getGoal()).goal; @@ -256,7 +281,7 @@ async function runHeadlessGoal( } interface ResolvedPromptSession { - readonly session: Session; + readonly session: PromptSession; readonly resumed: boolean; readonly restorePermission: () => Promise; readonly telemetryModel?: string; @@ -264,13 +289,16 @@ interface ResolvedPromptSession { } async function resolvePromptSession( - harness: PythinkerHarness, + harness: PromptHarness, opts: CLIOptions, workDir: string, defaultModel: string | undefined, stderr: PromptOutput, setRestorePermission: (restorePermission: () => Promise) => void, ): Promise { + // `--agent`/`--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths never forward a + // profile — the bound agent is restored from the session itself. if (opts.session !== undefined) { const sessions = await harness.listSessions({ sessionId: opts.session, workDir }); const target = sessions[0]; @@ -290,15 +318,8 @@ async function resolvePromptSession( } const session = await harness.resumeSession({ id: opts.session, - setupTrigger: opts.init ? 'init' : opts.maintenance ? 'maintenance' : undefined, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, }); - if (opts.rewindFiles !== undefined) { - return { - session, - resumed: true, - restorePermission: async () => {}, - }; - } const status = await session.getStatus(); const restorePermission = await forcePromptPermission( session, @@ -324,7 +345,7 @@ async function resolvePromptSession( if (previous !== undefined) { const session = await harness.resumeSession({ id: previous.id, - setupTrigger: opts.init ? 'init' : opts.maintenance ? 'maintenance' : undefined, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, }); const status = await session.getStatus(); const restorePermission = await forcePromptPermission( @@ -347,12 +368,16 @@ async function resolvePromptSession( stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); } + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const model = requireConfiguredModel(opts.model, defaultModel); const session = await harness.createSession({ workDir, model, permission: 'auto', - setupTrigger: opts.init ? 'init' : opts.maintenance ? 'maintenance' : undefined, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + agentProfile, + agentFiles: opts.agentFiles?.length ? opts.agentFiles : undefined, + drainAgentTasksOnStop: true, }); installHeadlessHandlers(session); return { @@ -365,7 +390,7 @@ async function resolvePromptSession( } async function forcePromptPermission( - session: Session, + session: PromptSession, previousPermission: SessionStatus['permission'], setRestorePermission: (restorePermission: () => Promise) => void, ): Promise<() => Promise> { @@ -384,7 +409,7 @@ async function forcePromptPermission( return restorePermission; } -function requireConfiguredModel(...models: readonly (string | undefined)[]): string { +export function requireConfiguredModel(...models: readonly (string | undefined)[]): string { const model = configuredModel(...models); if (model === undefined) { throw new Error( @@ -394,117 +419,90 @@ function requireConfiguredModel(...models: readonly (string | undefined)[]): str return model; } -function configuredModel(...models: readonly (string | undefined)[]): string | undefined { +export function configuredModel(...models: readonly (string | undefined)[]): string | undefined { return models.find((model) => model !== undefined && model.trim().length > 0); } -function installHeadlessHandlers(session: Session): void { +function installHeadlessHandlers(session: PromptSession): void { session.setApprovalHandler(() => ({ decision: 'approved' })); session.setQuestionHandler(() => null); } -/** Flush a prompt run's output: custom writers expose `flush`, real streams drain. */ -async function flushPromptOutput(output: PromptOutput): Promise { - if (output.flush !== undefined) { - await output.flush(); - return; - } - if (output === process.stdout) { - await drainWritable(process.stdout); - return; - } - if (output === process.stderr) { - await drainWritable(process.stderr); - } -} - -/** - * Install graceful signal handling for a prompt run. The first termination - * signal runs `cleanup` and flushes output, then exits with the signal's - * conventional code; a second signal while cleanup is still pending forces an - * immediate exit instead of letting the process hang on a slow shutdown. - */ -function installPromptTerminationCleanup( +export function installPromptTerminationCleanup( promptProcess: PromptProcess, cleanup: () => Promise, - flushOutput: () => Promise, -): { remove(): void; isTerminating(): boolean } { - let terminationSignal: NodeJS.Signals | undefined; - let forced = false; - let removed = false; - const remove = (): void => { - if (removed) return; - removed = true; - promptProcess.off('SIGINT', onSigint); - promptProcess.off('SIGTERM', onSigterm); - promptProcess.off('SIGHUP', onSighup); - }; +): () => void { + let terminating = false; const exitAfterCleanup = async (signal: NodeJS.Signals): Promise => { - if (terminationSignal !== undefined) { - if (!forced) { - forced = true; - remove(); - promptProcess.exit(signalExitCode(signal)); - } - return; - } - terminationSignal = signal; + if (terminating) return; + terminating = true; try { - await cleanup().catch(() => {}); - await flushOutput().catch(() => {}); + await cleanup(); } finally { - if (!forced) { - remove(); - promptProcess.exit(signalExitCode(signal)); - } + promptProcess.exit(signalExitCode(signal)); } }; const onSigint = () => exitAfterCleanup('SIGINT'); const onSigterm = () => exitAfterCleanup('SIGTERM'); const onSighup = () => exitAfterCleanup('SIGHUP'); - promptProcess.on('SIGINT', onSigint); - promptProcess.on('SIGTERM', onSigterm); - promptProcess.on('SIGHUP', onSighup); - return { - remove, - isTerminating: () => terminationSignal !== undefined, + promptProcess.once('SIGINT', onSigint); + promptProcess.once('SIGTERM', onSigterm); + promptProcess.once('SIGHUP', onSighup); + return () => { + promptProcess.off('SIGINT', onSigint); + promptProcess.off('SIGTERM', onSigterm); + promptProcess.off('SIGHUP', onSighup); }; } -function signalExitCode(signal: NodeJS.Signals): number { +export function signalExitCode(signal: NodeJS.Signals): number { if (signal === 'SIGINT') return 130; if (signal === 'SIGHUP') return 129; return 143; } +type PrintTurnSession = PromptSession & + Required>; + function runPromptTurn( - session: Session, + session: PrintTurnSession, prompt: string, outputFormat: PromptOutputFormat, stdout: PromptOutput, stderr: PromptOutput, - outputSchema?: JsonObject, ): Promise { let activeTurnId: number | undefined; let activeAgentId: string | undefined; const outputWriter = outputFormat === 'stream-json' - ? new PromptJsonWriter(stdout, session.id) - : outputFormat === 'json' - ? new PromptResultWriter(stdout, session.id) - : new PromptTranscriptWriter(stdout, stderr); + ? new PromptJsonWriter(stdout) + : new PromptTranscriptWriter(stdout, stderr); let settled = false; let unsubscribe: (() => void) | undefined; + // A `pythinker -p` run is not done just because the model ended a turn: an active + // goal drives continuation turns on its own, and a scheduled cron task fires + // later from an idle session — both trigger new turns after `end_turn`. While + // either is pending, something must keep the event loop alive: the cron + // scheduler's tick is deliberately unref'd, so without a ref'd handle the + // process would drain and exit before the next turn is ever triggered. This + // no-op interval is that handle; finish() always clears it. + let keepAliveTimer: NodeJS.Timeout | undefined; + const holdEventLoop = (): void => { + keepAliveTimer ??= setInterval(() => {}, 60_000); + }; + const releaseEventLoop = (): void => { + if (keepAliveTimer === undefined) return; + clearInterval(keepAliveTimer); + keepAliveTimer = undefined; + }; return new Promise((resolve, reject) => { - const finish = ( - error?: Error, - ended?: Extract, - ): void => { + const finish = (error?: Error): void => { if (settled) return; settled = true; + releaseEventLoop(); unsubscribe?.(); - outputWriter.finish(ended); + outputWriter.finish(); if (error !== undefined) { reject(error); return; @@ -512,6 +510,36 @@ function runPromptTurn( resolve(); }; + // Re-evaluates whether the run can settle now that the main agent is idle. + // The run outlives a completed turn while a goal is still active (the goal + // driver launches the next continuation turn itself) or while cron tasks + // with a future fire remain (their fire steers a fresh turn when idle). + // Called on turn.ended and on a terminal goal.updated — the latter covers + // the driver blocking a goal on a hard budget, which emits no further + // turn.ended. Only when neither is pending do we drain background tasks + // and settle. + const evaluateRunCompletion = async (): Promise => { + try { + const { goal } = await session.getGoal(); + if (settled || activeTurnId !== undefined) return; + if (goal?.status === 'active') { + holdEventLoop(); + return; + } + const { tasks } = await session.getCronTasks(); + if (settled || activeTurnId !== undefined) return; + // A task whose expression has no future fire can never trigger a + // turn; don't hold the run open for it. + if (tasks.some((task) => task.nextFireAt !== null)) { + holdEventLoop(); + return; + } + await finishCompletedTurn(); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); + } + }; + unsubscribe = session.onEvent((event) => { if (event.type === 'error') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { @@ -520,7 +548,7 @@ function runPromptTurn( finish(new Error(`${event.code}: ${event.message}`)); return; } - if (event.type === 'turn.started' && activeTurnId === undefined) { + if (event.type === 'turn.started') { if (event.agentId !== PROMPT_MAIN_AGENT_ID) { return; } @@ -528,6 +556,16 @@ function runPromptTurn( activeAgentId = event.agentId; return; } + if ( + event.type === 'goal.updated' && + event.agentId === PROMPT_MAIN_AGENT_ID && + activeTurnId === undefined && + event.snapshot !== null && + event.snapshot.status !== 'active' + ) { + void evaluateRunCompletion(); + return; + } if ( activeTurnId === undefined || activeAgentId === undefined || @@ -544,6 +582,7 @@ function runPromptTurn( return; case 'turn.step.retrying': outputWriter.discardAssistant(); + outputWriter.writeRetrying(event); return; case 'assistant.delta': outputWriter.writeAssistantDelta(event.delta); @@ -572,7 +611,10 @@ function runPromptTurn( return; case 'turn.ended': if (event.reason === 'completed') { - finish(undefined, event); + outputWriter.flushAssistant(); + activeTurnId = undefined; + activeAgentId = undefined; + void evaluateRunCompletion(); return; } finish(new Error(formatTurnEndedFailure(event))); @@ -595,410 +637,37 @@ function runPromptTurn( case 'subagent.started': case 'subagent.suspended': case 'tool.list.updated': - case 'turn.started': case 'turn.step.completed': case 'warning': - case 'workflow.warning': return; } }); - session.prompt(prompt, { outputSchema }).catch((error: unknown) => { + session.prompt(prompt).catch((error: unknown) => { finish(error instanceof Error ? error : new Error(String(error))); }); - }); -} - -interface PromptTurnWriter { - writeAssistantDelta(delta: string): void; - writeHookResult(event: HookResultEvent): void; - writeThinkingDelta(delta: string): void; - writeToolCall(toolCallId: string, name: string, args: unknown): void; - writeToolCallDelta( - toolCallId: string, - name: string | undefined, - argumentsPart: string | undefined, - ): void; - writeToolResult(toolCallId: string, output: unknown): void; - flushAssistant(): void; - discardAssistant(): void; - finish(ended?: Extract): void; -} - -class PromptTranscriptWriter implements PromptTurnWriter { - private readonly assistantWriter: PromptBlockWriter; - private readonly thinkingWriter: PromptBlockWriter; - - constructor(stdout: PromptOutput, stderr: PromptOutput) { - this.assistantWriter = new PromptBlockWriter(stdout); - this.thinkingWriter = new PromptBlockWriter(stderr); - } - - writeAssistantDelta(delta: string): void { - this.thinkingWriter.finish(); - this.assistantWriter.write(delta); - } - - writeHookResult(event: HookResultEvent): void { - this.thinkingWriter.finish(); - this.assistantWriter.finish(); - this.assistantWriter.write(formatHookResultPlain(event)); - this.assistantWriter.finish(); - } - - writeThinkingDelta(delta: string): void { - this.thinkingWriter.write(delta); - } - - writeToolCall(): void {} - - writeToolCallDelta(): void {} - - writeToolResult(): void {} - - flushAssistant(): void {} - - discardAssistant(): void {} - - finish(): void { - this.thinkingWriter.finish(); - this.assistantWriter.finish(); - } -} - -interface PromptJsonResult { - readonly type: 'result'; - readonly subtype: 'success'; - readonly is_error: false; - readonly result: string; - readonly structured_output?: unknown; - readonly session_id: string; -} - -class PromptResultWriter implements PromptTurnWriter { - private assistantText = ''; - private resultText = ''; - - constructor( - private readonly stdout: PromptOutput, - private readonly sessionId: string, - ) {} - - writeAssistantDelta(delta: string): void { - this.assistantText += delta; - } - - writeHookResult(event: HookResultEvent): void { - this.flushAssistant(); - this.resultText = formatHookResultPlain(event); - } - - writeThinkingDelta(): void {} - - writeToolCall(): void {} - - writeToolCallDelta(): void {} - - writeToolResult(): void {} - - flushAssistant(): void { - if (this.assistantText.length === 0) return; - this.resultText = this.assistantText; - this.assistantText = ''; - } - - discardAssistant(): void { - this.assistantText = ''; - } - - finish(ended?: Extract): void { - if (ended === undefined) return; - this.flushAssistant(); - const result: PromptJsonResult = { - type: 'result', - subtype: 'success', - is_error: false, - result: this.resultText, - structured_output: ended.structuredOutput, - session_id: this.sessionId, - }; - this.stdout.write(`${JSON.stringify(result)}\n`); - } -} - -interface PromptJsonToolCall { - type: 'function'; - id: string; - function: { - name: string; - arguments: string; - }; -} - -interface PromptJsonAssistantMessage { - role: 'assistant'; - content?: string; - tool_calls?: PromptJsonToolCall[]; -} - -interface PromptJsonToolMessage { - role: 'tool'; - tool_call_id: string; - content: string; -} - -interface PromptJsonResumeMetaMessage { - role: 'meta'; - type: 'session.resume_hint'; - session_id: string; - command: string; - content: string; -} - -function writeResumeHint( - sessionId: string, - outputFormat: PromptOutputFormat, - stdout: PromptOutput, - stderr: PromptOutput, -): void { - const command = `pythinker -r ${sessionId}`; - const content = `To resume this session: ${command}`; - if (outputFormat === 'stream-json') { - const message: PromptJsonResumeMetaMessage = { - role: 'meta', - type: 'session.resume_hint', - session_id: sessionId, - command, - content, - }; - stdout.write(`${JSON.stringify(message)}\n`); - return; - } - stderr.write(`${content}\n`); -} - -class PromptJsonWriter implements PromptTurnWriter { - private assistantText = ''; - private resultText = ''; - private readonly toolCalls: PromptJsonToolCall[] = []; - - constructor( - private readonly stdout: PromptOutput, - private readonly sessionId: string, - ) {} - - writeAssistantDelta(delta: string): void { - this.assistantText += delta; - } - - writeHookResult(event: HookResultEvent): void { - this.flushAssistant(); - const content = formatHookResultPlain(event); - this.writeJsonLine({ - role: 'assistant', - content, - }); - this.resultText = content; - } - - writeThinkingDelta(): void {} - writeToolCall(toolCallId: string, name: string, args: unknown): void { - const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); - if (existing !== undefined) { - existing.function.name = name; - existing.function.arguments = stringifyJsonValue(args); - return; - } - this.toolCalls.push({ - type: 'function', - id: toolCallId, - function: { - name, - arguments: stringifyJsonValue(args), - }, - }); - } - - writeToolCallDelta( - toolCallId: string, - name: string | undefined, - argumentsPart: string | undefined, - ): void { - const toolCall = this.findOrCreateToolCall(toolCallId, name ?? ''); - if (name !== undefined) { - toolCall.function.name = name; - } - if (argumentsPart !== undefined) { - toolCall.function.arguments += argumentsPart; - } - } - - writeToolResult(toolCallId: string, output: unknown): void { - this.flushAssistant(); - this.writeJsonLine({ - role: 'tool', - tool_call_id: toolCallId, - content: stringifyToolOutput(output), - }); - } - - flushAssistant(): void { - if (this.assistantText.length === 0 && this.toolCalls.length === 0) return; - const message: PromptJsonAssistantMessage = { - role: 'assistant', - content: this.assistantText.length > 0 ? this.assistantText : undefined, - tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined, - }; - this.writeJsonLine(message); - if (this.assistantText.length > 0) this.resultText = this.assistantText; - this.discardAssistant(); - } - - discardAssistant(): void { - this.assistantText = ''; - this.toolCalls.length = 0; - } - - finish(ended?: Extract): void { - this.flushAssistant(); - if (ended?.structuredOutput !== undefined) { - this.writeJsonLine({ - type: 'result', - subtype: 'success', - is_error: false, - result: this.resultText, - structured_output: ended.structuredOutput, - session_id: this.sessionId, - }); - } - } - - private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall { - const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId); - if (existing !== undefined) return existing; - const toolCall: PromptJsonToolCall = { - type: 'function', - id: toolCallId, - function: { - name, - arguments: '', - }, - }; - this.toolCalls.push(toolCall); - return toolCall; - } - - private writeJsonLine( - message: PromptJsonAssistantMessage | PromptJsonToolMessage | PromptJsonResult, - ): void { - this.stdout.write(`${JSON.stringify(message)}\n`); - } -} - -function parseOutputSchema(value: string | undefined): JsonObject | undefined { - if (value === undefined) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch (error) { - throw new Error( - `Invalid --json-schema JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, - ); - } - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('Invalid --json-schema JSON: expected a JSON object.'); - } - return parsed as JsonObject; -} - -class PromptBlockWriter { - private started = false; - private atLineStart = false; - private lineWidth = 0; - private readonly wrapWidth: number | undefined; - - constructor(private readonly output: PromptOutput) { - this.wrapWidth = - typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1 - ? output.columns - : undefined; - } - - write(chunk: string): void { - if (chunk.length === 0) return; - let rendered = this.start(); - for (const char of chunk) { - if (this.atLineStart && char !== '\n') { - rendered += PROMPT_BLOCK_INDENT; - this.atLineStart = false; - this.lineWidth = PROMPT_BLOCK_INDENT.length; - } - const charWidth = visibleCharWidth(char); - if ( - this.wrapWidth !== undefined && - !this.atLineStart && - char !== '\n' && - this.lineWidth + charWidth > this.wrapWidth - ) { - rendered += `\n${PROMPT_BLOCK_INDENT}`; - this.lineWidth = PROMPT_BLOCK_INDENT.length; - } - rendered += char; - if (char === '\n') { - this.atLineStart = true; - this.lineWidth = 0; - } else { - this.lineWidth += charWidth; + async function finishCompletedTurn(): Promise { + // Flush the buffered assistant message before the end-of-turn policy + // runs: in stream-json mode the final message is only emitted by + // finish(), so a long drain/steer wait would otherwise withhold the main + // turn's result until the run exits. + outputWriter.flushAssistant(); + try { + const action = await session.handlePrintMainTurnCompleted(); + if (action === 'continue') { + // Stay alive: a still-pending background task will, on completion, + // steer the main agent into a new turn whose events we keep mapping. + // Do not finish yet. + holdEventLoop(); + return; + } + } catch (error) { + log.warn('handlePrintMainTurnCompleted failed', { error }); } + finish(); } - this.output.write(rendered); - } - - finish(): void { - if (!this.started) return; - this.output.write(this.atLineStart ? '\n' : '\n\n'); - this.started = false; - this.atLineStart = false; - this.lineWidth = 0; - } - - private start(): string { - if (this.started) return ''; - this.started = true; - this.atLineStart = false; - this.lineWidth = PROMPT_BLOCK_BULLET.length; - return PROMPT_BLOCK_BULLET; - } -} - -function visibleCharWidth(char: string): number { - return char === '\t' ? 4 : 1; -} - -function formatHookResultPlain(event: HookResultEvent): string { - return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`; -} - -function formatHookResultTitle(event: HookResultEvent): string { - return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`; -} - -function formatHookResultBody(event: HookResultEvent): string { - const content = event.content.trim(); - return content.length === 0 ? '(empty)' : content; -} - -function stringifyJsonValue(value: unknown): string { - if (typeof value === 'string') return value; - const json = JSON.stringify(value); - return json ?? ''; -} - -function stringifyToolOutput(output: unknown): string { - if (typeof output === 'string') return output; - const json = JSON.stringify(output); - return json ?? String(output); + }); } function hasTurnId(event: Event): event is Event & { readonly turnId: number } { @@ -1006,6 +675,12 @@ function hasTurnId(event: Event): event is Event & { readonly turnId: number } { } function formatTurnEndedFailure(event: Extract): string { + if (event.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`; + if (event.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } return `Prompt turn ended with reason: ${event.reason}`; } diff --git a/apps/pythinker-code/src/cli/run-shell.ts b/apps/pythinker-code/src/cli/run-shell.ts index c5dc125c..8f7ca7c6 100644 --- a/apps/pythinker-code/src/cli/run-shell.ts +++ b/apps/pythinker-code/src/cli/run-shell.ts @@ -1,9 +1,14 @@ -import { execSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { createPythinkerHarness, + createPythinkerHarnessV2, + flushDiagnosticLogsSync, log, type PythinkerHarness, + type PythinkerHarnessOptions, type TelemetryClient, } from '@pymodel/pythinker-code-sdk'; import { @@ -15,22 +20,27 @@ import { } from '@pymodel/pythinker-telemetry'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; import { PythinkerTUI } from '#/tui/index'; +import { startupTrace } from '#/utils/startup-trace'; import { currentTheme, getColorPalette } from '#/tui/theme'; -import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import type { CLIOptions } from './options'; -import { drainWritable, writeAndDrain } from './output'; +import { resolveAgentProfileSelection } from './agent-selection'; +import { isPythinkerV2Enabled } from './experimental-v2'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry'; import { createPythinkerCodeHostIdentity } from './version'; export async function runShell( opts: CLIOptions, version: string, + runOptions: { readonly migrateOnly?: boolean } = {}, ): Promise { const startedAt = Date.now(); const configStartedAt = startedAt; @@ -55,11 +65,31 @@ export async function runShell( withContext: withTelemetryContext, setContext: setTelemetryContext, }; - const harness = createPythinkerHarness({ + const harnessOptions: PythinkerHarnessOptions = { homeDir: telemetryBootstrap.homeDir, identity: createPythinkerCodeHostIdentity(version), + skillDirs: opts.skillsDirs, telemetry: telemetryClient, - }); + onOAuthRefresh: (outcome) => { + if (outcome.success) { + track('oauth_refresh', { outcome: 'success' }); + return; + } + track('oauth_refresh', { + outcome: 'error', + reason: outcome.reason, + }); + }, + sessionStartedProperties: { yolo: opts.yolo, auto: opts.auto, plan: opts.plan, afk: false }, + }; + // The agent-core-v2 route is the default (same engine gate as `pythinker -p`): + // the harness is the SDK's v2-backed client, so the whole TUI runs on the + // agent-core-v2 engine unless the legacy flag is set. + const engineV2 = isPythinkerV2Enabled(); + const harness = engineV2 + ? createPythinkerHarnessV2(harnessOptions) + : createPythinkerHarness(harnessOptions); + startupTrace('harness:created'); log.info('pythinker-code starting', { version, uiMode: CLI_UI_MODE, @@ -69,30 +99,36 @@ export async function runShell( }); await harness.ensureConfigFile(); - if (opts.initOnly === true) { - try { - const config = await harness.getConfig(); - await harness.createSession({ - workDir, - model: opts.model ?? config.defaultModel, - setupTrigger: 'init', - }); - } finally { - await harness.close(); - } + const migrationPlan = await detectPendingMigration({ + sourceHome: join(homedir(), '.pythinker'), + targetHome: harness.homeDir, + ignoreMarker: runOptions.migrateOnly, + }); + if (runOptions.migrateOnly === true && migrationPlan === null) { + process.stdout.write(' Nothing to migrate from ~/.pythinker/.\n'); + await harness.close(); return; } const config = await harness.getConfig(); - for (const warning of (await harness.getConfigDiagnostics()).warnings) { - configWarning = combineStartupNotice(configWarning, warning); - } + startupTrace('config:loaded'); + // Config diagnostics (deprecated keys, invalid sections, ...) are surfaced + // by the TUI itself at `finishStartup` via `showConfigWarningsIfAny` — + // folded into the dim startup notice they were too easy to miss. const configMs = Date.now() - configStartedAt; + // Resolve --agent/--agent-file once for the startup session; validateOptions + // has already rejected them alongside --session/--continue. + const agentProfile = await resolveAgentProfileSelection(opts, workDir); const tui = new PythinkerTUI(harness, { cliOptions: opts, + agentProfile, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, tuiConfig, version, workDir, startupNotice: configWarning, + migrationPlan, + migrateOnly: runOptions.migrateOnly, + engineV2, }); initializeCliTelemetry({ @@ -104,7 +140,6 @@ export async function runShell( }); setCrashPhase('runtime'); - const resumed = opts.continue || opts.session !== undefined; const trackLifecycleForSession = ( sessionId: string, event: string, @@ -120,14 +155,86 @@ export async function runShell( trackLifecycleForSession(tui.getCurrentSessionId(), event, properties); }; + let savedStty: string | undefined; + // stty runs before tui.start() reaches the workspace trust gate, so it must + // never be resolved by name through PATH: a `.` or empty PATH segment would + // let an untrusted checkout plant an `stty` executable and run it pre-trust. + // resolveCommandPath returns an absolute path and refuses hits inside the + // cwd; when it cannot resolve stty, skip the save/restore entirely — it is + // best-effort terminal hygiene, not required for startup. + // stty is also POSIX-only, so skip it on Windows instead of relying on the + // catch below. + const sttyPath = process.platform === 'win32' ? undefined : resolveCommandPath('stty'); + if (sttyPath !== undefined) { + try { + // stty operates on the terminal behind stdin, so stdin must be the TTY — + // piping /dev/null (ignore) makes stty fail with "not a tty". + const saved = execFileSync(sttyPath, ['-g'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'ignore'], + }); + savedStty = saved.trim(); + execFileSync(sttyPath, ['-ixon'], { stdio: ['inherit', 'ignore', 'ignore'] }); + } catch { + /* ignore */ + } + } + const restoreStty = (): void => { + if (sttyPath === undefined || savedStty === undefined) return; + const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0); + if (args.length === 0) return; + spawnSync(sttyPath, args, { stdio: ['inherit', 'ignore', 'ignore'] }); + }; + + // If we crash without going through PythinkerTUI.stop(), the terminal is left in + // raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore + // both before exiting so the user's shell is usable afterwards. + const emergencyExit = (exitCode: number): void => { + // The crash log above is only enqueued into the async sink; flush it + // synchronously or the `process.exit()` below would drop the one line that + // explains why we crashed. Best-effort: an exit path must never throw. + try { + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } + restoreTerminalModes(); + restoreStty(); + process.exit(exitCode); + }; + const onUncaughtException = (error: unknown): void => { + try { + log.error('uncaughtException, restoring terminal and exiting', { error: String(error) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + const onUnhandledRejection = (reason: unknown): void => { + try { + log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) }); + } catch { + /* ignore */ + } + emergencyExit(1); + }; + process.on('uncaughtException', onUncaughtException); + process.on('unhandledRejection', onUnhandledRejection); + // Remove the crash handlers once the TUI exits cleanly so repeated runShell() + // calls in the same process (e.g. tests) don't accumulate process listeners. + const removeCrashHandlers = (): void => { + process.off('uncaughtException', onUncaughtException); + process.off('unhandledRejection', onUnhandledRejection); + }; + tui.onExit = async (exitCode = 0) => { const sessionId = tui.getCurrentSessionId(); const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); - await writeAndDrain(process.stdout, `${gutter}Bye!\n`); + process.stdout.write(`${gutter}Bye!\n`); const hints: string[] = []; if (sessionId !== '' && hasContent) { hints.push(`${gutter}To resume this session: pythinker -r ${sessionId}`); @@ -136,29 +243,25 @@ export async function runShell( hints.push(`${gutter}open ${toTerminalHyperlink(tui.exitOpenUrl, tui.exitOpenUrl)}`); } if (hints.length > 0) { - await writeAndDrain(process.stderr, `\n${hints.join('\n')}\n`); + process.stderr.write(`\n${hints.join('\n')}\n`); + } + removeCrashHandlers(); + restoreStty(); + if (tui.exitForegroundTask !== undefined) { + // `/web` starting a new server: the TUI has shut down cleanly; hand the + // terminal to the foreground server instead of exiting. The task runs + // until the server stops (Ctrl+C), then this process exits. + await tui.exitForegroundTask(exitCode); + return; } - // Flush everything still queued (e.g. alt-screen teardown) before exiting, - // or the terminal may drop the final lines. - await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); process.exit(exitCode); }; - try { - execSync('stty -ixon', { stdio: 'ignore' }); - } catch { - /* ignore */ - } try { const initStartedAt = Date.now(); + startupTrace('tui.start:begin'); await tui.start(); + startupTrace('tui.start:end'); const initMs = Date.now() - initStartedAt; - trackLifecycle('started', { - resumed, - yolo: opts.yolo, - auto: opts.auto, - plan: opts.plan, - afk: false, - }); const startupSessionId = tui.getCurrentSessionId(); const mcpMs = await tui.getStartupMcpMs(); trackLifecycleForSession(startupSessionId, 'startup_perf', { @@ -166,10 +269,12 @@ export async function runShell( config_ms: configMs, init_ms: initMs, mcp_ms: mcpMs, + tui_mode: tui.state.ui.mode, }); } catch (error) { + removeCrashHandlers(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/pythinker-code/src/cli/sub/acp-native.ts b/apps/pythinker-code/src/cli/sub/acp-native.ts new file mode 100644 index 00000000..d75b5e0a --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/acp-native.ts @@ -0,0 +1,72 @@ +/** + * Native `pythinker acp` implementation. + * + * Starts the Agent Client Protocol (ACP) server backed directly by the + * DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible + * clients can drive a pythinker-code session on the default engine. + * + * Wire-up mirrors `pythinker acp` for the parts that are host-independent: + * - `--login` pivots into the shared device-code login flow (the entry point + * ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking + * the agent binary with the advertised `args:['--login']`). + * - `PYTHINKER_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the + * login subprocess writes its token under the same data root the server + * reads from, and `process.argv[1]` is advertised as the legacy + * `_meta['terminal-auth'].command` fallback. + * + * `@pymodel/acp-server` (and its `agent-core-v2` engine) is loaded via a + * lazy dynamic import so parsing the CLI does not initialize the ACP engine — + * mirroring the `pythinker server run` v2 routing in `#/cli/sub/server/run.ts`. + */ + +import type { Command } from 'commander'; + +import { getVersion } from '#/cli/version'; +import { PYTHINKER_CODE_HOME_ENV } from '#/constant/app'; +import { getDataDir } from '#/utils/paths'; + +import { runLoginFlow } from './login-flow'; + +export function registerNativeAcpCommand(parent: Command): void { + parent + .command('acp') + .description('Run pythinker-code as an Agent Client Protocol (ACP) server over stdio.') + .option( + '--login', + 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', + false, + ) + .action(async (opts: { login?: boolean }) => { + if (opts.login === true) { + await runLoginFlow(); + return; + } + // Forward `PYTHINKER_CODE_HOME` (if set) into `authMethods[0].env` so the + // login subprocess clients spawn for terminal-auth writes its token + // under the same data root the ACP server reads from. + const sandboxHome = process.env[PYTHINKER_CODE_HOME_ENV]; + const terminalAuthEnv = + sandboxHome !== undefined && sandboxHome.length > 0 + ? { [PYTHINKER_CODE_HOME_ENV]: sandboxHome } + : undefined; + // Legacy `_meta.terminal-auth` fallback for clients that don't yet + // honor the first-class `type:'terminal'`. `command` is the absolute + // path to this very binary so the client can spawn it for login. + const legacyCommand = process.argv[1]; + try { + const { runAcpServer } = await import('@pymodel/acp-server'); + await runAcpServer({ + homeDir: getDataDir(), + agentInfo: { name: 'Pythinker Code CLI', version: getVersion() }, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), + }); + process.exit(0); + } catch (error) { + process.stderr.write(`acp server: fatal error: ${String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/pythinker-code/src/cli/sub/acp.ts b/apps/pythinker-code/src/cli/sub/acp.ts index 7a9c5d76..c1309ae0 100644 --- a/apps/pythinker-code/src/cli/sub/acp.ts +++ b/apps/pythinker-code/src/cli/sub/acp.ts @@ -1,9 +1,9 @@ /** - * `pythinker acp` sub-command. + * `pythinker acp` sub-command routing and legacy implementation. * - * Starts the Agent Client Protocol (ACP) server over stdio so that - * ACP-compatible clients (editors, IDEs, custom front-ends) can drive - * a pythinker-code session. + * By default the command delegates to the agent-core-v2 ACP server. A truthy + * `PYTHINKER_CODE_LEGACY_FLAG` uses the SDK harness and `@pymodel/acp-adapter` + * implementation below instead. * * Wire-up: * - A {@link PythinkerHarness} is constructed with the pythinker-code host identity @@ -29,14 +29,20 @@ import { } from '@pymodel/acp-adapter'; import { createPythinkerHarness, type Session, type SkillSummary } from '@pymodel/pythinker-code-sdk'; -import { drainWritable, isBrokenPipeError, writeAndDrain } from '#/cli/output'; -import { createPythinkerCodeHostIdentity, getVersion } from '#/cli/version'; import { PYTHINKER_CODE_HOME_ENV } from '#/constant/app'; +import { createPythinkerCodeHostIdentity, getVersion } from '#/cli/version'; import { buildSkillSlashCommands } from '#/tui/commands/skills'; +import { isLegacyEnabled } from '../experimental-v2'; +import { registerNativeAcpCommand } from './acp-native'; import { runLoginFlow } from './login-flow'; export function registerAcpCommand(parent: Command): void { + if (!isLegacyEnabled()) { + registerNativeAcpCommand(parent); + return; + } + parent .command('acp') .description('Run pythinker-code as an Agent Client Protocol (ACP) server over stdio.') @@ -109,46 +115,19 @@ export function registerAcpCommand(parent: Command): void { skillCommandMap: built.commandMap, }; }; - let hasFatalError = false; - let fatalError: unknown; try { await runAcpServer(harness, { agentInfo: { name: 'Pythinker Code CLI', version: getVersion() }, slashCommands: resolveSlashCommands, - terminalAuthEnv, - terminalAuthLegacyCommand: - legacyCommand !== undefined && legacyCommand.length > 0 ? legacyCommand : undefined, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), }); - await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); - // A closed transport (EPIPE) means the client disconnected — a normal - // end of session, not a server failure. Anything else is fatal. - } catch (error) { - if (!isBrokenPipeError(error)) { - hasFatalError = true; - fatalError = error; - } + process.exit(0); + } catch (err) { + process.stderr.write(`acp server: fatal error: ${String(err)}\n`); + process.exit(1); } - if (hasFatalError) { - try { - await writeAndDrain( - process.stderr, - `acp server: fatal error: ${formatAcpFatalError(fatalError)}\n`, - ); - } finally { - process.exit(1); - } - } - process.exit(0); }); } - -/** Render an arbitrary rejection as a stable, printable message. */ -function formatAcpFatalError(error: unknown): string { - if (error instanceof Error) return error.message; - if (typeof error === 'string') return error; - try { - return JSON.stringify(error) ?? 'Unknown error'; - } catch { - return 'Unknown error'; - } -} diff --git a/apps/pythinker-code/src/cli/sub/dashboard.ts b/apps/pythinker-code/src/cli/sub/dashboard.ts deleted file mode 100644 index 7ce35d19..00000000 --- a/apps/pythinker-code/src/cli/sub/dashboard.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * `pythinker dashboard` sub-command. - * - * CLI glue only: resolves the pythinker home, starts the in-process session - * dashboard server (auto-picking a free port by default), prints the URL, - * optionally opens the browser (with an optional session deep-link), then - * waits for Ctrl-C and shuts the server down. The dashboard server itself - * lives in `@pymodel/dashboard-server`. - */ - -import type { Command } from 'commander'; - -import { createCliTelemetryBootstrap } from '#/cli/telemetry'; -import { openUrl } from '#/utils/open-url'; - -interface WritableLike { - write(chunk: string): boolean; -} - -export interface StartedDashboardServer { - readonly port: number; - readonly host: string; - readonly url: string; - readonly close: () => Promise; -} - -export interface StartDashboardServerArgs { - readonly homeDir: string; - readonly port: number; - readonly host?: string; - readonly webAsset?: { gzipped: Uint8Array }; -} - -export interface DashboardDeps { - readonly getHomeDir: () => string; - readonly startDashboardServer: (opts: StartDashboardServerArgs) => Promise; - readonly openUrl: (url: string) => Promise; - readonly waitForShutdown: () => Promise; - readonly stdout: WritableLike; - readonly stderr: WritableLike; - readonly exit: (code: number) => never; -} - -export interface DashboardOptions { - readonly open: boolean; - readonly port?: number; - readonly host?: string; - readonly sessionId?: string; -} - -export async function handleDashboard(deps: DashboardDeps, opts: DashboardOptions): Promise { - const homeDir = deps.getHomeDir(); - - // Lazily load the embedded single-file SPA so normal `pythinker` startup never - // pays for it. The module is generated at build time (prebuild). When running - // from source without a build — e.g. tests — the generated value module is - // absent and the dynamic import throws; in that case the server falls back to - // its own static `public/` directory. - let webAsset: { gzipped: Uint8Array } | undefined; - try { - const { DASHBOARD_WEB_GZIP_B64 } = await import('#/generated/dashboard-web-asset'); - if (DASHBOARD_WEB_GZIP_B64.length > 0) { - webAsset = { gzipped: new Uint8Array(Buffer.from(DASHBOARD_WEB_GZIP_B64, 'base64')) }; - } - } catch { - // Embedded asset not generated in this context — fall back to filesystem. - } - - let server: StartedDashboardServer; - try { - server = await deps.startDashboardServer({ - homeDir, - port: opts.port ?? 0, - ...(opts.host === undefined ? {} : { host: opts.host }), - ...(webAsset === undefined ? {} : { webAsset }), - }); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - deps.stderr.write(`Failed to start pythinker dashboard: ${msg}\n`); - return deps.exit(1); - } - - const target = - opts.sessionId === undefined - ? server.url - : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; - - deps.stdout.write(`pythinker dashboard is running at ${server.url}\n`); - deps.stdout.write('Press Ctrl-C to stop.\n'); - - if (opts.open) { - try { - await deps.openUrl(target); - } catch { - deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`); - } - } - - await deps.waitForShutdown(); - await server.close(); -} - -export function registerDashboardCommand(parent: Command, overrides?: Partial): void { - parent - .command('dashboard') - .description('Launch the session dashboard in your browser.') - .option('--port ', 'Port to bind. Default: auto-pick a free port.') - .option('--host ', 'Host to bind. Default: 127.0.0.1.') - .option('--no-open', 'Do not open the browser automatically.') - .argument('[sessionId]', 'Open directly to this session.') - .action( - async ( - sessionId: string | undefined, - options: { port?: string; host?: string; open?: boolean }, - ) => { - const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); - await handleDashboard(createDefaultDashboardDeps(overrides), { - open: options.open !== false, - ...(port === undefined || Number.isNaN(port) ? {} : { port }), - ...(options.host === undefined ? {} : { host: options.host }), - ...(sessionId === undefined ? {} : { sessionId }), - }); - }, - ); -} - -function createDefaultDashboardDeps(overrides: Partial = {}): DashboardDeps { - return { - getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), - startDashboardServer: - overrides.startDashboardServer ?? - (async (opts) => { - // Dynamic import keeps the dashboard server (and Hono) out of the hot path. - const { startDashboardServer } = await import('@pymodel/dashboard-server/start'); - return startDashboardServer(opts); - }), - // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep. - openUrl: - overrides.openUrl ?? - (async (url: string) => { - openUrl(url); - }), - waitForShutdown: overrides.waitForShutdown ?? waitForSigint, - stdout: overrides.stdout ?? process.stdout, - stderr: overrides.stderr ?? process.stderr, - exit: overrides.exit ?? ((code: number) => process.exit(code)), - }; -} - -function waitForSigint(): Promise { - return new Promise((resolve) => { - const onSig = (): void => { - process.off('SIGINT', onSig); - resolve(); - }; - process.on('SIGINT', onSig); - }); -} diff --git a/apps/pythinker-code/src/cli/sub/doctor.ts b/apps/pythinker-code/src/cli/sub/doctor.ts index f08896a3..42bf09c0 100644 --- a/apps/pythinker-code/src/cli/sub/doctor.ts +++ b/apps/pythinker-code/src/cli/sub/doctor.ts @@ -1,31 +1,17 @@ -import { constants as fsConstants, existsSync } from 'node:fs'; -import { access, readFile, realpath } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { createPythinkerConfigRpc, - findExistingRg, - resolvePythinkerHome, type PythinkerConfigRpc, type PythinkerConfigValidationIssue, - type RgResolution, } from '@pymodel/pythinker-code-sdk'; import type { Command } from 'commander'; import { z } from 'zod'; +import { isPythinkerV2Enabled } from '#/cli/experimental-v2'; import { getTuiConfigPath, parseTuiConfig } from '#/tui/config'; -import { readUpdateCache } from '#/cli/update/cache'; -import { readUpdateInstallState } from '#/cli/update/install-state'; -import { - automaticUpdateModeFor, - isAutoUpdateDisabledByEnv, - shouldAutoInstallUpdates, - type AutomaticUpdateMode, -} from '#/cli/update/preflight'; -import { detectInstallSource } from '#/cli/update/source'; -import type { UpdateInstallFailure } from '#/cli/update/types'; -import { findHostPackageRoot, getVersion } from '#/cli/version'; -import { getUpdateInstallLogFile } from '#/utils/paths'; interface WritableLike { write(chunk: string): boolean; @@ -43,30 +29,7 @@ export interface DoctorDeps { readonly configRpc?: PythinkerConfigRpc; readonly fileExists?: (path: string) => boolean; readonly readTextFile?: (path: string) => Promise; - readonly validateConfigToml?: (text: string, path: string) => MaybePromise; - readonly runtimeInfo?: () => MaybePromise; -} - -export interface DoctorRuntimeInfo { - readonly version: string; - readonly installSource: string; - /** Absent on a native binary: a packaged install has no `package.json`. */ - readonly packageRoot?: string; - readonly executable: string; - readonly installations?: readonly string[]; - readonly ripgrep?: RgResolution; - readonly update?: { - readonly latest: string | null; - readonly checkedAt: string | null; - readonly autoUpdate?: 'on' | 'off' | 'env-disabled'; - readonly mode?: AutomaticUpdateMode; - readonly pendingVersion?: string; - readonly pendingRequestedBy?: 'automatic' | 'manual'; - readonly activeOperation?: string; - readonly lastSuccess?: string; - readonly lastFailure?: string; - readonly logPath?: string; - }; + readonly validateConfigToml?: (text: string, path: string) => MaybePromise; } export interface DoctorOptions { @@ -78,7 +41,8 @@ interface CheckSpec { readonly label: 'config.toml' | 'tui.toml'; readonly path: string; readonly explicit: boolean; - readonly parse: (text: string, path: string) => MaybePromise; + /** Throws on invalid content; may return a non-fatal warning message. */ + readonly parse: (text: string, path: string) => MaybePromise; } interface CheckResult { @@ -97,27 +61,17 @@ interface ResolvedDoctorDeps { readonly exit: (code: number) => never; readonly fileExists: (path: string) => boolean; readonly readTextFile: (path: string) => Promise; - readonly validateConfigToml: (text: string, path: string) => MaybePromise; - readonly runtimeInfo: () => MaybePromise; + readonly validateConfigToml: (text: string, path: string) => MaybePromise; } -export async function handleDoctor( - deps: Partial | undefined, - options: DoctorOptions, -): Promise { +export async function handleDoctor(deps: DoctorDeps, options: DoctorOptions): Promise { const resolved = resolveDeps(deps); const cwd = resolved.cwd(); const specs = await buildCheckSpecs(resolved, options, cwd); - const [results, runtimeInfo] = await Promise.all([ - Promise.all(specs.map((spec) => checkTomlFile(resolved, spec))), - options.target === undefined ? resolved.runtimeInfo() : undefined, - ]); + const results = await Promise.all(specs.map((spec) => checkTomlFile(resolved, spec))); const issueCount = results.filter((result) => result.status === 'ERROR').length; - const text = - issueCount === 0 - ? formatSuccess(results, runtimeInfo) - : formatFailure(results, issueCount, runtimeInfo); + const text = issueCount === 0 ? formatSuccess(results) : formatFailure(results, issueCount); if (issueCount === 0) { resolved.stdout.write(text); } else { @@ -178,87 +132,20 @@ function resolveDeps(deps: Partial | DoctorDeps | undefined): Resolv readTextFile: deps?.readTextFile ?? ((path) => readFile(path, 'utf-8')), validateConfigToml: deps?.validateConfigToml ?? - ((text, filePath) => getConfigRpc().validateConfigToml({ text, filePath })), - runtimeInfo: - deps?.runtimeInfo ?? - (async () => { - const [installSource, installations, ripgrep, update, installState, autoInstall] = await Promise.all([ - detectInstallSource(), - findPythinkerExecutables(), - findExistingRg(resolvePythinkerHome()), - readUpdateCache(), - readUpdateInstallState(), - shouldAutoInstallUpdates(), - ]); - return { - version: getVersion(), - installSource, - packageRoot: findHostPackageRoot() ?? undefined, - executable: process.execPath, - installations, - ripgrep, - update: { - latest: update.latest, - checkedAt: update.checkedAt, - autoUpdate: isAutoUpdateDisabledByEnv() ? 'env-disabled' : autoInstall ? 'on' : 'off', - mode: automaticUpdateModeFor(installSource, process.platform), - pendingVersion: installState.pending?.version, - pendingRequestedBy: installState.pending?.requestedBy, - activeOperation: - installState.active === null - ? undefined - : `${installState.active.operation ?? 'install'} ${installState.active.version}`, - lastSuccess: - installState.lastSuccess === null - ? undefined - : `${installState.lastSuccess.version} (installed ` + - `${installState.lastSuccess.installedAt})` + - (installState.lastSuccess.unverified === undefined - ? '' - : ` — unverified: ${installState.lastSuccess.unverified}`), - lastFailure: - installState.lastFailure === null - ? undefined - : formatUpdateFailure(installState.lastFailure), - logPath: getUpdateInstallLogFile(), - }, - }; + (async (text, filePath) => { + if (isPythinkerV2Enabled()) { + // Default v2 route (same engine gate as `pythinker -p`): validate with + // the agent-core-v2 section registry instead of the legacy schema. + // Loaded lazily so the v2 module graph stays off the legacy path. + const { validateConfigTomlV2 } = await import('../v2/validate-config'); + return validateConfigTomlV2(text, filePath); + } + await getConfigRpc().validateConfigToml({ text, filePath }); + return undefined; }), }; } -function formatUpdateFailure(failure: UpdateInstallFailure): string { - const summary = `${failure.operation ?? 'install'} ${failure.version} ` + - `(attempt ${String(failure.attempts)})`; - const message = failure.message?.replaceAll(/\s+/gu, ' ').trim(); - return message === undefined || message === '' ? summary : `${summary}: ${message}`; -} - -export async function findPythinkerExecutables( - pathValue = process.env['PATH'], - platform: NodeJS.Platform = process.platform, -): Promise { - if (pathValue === undefined || pathValue === '') return []; - const names = - platform === 'win32' - ? ['pythinker.exe', 'pythinker.cmd', 'pythinker.bat', 'pythinker'] - : ['pythinker']; - const installations = new Map(); - for (const directory of pathValue.split(platform === 'win32' ? ';' : ':')) { - for (const name of names) { - const candidate = resolve(directory === '' ? '.' : directory, name); - try { - await access(candidate, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK); - const target = await realpath(candidate); - if (!installations.has(target)) installations.set(target, candidate); - } catch { - // Missing and non-executable PATH entries are not installations. - } - } - } - return [...installations.values()]; -} - async function buildCheckSpecs( deps: ResolvedDoctorDeps, options: DoctorOptions, @@ -329,8 +216,8 @@ async function checkTomlFile(deps: ResolvedDoctorDeps, spec: CheckSpec): Promise try { const text = await deps.readTextFile(spec.path); - await spec.parse(text, spec.path); - return { label: spec.label, path: spec.path, status: 'OK' }; + const warning = await spec.parse(text, spec.path); + return { label: spec.label, path: spec.path, status: 'OK', message: warning ?? undefined }; } catch (error) { return { label: spec.label, @@ -361,14 +248,10 @@ function resolveInputPath(input: string, cwd: string): string { return isAbsolute(input) ? input : resolve(cwd, input); } -function formatSuccess( - results: readonly CheckResult[], - runtimeInfo: DoctorRuntimeInfo | undefined, -): string { +function formatSuccess(results: readonly CheckResult[]): string { return [ 'Pythinker doctor', '', - ...formatRuntimeInfo(runtimeInfo), ...formatResults(results), '', 'All checked config files are valid.', @@ -376,103 +259,15 @@ function formatSuccess( ].join('\n'); } -function formatFailure( - results: readonly CheckResult[], - issueCount: number, - runtimeInfo: DoctorRuntimeInfo | undefined, -): string { +function formatFailure(results: readonly CheckResult[], issueCount: number): string { return [ `Pythinker doctor found ${String(issueCount)} ${issueCount === 1 ? 'issue' : 'issues'}.`, '', - ...formatRuntimeInfo(runtimeInfo), ...formatResults(results), '', ].join('\n'); } -function formatRuntimeInfo(info: DoctorRuntimeInfo | undefined): string[] { - if (info === undefined) return []; - const installations = info.installations ?? []; - return [ - 'Runtime', - ` Version: ${info.version}`, - ` Install source: ${info.installSource}`, - ...(info.packageRoot === undefined ? [] : [` Package root: ${info.packageRoot}`]), - ` Executable: ${info.executable}`, - ...(installations.length > 1 - ? [ - ' Warning: Multiple Pythinker executables found on PATH:', - ...installations.map((path) => ` ${path}`), - ] - : []), - ...(info.ripgrep === undefined - ? [] - : [` Search: ${info.ripgrep.path} (${info.ripgrep.source})`]), - ...(info.update === undefined - ? [] - : [ - ' Update channel: CDN staged rollout', - ...formatAutomaticUpdate(info), - ...(info.update.latest === null - ? [' Latest cached version: unavailable'] - : [ - ` Latest cached version: ${info.update.latest}${ - info.update.checkedAt === null ? '' : ` (checked ${info.update.checkedAt})` - }`, - ]), - ...formatPreparedUpdate(info.update), - ...(info.update.activeOperation === undefined - ? [] - : [` Update operation: ${info.update.activeOperation}`]), - ...(info.update.lastSuccess === undefined - ? [] - : [` Last update success: ${info.update.lastSuccess}`]), - ...(info.update.lastFailure === undefined - ? [] - : [` Last update failure: ${info.update.lastFailure}`]), - ...(info.update.logPath === undefined ? [] : [` Update log: ${info.update.logPath}`]), - ]), - '', - ]; -} - -function formatPreparedUpdate( - update: NonNullable, -): string[] { - if (update.pendingVersion === undefined) return []; - if (update.pendingRequestedBy === 'automatic' && update.autoUpdate !== 'on') { - return [ - ` Prepared update: ${update.pendingVersion} ` + - '(automatic activation paused until auto-update is enabled)', - ]; - } - return [` Prepared update: ${update.pendingVersion} (installs on next launch)`]; -} - -function formatAutomaticUpdate(info: DoctorRuntimeInfo): string[] { - const update = info.update; - if (update?.autoUpdate === undefined) return []; - if (update.autoUpdate === 'env-disabled') { - return [ - ' Auto-update: disabled by PYTHINKER_CODE_NO_AUTO_UPDATE or ' + - 'PYTHINKER_CLI_NO_AUTO_UPDATE', - ]; - } - if (update.autoUpdate === 'off') { - return [' Auto-update: off (tui.toml [upgrade].auto_install)']; - } - switch (update.mode) { - case 'restart-install': - return [' Auto-update: on (prepare in background; install on next launch)']; - case 'background-install': - return [' Auto-update: on (installs in background)']; - case 'manual': - return [` Auto-update: unavailable for ${info.installSource}`]; - case undefined: - return [' Auto-update: on (tui.toml [upgrade].auto_install)']; - } -} - function formatResults(results: readonly CheckResult[]): string[] { const lines: string[] = []; for (const result of results) { diff --git a/apps/pythinker-code/src/cli/sub/export.ts b/apps/pythinker-code/src/cli/sub/export.ts index 04d81bfb..da7f5aed 100644 --- a/apps/pythinker-code/src/cli/sub/export.ts +++ b/apps/pythinker-code/src/cli/sub/export.ts @@ -15,6 +15,7 @@ import { } from '@pymodel/pythinker-telemetry'; import { createPythinkerHarness, + createPythinkerHarnessV2, type ExportSessionInput, type ExportSessionResult, type PythinkerHarness, @@ -30,6 +31,8 @@ import { detectInstallSource } from '#/cli/update/source'; import { createPythinkerCodeHostIdentity } from '#/cli/version'; import { detectShellEnvironment } from '#/utils/process/shell-env'; +import { isPythinkerV2Enabled } from '../experimental-v2'; + interface WritableLike { write(chunk: string): boolean; } @@ -120,15 +123,22 @@ export function registerExportCommand(parent: Command, deps?: Partial { - await handleExport(createDefaultExportDeps(deps), sessionId, options.output, { - yes: options.yes === true, - includeGlobalLog: options.includeGlobalLog !== false, - }); + const resolved = createDefaultExportDeps(deps); + try { + await handleExport(resolved, sessionId, options.output, { + yes: options.yes === true, + includeGlobalLog: options.includeGlobalLog !== false, + }); + } finally { + await resolved.close(); + } }, ); } -function createDefaultExportDeps(overrides: Partial = {}): ExportDeps { +function createDefaultExportDeps(overrides: Partial = {}): ExportDeps & { + readonly close: () => Promise; +} { let harness: PythinkerHarness | undefined; let telemetryBootstrap: ReturnType | undefined; let telemetryInitialized = false; @@ -145,7 +155,9 @@ function createDefaultExportDeps(overrides: Partial = {}): ExportDep }; const getHarness = (): PythinkerHarness => { const currentTelemetryBootstrap = getTelemetryBootstrap(); - harness ??= createPythinkerHarness({ + // Same engine gate as `pythinker -p` / the TUI: the SDK's v2-backed harness by + // default, the legacy agent-core harness when PYTHINKER_CODE_LEGACY_FLAG is set. + harness ??= (isPythinkerV2Enabled() ? createPythinkerHarnessV2 : createPythinkerHarness)({ homeDir: currentTelemetryBootstrap.homeDir, identity, telemetry: telemetryClient, @@ -197,6 +209,12 @@ function createDefaultExportDeps(overrides: Partial = {}): ExportDep stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, exit: overrides.exit ?? ((code: number) => process.exit(code)), + // The v2 harness boots an engine whose watchers hold the event loop open; + // close it so a one-shot command can exit. No-op when the run never needed + // the harness. + close: async () => { + await harness?.close(); + }, }; } diff --git a/apps/pythinker-code/src/cli/sub/login-flow.ts b/apps/pythinker-code/src/cli/sub/login-flow.ts index ed2d9a5c..bde445c1 100644 --- a/apps/pythinker-code/src/cli/sub/login-flow.ts +++ b/apps/pythinker-code/src/cli/sub/login-flow.ts @@ -1,69 +1,63 @@ /** - * Shared login flow used by both `pythinker login` (top-level subcommand) and - * `pythinker acp --login` (the first-class ACP terminal-auth entry point). - * Exiting the process is part of the contract — callers MUST treat the - * returned promise as `Promise`. - * - * The terminal `LoginUi` renderer (`#/auth/terminal-login-ui`) shows the same - * provider picker as the TUI's `/login`. `--provider ` skips the - * picker; anything that does not match a known provider fails loudly rather - * than falling back to a default. Without a TTY the flow refuses to guess and - * exits non-zero. + * Shared device-code login flow used by both `pythinker login` (top-level + * subcommand) and `pythinker acp --login` (the first-class ACP terminal-auth + * entry point). Exiting the process is part of the contract — callers + * MUST treat the returned promise as `Promise`. */ -import { createPythinkerHarness, runLogin } from '@pymodel/pythinker-code-sdk'; +import { createPythinkerHarness } from '@pymodel/pythinker-code-sdk'; -import { createTerminalLoginUi, UnknownProviderError } from '#/auth/terminal-login-ui'; -import { writeAndDrain } from '#/cli/output'; import { createPythinkerCodeHostIdentity } from '#/cli/version'; +import { openUrl } from '#/utils/open-url'; -export interface LoginFlowOptions { - /** `--provider ` — resolve and skip the interactive picker. */ - readonly provider?: string | undefined; -} - -export async function runLoginFlow(options: LoginFlowOptions = {}): Promise { - if (!process.stdin.isTTY) { - try { - await writeAndDrain( - process.stderr, - 'Login requires an interactive terminal.\n', - ); - } finally { - process.exit(1); - } - } - +export async function runLoginFlow(): Promise { const identity = createPythinkerCodeHostIdentity(); const harness = createPythinkerHarness({ identity, uiMode: 'cli', }); - const ui = createTerminalLoginUi(harness, { provider: options.provider }); - // Ctrl-C cancels whatever is in flight (catalog fetch, OAuth poll). While a - // clack prompt is active, clack handles the signal itself and resolves the - // prompt with the cancel symbol — cancelInFlight is unset then, so this is - // a no-op and the prompt's own cancel path decides the outcome. + const controller = new AbortController(); process.once('SIGINT', () => { - ui.cancelInFlight?.(); + controller.abort(); }); try { - const loggedIn = await runLogin(ui); - // Flush before exit so clack's final frame survives process.exit. - try { - await writeAndDrain(process.stderr, ''); - } finally { - process.exit(loggedIn ? 0 : 1); - } + const result = await harness.auth.login(undefined, { + signal: controller.signal, + onDeviceCode: (data) => { + const url = data.verificationUriComplete || data.verificationUri; + // Print the manual fallback before attempting to open the user's + // browser so headless/browser-opener failures never hide the URL + // and code needed to complete login. + process.stderr.write( + [ + '', + `Opening browser for Pythinker device login: ${url}`, + `If the browser did not open, paste the URL above and enter code: ${data.userCode}`, + data.expiresIn !== null && data.expiresIn !== undefined + ? `Code expires in ${data.expiresIn}s.` + : undefined, + 'Waiting for authorization to complete...', + '', + ] + .filter((line): line is string => line !== undefined) + .join('\n'), + ); + try { + openUrl(url); + } catch { + // Best effort only: the manual fallback has already been printed. + } + }, + }); + process.stderr.write(`Logged in to ${result.providerName}.\n`); + process.exit(0); } catch (error) { - const message = - error instanceof UnknownProviderError - ? `Unknown provider "${error.input}"\nValid provider ids: ${error.validIds.join(', ')}\n` - : `Login failed: ${error instanceof Error ? error.message : String(error)}\n`; - try { - await writeAndDrain(process.stderr, message); - } finally { - process.exit(1); + if (controller.signal.aborted) { + process.stderr.write('Login cancelled.\n'); + } else { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Login failed: ${message}\n`); } + process.exit(1); } } diff --git a/apps/pythinker-code/src/cli/sub/login.ts b/apps/pythinker-code/src/cli/sub/login.ts index 642bc54e..0c3efd07 100644 --- a/apps/pythinker-code/src/cli/sub/login.ts +++ b/apps/pythinker-code/src/cli/sub/login.ts @@ -1,8 +1,5 @@ /** - * `pythinker login` — interactive multi-provider login. The action drives the - * same provider picker the TUI's `/login` offers (OAuth and API-key flows, - * plus every catalog provider). `--provider ` skips the picker. - * + * `pythinker login` — drive the OAuth device-code flow non-interactively. * The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path) * advertised by the ACP server points clients at this entry point. The * first-class ACP `args=['--login']` path enters the same flow via @@ -16,12 +13,8 @@ import { runLoginFlow } from './login-flow'; export function registerLoginCommand(parent: Command): void { parent .command('login') - .description('Log in to a model provider.') - .option( - '-p, --provider ', - 'provider id or name to log in to (skips provider selection)', - ) - .action(async (opts: { provider?: string }) => { - await runLoginFlow({ provider: opts.provider }); + .description('Authenticate with Pythinker Code CLI via the device-code flow.') + .action(async () => { + await runLoginFlow(); }); } diff --git a/apps/pythinker-code/src/cli/sub/mcp.ts b/apps/pythinker-code/src/cli/sub/mcp.ts deleted file mode 100644 index 519fe88b..00000000 --- a/apps/pythinker-code/src/cli/sub/mcp.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { runPythinkerMcpServer } from '@pymodel/pythinker-code-sdk'; -import type { Command } from 'commander'; - -import { getVersion } from '#/cli/version'; - -export interface McpCommandDeps { - readonly cwd: () => string; - readonly version: () => string; - readonly runServer: typeof runPythinkerMcpServer; -} - -export function registerMcpCommand(parent: Command, deps?: Partial): void { - const resolved: McpCommandDeps = { - cwd: deps?.cwd ?? (() => process.cwd()), - version: deps?.version ?? getVersion, - runServer: deps?.runServer ?? runPythinkerMcpServer, - }; - const mcp = parent.command('mcp').description('Manage Model Context Protocol integrations.'); - mcp - .command('serve') - .description('Expose active built-in Pythinker tools as an MCP server over stdio.') - .option('--debug', 'Enable debug diagnostics.', false) - .option('--verbose', 'Enable verbose server mode.', false) - .action(async (options: { debug: boolean; verbose: boolean }) => { - await resolved.runServer({ - workDir: resolved.cwd(), - version: resolved.version(), - debug: options.debug, - verbose: options.verbose, - }); - }); -} diff --git a/apps/pythinker-code/src/cli/sub/plugin-run-node.ts b/apps/pythinker-code/src/cli/sub/plugin-run-node.ts index 071c8143..a73e7620 100644 --- a/apps/pythinker-code/src/cli/sub/plugin-run-node.ts +++ b/apps/pythinker-code/src/cli/sub/plugin-run-node.ts @@ -17,7 +17,7 @@ export async function runPluginNodeEntry(entry: string, args: readonly string[]) } process.argv = [process.argv[0] ?? process.execPath, entryReal, ...args]; - await import(/* @vite-ignore */ pathToFileURL(entryReal).href); + await import(pathToFileURL(entryReal).href); } function isWithin(candidate: string, root: string): boolean { diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index f6cae404..3552f054 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -17,17 +17,16 @@ import { CustomRegistryApiError, fetchCustomRegistry, type CustomRegistrySource, - type PlatformConfigShape, + type ManagedPythinkerConfigShape, } from '@pymodel/pythinker-code-oauth'; import { - catalogConnectionWire, + applyCatalogProvider, catalogProviderModels, CatalogFetchError, createPythinkerHarness, + createPythinkerHarnessV2, DEFAULT_CATALOG_URL, - fetchCatalog, - importCatalogProvider, - inferWireType, + resolveCatalogImport, type Catalog, type CatalogProviderEntry, type PythinkerConfig, @@ -35,7 +34,10 @@ import { } from '@pymodel/pythinker-code-sdk'; import type { Command } from 'commander'; -import { createPythinkerCodeHostIdentity } from '#/cli/version'; +import { createPythinkerCodeHostIdentity, createPythinkerCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; + +import { isPythinkerV2Enabled } from '../experimental-v2'; interface WritableLike { write(chunk: string): boolean; @@ -65,9 +67,9 @@ interface CatalogListOptions { interface CatalogAddOptions { readonly apiKey?: string; - readonly apiKeyEnv?: string; readonly defaultModel?: string; readonly url?: string; + readonly baseUrl?: string; } export async function handleProviderAdd( @@ -100,7 +102,7 @@ export async function handleProviderAdd( let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); + entries = await fetchCustomRegistry(source, { userAgent: createPythinkerCodeUserAgent() }); } catch (error) { const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : ''; deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`); @@ -118,14 +120,7 @@ export async function handleProviderAdd( // would discard providers we already applied in memory but have not yet // persisted. Drop every stale id up front in a single batch instead, then // apply against the resulting fresh config. - // Snapshot the defaults before `removeProvider`: the core clears - // `defaultProvider`/`defaultModel`/`defaultThinking` whenever the removed - // provider owns them, and the batch below must not wipe user defaults - // just because a registry entry is being re-imported. let config = await harness.getConfig(); - const previousDefaultProvider = config.defaultProvider; - const previousDefaultModel = config.defaultModel; - const previousDefaultThinking = config.defaultThinking; const staleIds = entryList .filter((entry) => config.providers[entry.id] !== undefined) .map((entry) => entry.id); @@ -141,24 +136,9 @@ export async function handleProviderAdd( modelCount += Object.keys(entry.models).length; } - // Restore the previous defaults only while they still resolve after the - // re-import; a stale id must be cleared rather than persisted. - config.defaultProvider = - previousDefaultProvider !== undefined && config.providers[previousDefaultProvider] !== undefined - ? previousDefaultProvider - : undefined; - config.defaultModel = - previousDefaultModel !== undefined && config.models?.[previousDefaultModel] !== undefined - ? previousDefaultModel - : undefined; - config.defaultThinking = previousDefaultThinking; - await harness.setConfig({ providers: config.providers, models: config.models, - defaultProvider: config.defaultProvider, - defaultModel: config.defaultModel, - defaultThinking: config.defaultThinking, }); deps.stdout.write( @@ -299,9 +279,15 @@ export async function handleCatalogList( for (const [id, entry] of entries) { const modelCount = entry.models === undefined ? 0 : Object.keys(entry.models).length; - const wire = inferWireType(entry) ?? '?'; + const resolution = resolveCatalogImport(entry); + const wireLabel = + resolution.kind === 'invalid' + ? '?' + : resolution.guessed + ? `${resolution.wire} (guessed)` + : resolution.wire; deps.stdout.write( - `${id} wire=${wire} models=${String(modelCount)} ${entry.name ?? ''}\n`, + `${id} wire=${wireLabel} models=${String(modelCount)} ${entry.name ?? ''}\n`, ); } } @@ -316,6 +302,14 @@ export async function handleCatalogAdd( providerId: string, opts: CatalogAddOptions, ): Promise { + const apiKey = resolveApiKey(opts.apiKey, deps.env); + if (apiKey === undefined) { + deps.stderr.write( + 'Missing API key. Pass --api-key or set PYTHINKER_REGISTRY_API_KEY.\n', + ); + deps.exit(1); + } + const url = opts.url ?? DEFAULT_CATALOG_URL; const catalog = await loadCatalogOrExit(deps, url); @@ -325,31 +319,37 @@ export async function handleCatalogAdd( deps.exit(1); } - const wire = catalogConnectionWire(entry); - if (wire === undefined) { - deps.stderr.write(`Provider "${providerId}" cannot be configured with one API key.\n`); + const resolution = resolveCatalogImport(entry, opts.baseUrl); + if (resolution.kind === 'invalid') { + switch (resolution.reason) { + case 'unknown-explicit-type': + deps.stderr.write( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.\n`, + ); + break; + case 'proprietary-sdk': + deps.stderr.write( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.\n`, + ); + break; + case 'empty-base-url': + deps.stderr.write('--base-url cannot be empty.\n'); + break; + case 'placeholder-base-url': + deps.stderr.write( + `Base URL "${opts.baseUrl}" contains an env placeholder. Pass --base-url with the resolved value.\n`, + ); + break; + } deps.exit(1); } - - const literalApiKey = opts.apiKey?.trim(); - const apiKeyEnvVar = (opts.apiKeyEnv ?? entry.env?.[0])?.trim(); - let useEnvVar = false; - if (literalApiKey === undefined || literalApiKey.length === 0) { - if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) { - deps.stderr.write( - `Provider "${providerId}" does not declare an API key environment variable. Pass --api-key .\n`, - ); - deps.exit(1); - } - const envValue = deps.env[apiKeyEnvVar]?.trim(); - if (envValue === undefined || envValue.length === 0) { - deps.stderr.write( - `Environment variable "${apiKeyEnvVar}" is not set or is empty. Set it or pass --api-key .\n`, - ); - deps.exit(1); - } - useEnvVar = true; + if (resolution.kind === 'needs-base-url') { + deps.stderr.write( + `The catalog does not declare an endpoint for "${providerId}". Pass --base-url (e.g. the vendor's OpenAI-compatible base URL).\n`, + ); + deps.exit(1); } + const { wire, baseUrl } = resolution; const models = catalogProviderModels(entry); if (models.length === 0) { @@ -365,24 +365,71 @@ export async function handleCatalogAdd( } const harness = deps.getHarness(); - try { - await importCatalogProvider(harness, { - providerId, - entry, - catalogUrl: url, - apiKey: useEnvVar ? undefined : literalApiKey, - apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, - defaultModel: opts.defaultModel, - }); - } catch (error) { - deps.stderr.write(`${errorMessage(error)}\n`); - deps.exit(1); + await harness.ensureConfigFile(); + + let config = await harness.getConfig(); + + // Capture defaults BEFORE `removeProvider`, because that call clears + // `defaultModel` when it points at one of this provider's aliases (see + // `core-impl.ts removePythinkerProvider`). Without this, re-importing an + // already-configured provider would lose the user's previously-set default + // even when `--default-model` is not supplied. + const previousDefaultModel = config.defaultModel; + const previousThinking = config.thinking; + + if (config.providers[providerId] !== undefined) { + config = await harness.removeProvider(providerId); } + // `applyCatalogProvider` always overwrites both `defaultModel` and + // `[thinking]`. The values we pass here are temporary; we restore + // a consistent state in the post-apply block below. + applyCatalogProvider(config, { + providerId, + wire, + ...(baseUrl === undefined ? {} : { baseUrl }), + apiKey, + models, + selectedModelId: opts.defaultModel ?? '', + thinking: false, + }); + + // Resolve the final `defaultModel`: + // - If the caller asked for one, `applyCatalogProvider` already set it. + // - Else, restore the previous default ONLY when its alias still resolves + // after the catalog refresh; the catalog may have dropped the old + // model, in which case restoring would point default_model at a + // non-existent alias and break the next session. + if (opts.defaultModel === undefined) { + const stillResolves = + previousDefaultModel !== undefined && + config.models?.[previousDefaultModel] !== undefined; + config.defaultModel = stillResolves ? previousDefaultModel : undefined; + } + + // Always restore `[thinking]` from what was there before — including + // `undefined`. Persisting `enabled: false` when the user never set it would + // make `resolveThinkingEffort` (agent-core/src/agent/config/thinking.ts) treat + // it as an explicit "off" request and silently disable thinking, even for + // thinking-capable models. + config.thinking = previousThinking; + + await harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + const displayName = entry.name ?? providerId; deps.stdout.write( `Imported ${displayName} (${providerId}) with ${String(models.length)} model${models.length === 1 ? '' : 's'} from ${url}.\n`, ); + if (resolution.guessed) { + deps.stdout.write( + `Note: the catalog does not declare a protocol for "${providerId}"; guessed "openai". Edit "type" in config.toml if requests fail.\n`, + ); + } if (opts.defaultModel !== undefined) { deps.stdout.write(`Default model set to ${providerId}/${opts.defaultModel}.\n`); } @@ -390,7 +437,13 @@ export async function handleCatalogAdd( async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { try { - return await fetchCatalog(url); + const loaded = await fetchCatalogOrBuiltIn(url, { userAgent: createPythinkerCodeUserAgent() }); + if (loaded.fromBuiltIn) { + deps.stderr.write( + `Warning: failed to reach ${url}; using the built-in models.dev catalog snapshot.\n`, + ); + } + return loaded.catalog; } catch (error) { const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); @@ -407,12 +460,17 @@ export function registerProviderCommand(parent: Command, deps?: Partial Promise): Promise => { + const runAction = async ( + resolved: ResolvedProviderDeps, + run: () => Promise, + ): Promise => { try { await run(); } catch (error) { resolved.stderr.write(`${errorMessage(error)}\n`); resolved.exit(1); + } finally { + await resolved.close(); } }; @@ -471,42 +529,55 @@ export function registerProviderCommand(parent: Command, deps?: Partial') .description('Import a known provider from the catalog by id.') - .option('--api-key ', 'Provider API key to store in config.toml (takes precedence over --api-key-env).') - .option('--api-key-env ', 'Environment variable containing the provider API key.') + .option('--api-key ', 'API key for the provider. Falls back to PYTHINKER_REGISTRY_API_KEY.') .option('--default-model ', 'Mark the imported model as default_model after import.') + .option( + '--base-url ', + 'Override the catalog endpoint. Required when the catalog declares none (or an env placeholder).', + ) .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) .action( async ( providerId: string, - options: { apiKey?: string; apiKeyEnv?: string; defaultModel?: string; url?: string }, + options: { apiKey?: string; defaultModel?: string; url?: string; baseUrl?: string }, ) => { const resolved = resolveDeps(deps); await runAction(resolved, () => handleCatalogAdd(resolved, providerId, { - apiKey: options.apiKey, - apiKeyEnv: options.apiKeyEnv, - defaultModel: options.defaultModel, - url: options.url, + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + ...(options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel }), + ...(options.url === undefined ? {} : { url: options.url }), + ...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }), }), ); }, ); } -function resolveDeps(overrides: Partial = {}): ProviderDeps { +type ResolvedProviderDeps = ProviderDeps & { readonly close: () => Promise }; + +function resolveDeps(overrides: Partial = {}): ResolvedProviderDeps { let harness: PythinkerHarness | undefined; const identity = createPythinkerCodeHostIdentity(); return { getHarness: overrides.getHarness ?? (() => { - harness ??= createPythinkerHarness({ identity }); + // Same engine gate as the TUI's `/provider` flow: the SDK's v2-backed + // harness by default, the legacy agent-core harness when + // PYTHINKER_CODE_LEGACY_FLAG is set. + harness ??= (isPythinkerV2Enabled() ? createPythinkerHarnessV2 : createPythinkerHarness)({ identity }); return harness; }), stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, env: overrides.env ?? process.env, exit: overrides.exit ?? ((code: number) => process.exit(code)), + // The v2 harness boots an engine whose watchers hold the event loop open; + // close it so a one-shot command can exit. No-op for injected harnesses. + close: async () => { + await harness?.close(); + }, }; } @@ -517,8 +588,8 @@ function resolveApiKey(flag: string | undefined, env: NodeJS.ProcessEnv): string return undefined; } -function asManaged(config: PythinkerConfig): PlatformConfigShape { - return config as unknown as PlatformConfigShape; +function asManaged(config: PythinkerConfig): ManagedPythinkerConfigShape { + return config as unknown as ManagedPythinkerConfigShape; } function providerSourceLabel(provider: PythinkerConfig['providers'][string]): string { @@ -527,10 +598,8 @@ function providerSourceLabel(provider: PythinkerConfig['providers'][string]): st if (source['kind'] === 'apiJson' && typeof source['url'] === 'string') { return `apiJson(${source['url']})`; } - if (source['kind'] === 'modelsDev' && typeof source['url'] === 'string') { - return `modelsDev(${source['url']})`; - } } + if (provider.oauth !== undefined) return 'oauth'; return 'inline'; } diff --git a/apps/pythinker-code/src/cli/sub/server/daemon.ts b/apps/pythinker-code/src/cli/sub/server/daemon.ts deleted file mode 100644 index 445b8e48..00000000 --- a/apps/pythinker-code/src/cli/sub/server/daemon.ts +++ /dev/null @@ -1,288 +0,0 @@ -/** - * `pythinker web` daemon orchestration — parent (spawner) side. - * - * Ensures a single background server daemon exists for this device, then - * returns its origin so the caller can open the web UI. The flow: - * - * 1. Read `~/.pythinker-code/server/lock`. If it names a *live* daemon, reuse it - * (wait for it to be healthy) — never spawn a second one. - * 2. Otherwise pick a free port (preferred port when available, else an - * OS-assigned one) and spawn `pythinker server run --daemon` as a detached - * child whose stdio is redirected to the server log. - * 3. Poll the lock until *some* live daemon (ours, or a concurrent racer's - * that won the lock) is healthy, then return its origin. - * - * The child side (`startServerDaemon`) lives in `./run.ts` next to the - * foreground runner so it can share the same bootstrap helpers. - */ - -import { spawn } from 'node:child_process'; -import { appendFileSync, closeSync, mkdirSync, openSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { createServer } from 'node:net'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; - -import { DEFAULT_LOCK_DIR, getLiveLock, type LockContents } from '@pymodel/server'; - -import { - DEFAULT_SERVER_HOST, - DEFAULT_SERVER_PORT, - isServerHealthy, - serverOrigin, - waitForServerHealthy, -} from './shared'; - -const SERVER_LOG_FILENAME = 'server.log'; - -/** How long to wait for an already-running daemon to answer `/healthz`. */ -const REUSE_HEALTH_TIMEOUT_MS = 15_000; -/** How long to wait for a freshly-spawned daemon to come up. */ -const SPAWN_TIMEOUT_MS = 20_000; -/** Poll cadence while waiting for the daemon to appear in the lock + healthz. */ -const POLL_INTERVAL_MS = 200; -/** Default log level for a daemon spawned without an explicit `--log-level`. */ -const DEFAULT_DAEMON_LOG_LEVEL = 'info'; - -export interface EnsureDaemonOptions { - /** Preferred port; on conflict a free port is chosen automatically. */ - port?: number; - /** Pino log level for the spawned daemon (defaults to `info`). */ - logLevel?: string; - /** Mount `/api/v1/debug/*` routes on the spawned daemon. */ - debugEndpoints?: boolean; - /** Idle-shutdown grace in ms for the spawned daemon (daemon mode only). */ - idleGraceMs?: number; -} - -export interface EnsureDaemonResult { - readonly origin: string; -} - -/** Path of the daemon log file (shared with the OS-service log location). */ -export function daemonLogPath(): string { - return join(DEFAULT_LOCK_DIR, SERVER_LOG_FILENAME); -} - -export function lockConnectHost(lock: LockContents): string { - const host = lock.host ?? DEFAULT_SERVER_HOST; - return host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host; -} - -/** True when `host:port` is currently free to bind (nothing listening). */ -function canBind(host: string, port: number): Promise { - return new Promise((resolvePromise) => { - const probe = createServer(); - probe.once('error', () => resolvePromise(false)); - probe.listen({ host, port }, () => { - probe.close(() => resolvePromise(true)); - }); - }); -} - -/** Ask the OS for an ephemeral free port on `host`. */ -function getFreePort(host: string): Promise { - return new Promise((resolvePromise, reject) => { - const probe = createServer(); - probe.once('error', reject); - probe.listen({ host, port: 0 }, () => { - const address = probe.address(); - if (address === null || typeof address === 'string') { - probe.close(() => reject(new Error('failed to allocate a free port'))); - return; - } - const { port } = address; - probe.close(() => resolvePromise(port)); - }); - }); -} - -/** - * How many consecutive `preferred + n` ports to probe before giving up and - * asking the OS for any free port. Mirrors `PORT_RETRY_LIMIT` in the server's - * own bind retry so the spawner and the daemon agree on the policy. - */ -export const DAEMON_PORT_SCAN_LIMIT = 100; - -/** - * Pick a port for a new daemon: prefer `preferred` when it is free, otherwise - * walk `preferred + 1`, `+ 2`, … upward and take the first free one. Only when - * the whole scan window is saturated do we fall back to an OS-assigned free - * port. - * - * Reusing an already-live daemon is handled by `ensureDaemon` before this runs, - * so a busy port here is held by a third-party process — bumping by one (rather - * than jumping to a random ephemeral port) keeps the URL predictable, matching - * the server's own "port busy ⇒ +1" bind retry. - */ -export async function resolveDaemonPort( - host: string = DEFAULT_SERVER_HOST, - preferred: number = DEFAULT_SERVER_PORT, -): Promise { - for ( - let candidate = preferred; - candidate < preferred + DAEMON_PORT_SCAN_LIMIT && candidate <= 65535; - candidate++ - ) { - if (await canBind(host, candidate)) return candidate; - } - return getFreePort(host); -} - -interface NodeSeaModule { - isSea(): boolean; -} - -const nodeRequire = createRequire(import.meta.url); -let cachedSea: NodeSeaModule | null | undefined; - -function loadSeaModule(): NodeSeaModule | null { - if (cachedSea !== undefined) return cachedSea; - try { - cachedSea = nodeRequire('node:sea') as NodeSeaModule; - } catch { - cachedSea = null; - } - return cachedSea; -} - -/** True when running as a compiled single-executable (SEA / native) binary. */ -function detectSea(): boolean { - const sea = loadSeaModule(); - if (sea === null) return false; - try { - return sea.isSea(); - } catch { - return false; - } -} - -/** - * Absolute path to the CLI entry that should be re-execed to run the daemon. - * Mirrors `resolveSupervisorProgram` in `packages/server/src/svc/program.ts`: - * when the CLI is a compiled single binary, `argv[1]` is the invoked command - * name (e.g. `pythinker`) or the first user argument — never a script path — so we - * must re-exec `process.execPath` itself. - */ -export function resolveDaemonProgram( - argv: readonly string[] = process.argv, - cwd: string = process.cwd(), - execPath: string = process.execPath, - isSea: boolean = detectSea(), -): string { - // In a SEA binary `argv[1]` is not a script path, so resolving it against - // `cwd` would produce a bogus path (e.g. `/pythinker`) and crash the spawn - // with ENOENT. Always re-exec the binary itself. - if (isSea) return execPath; - const candidate = argv[1] === 'server' ? execPath : (argv[1] ?? execPath); - return isAbsolute(candidate) ? candidate : resolve(cwd, candidate); -} - -interface SpawnDaemonChildOptions { - port: number; - logLevel: string; - debugEndpoints?: boolean; - idleGraceMs?: number; -} - -export function spawnDaemonChild(options: SpawnDaemonChildOptions): void { - const program = resolveDaemonProgram(); - const logPath = daemonLogPath(); - const logDir = dirname(logPath); - mkdirSync(logDir, { recursive: true }); - const args = [ - 'server', - 'run', - '--daemon', - '--port', - String(options.port), - '--log-level', - options.logLevel, - ]; - if (options.debugEndpoints === true) { - args.push('--debug-endpoints'); - } - if (options.idleGraceMs !== undefined) { - args.push('--idle-grace-ms', String(options.idleGraceMs)); - } - const logFd = openSync(logPath, 'a'); - try { - const child = spawn(program, args, { - detached: true, - // Run from the server log directory instead of inheriting the caller's - // cwd, so the long-lived daemon does not pin the directory it was - // launched from (notably blocking its deletion on Windows). - cwd: logDir, - stdio: ['ignore', logFd, logFd], - }); - child.once('error', (error) => { - // A spawn failure (e.g. ENOENT) surfaces asynchronously on the child, - // not as a thrown error. Without a listener Node would crash the parent - // with an unhandled 'error' event; record it instead and let the polling - // loop in `ensureDaemon` report the timeout. - try { - appendFileSync(logPath, `[spawner] failed to launch daemon: ${error.message}\n`); - } catch { - // Best-effort; the log directory may already be gone. - } - }); - child.unref(); - } finally { - // `spawn` dups the fd into the child; the parent must not keep it open. - closeSync(logFd); - } -} - -function sleep(ms: number): Promise { - return new Promise((resolvePromise) => { - setTimeout(resolvePromise, ms); - }); -} - -/** - * Ensure a daemon is running and return its origin. Non-blocking for the - * caller beyond the short health wait — the server itself keeps running in a - * detached process after this returns. - */ -export async function ensureDaemon(options: EnsureDaemonOptions = {}): Promise { - const preferred = options.port ?? DEFAULT_SERVER_PORT; - const logLevel = options.logLevel ?? DEFAULT_DAEMON_LOG_LEVEL; - - // 1. Reuse an already-live daemon if one holds the lock. - const existing = getLiveLock(); - if (existing) { - const origin = serverOrigin(lockConnectHost(existing), existing.port); - if (await waitForServerHealthy(origin, REUSE_HEALTH_TIMEOUT_MS)) { - return { origin }; - } - // Live pid but not responding (wedged or mid-boot failure). Fall through - // and spawn: if it is truly wedged our child loses the lock race and we - // reconnect below; if it died, stale takeover lets our child claim it. - } - - // 2. No reusable daemon — pick a free port and spawn one detached. - const port = await resolveDaemonPort(DEFAULT_SERVER_HOST, preferred); - spawnDaemonChild({ - port, - logLevel, - debugEndpoints: options.debugEndpoints, - idleGraceMs: options.idleGraceMs, - }); - - // 3. Wait until some live daemon (ours, or a racer that won the lock) is up. - const deadline = Date.now() + SPAWN_TIMEOUT_MS; - while (Date.now() < deadline) { - const live = getLiveLock(); - if (live) { - const origin = serverOrigin(lockConnectHost(live), live.port); - if (await isServerHealthy(origin, 500)) { - return { origin }; - } - } - await sleep(POLL_INTERVAL_MS); - } - - throw new Error( - `Pythinker server daemon failed to start within ${String(SPAWN_TIMEOUT_MS)}ms. ` + - `Check the log for details: ${daemonLogPath()}`, - ); -} diff --git a/apps/pythinker-code/src/cli/sub/server/index.ts b/apps/pythinker-code/src/cli/sub/server/index.ts deleted file mode 100644 index e90a243b..00000000 --- a/apps/pythinker-code/src/cli/sub/server/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * `pythinker server` parent command. Mounts: - * - `server run` (background daemon by default; `--foreground` to attach; the - * detached daemon child runs the same command with `--daemon`) - * - * The OS service-manager subcommands (`install/uninstall/start/stop/restart/ - * status`) are temporarily NOT registered — see the commented - * `addLifecycleCommands(server)` below. Their implementation is preserved in - * `./lifecycle.ts` + `packages/server/src/svc/*` for later re-exposure. - * - * The top-level `pythinker web` alias is registered separately via - * `registerWebAliasCommand` so it stays at the program root. - */ - -import type { Command } from 'commander'; - -import { registerPsCommand } from './ps'; -import { registerKillCommand } from './kill'; -import { buildRunCommand } from './run'; -import { registerWebAliasCommand } from './web-alias'; - -export function registerServerCommand(program: Command): void { - const server = program - .command('server') - .description('Run the local Pythinker server (REST + WebSocket + web UI).'); - - buildRunCommand( - server.command('run').description('Start the Pythinker server (background daemon; use --foreground to attach).'), - { defaultOpen: false }, - ); - - registerPsCommand(server); - - registerKillCommand(server); - - // OS service-manager commands (`install/uninstall/start/stop/restart/status`) - // are temporarily hidden — the product now favors the on-demand background - // daemon (`pythinker web`) over service-ization. The implementation still lives in - // `./lifecycle.ts` + `packages/server/src/svc/*`; re-import - // `addLifecycleCommands` and call it here to re-expose. - // addLifecycleCommands(server); - - registerWebAliasCommand(program); -} - -export { registerWebAliasCommand }; diff --git a/apps/pythinker-code/src/cli/sub/server/kill.ts b/apps/pythinker-code/src/cli/sub/server/kill.ts deleted file mode 100644 index 97286a6f..00000000 --- a/apps/pythinker-code/src/cli/sub/server/kill.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * `pythinker server kill` — terminate the running server. - * - * Combines two independent mechanisms so the server dies even if one path - * fails: - * - * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown - * (best-effort; older builds or a wedged server may not answer). - * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → - * SIGKILL). SIGKILL / TerminateProcess is the hard guarantee: - * it cannot be caught or ignored. - * - * The only honest failure mode is insufficient permissions (a process owned by - * another user), which surfaces as an error rather than a silent miss. - */ - -import type { Command } from 'commander'; - -import { getLiveLock, type LockContents } from '@pymodel/server'; - -import { writeAndDrain } from '../../output'; -import { lockConnectHost } from './daemon'; -import { serverOrigin } from './shared'; - -/** How long to wait for the graceful API shutdown request. */ -const API_TIMEOUT_MS = 2000; -/** Grace period after SIGTERM before escalating to SIGKILL. */ -const TERM_GRACE_MS = 3000; -/** Grace period after SIGKILL before giving up. */ -const KILL_GRACE_MS = 2000; -/** Poll cadence while waiting for the pid to exit. */ -const POLL_INTERVAL_MS = 100; - -export interface KillCommandDeps { - getLiveLock(): LockContents | undefined; - requestShutdown(origin: string): Promise; - signalPid(pid: number, signal: NodeJS.Signals): boolean; - pidAlive(pid: number): boolean; - sleep(ms: number): Promise; - stdout: Pick; - now(): number; -} - -export function registerKillCommand(server: Command): void { - server - .command('kill') - .description('Stop the running Pythinker server (graceful API + forced PID kill).') - .action(async () => { - try { - await handleKillCommand(DEFAULT_KILL_DEPS); - } catch (error) { - try { - await writeAndDrain( - process.stderr, - `${error instanceof Error ? error.message : String(error)}\n`, - ); - } finally { - process.exit(1); - } - } - }); -} - -export async function handleKillCommand(deps: KillCommandDeps): Promise { - const lock = deps.getLiveLock(); - if (!lock) { - deps.stdout.write('No running Pythinker server.\n'); - return; - } - - const { pid } = lock; - const origin = serverOrigin(lockConnectHost(lock), lock.port); - - // 1. API path — best-effort graceful shutdown. Ignore every outcome: the - // server may be an older build without the route, already wedged, or may - // drop the connection as it exits. - await deps.requestShutdown(origin).catch(() => {}); - - // 2. PID path — SIGTERM, wait, then SIGKILL. - deps.signalPid(pid, 'SIGTERM'); - - if (await waitForExit(pid, TERM_GRACE_MS, deps)) { - deps.stdout.write(`Pythinker server (pid ${String(pid)}) stopped.\n`); - return; - } - - deps.signalPid(pid, 'SIGKILL'); - - if (await waitForExit(pid, KILL_GRACE_MS, deps)) { - deps.stdout.write(`Pythinker server (pid ${String(pid)}) killed.\n`); - return; - } - - throw new Error( - `Failed to stop Pythinker server (pid ${String(pid)}); insufficient permissions?`, - ); -} - -async function waitForExit( - pid: number, - timeoutMs: number, - deps: Pick, -): Promise { - const deadline = deps.now() + timeoutMs; - do { - if (!deps.pidAlive(pid)) return true; - await deps.sleep(POLL_INTERVAL_MS); - } while (deps.now() < deadline); - return !deps.pidAlive(pid); -} - -/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ -export function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'ESRCH') return false; - // EPERM = process exists but we can't signal it. Treat as alive. - return true; - } -} - -/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ -export function signalPid(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch { - return false; - } -} - -/** POST the shutdown endpoint; resolves once the request completes or times out. */ -export async function requestShutdownViaApi(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, API_TIMEOUT_MS); - try { - await fetch(`${origin}/api/v1/shutdown`, { - method: 'POST', - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } -} - -const DEFAULT_KILL_DEPS: KillCommandDeps = { - getLiveLock, - requestShutdown: requestShutdownViaApi, - signalPid, - pidAlive, - sleep: (ms) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }), - stdout: process.stdout, - now: () => Date.now(), -}; diff --git a/apps/pythinker-code/src/cli/sub/server/lifecycle.ts b/apps/pythinker-code/src/cli/sub/server/lifecycle.ts deleted file mode 100644 index 222f86fa..00000000 --- a/apps/pythinker-code/src/cli/sub/server/lifecycle.ts +++ /dev/null @@ -1,270 +0,0 @@ -/** - * `pythinker server install/uninstall/start/stop/restart/status`. - * - * Phase 2 lands the CLI shape; the lifecycle calls into the platform service - * manager from `@pymodel/server`, which is filled in by Phase 3+. - * - * The Commander wiring here mirrors `addGatewayServiceCommands` from - * `../openclaw/src/cli/daemon-cli/register-service-commands.ts:58`. - */ - -import type { Command } from 'commander'; - -import { - ServiceUnavailableError, - ServiceUnsupportedError, - resolveServiceManager, - type InstallArgs, - type ServiceManager, - type ServiceStatus, -} from '@pymodel/server'; - -import { drainWritable } from '#/cli/output'; -import { openUrl as defaultOpenUrl } from '#/utils/open-url'; - -import { - DEFAULT_LOG_LEVEL, - DEFAULT_SERVER_HOST, - DEFAULT_SERVER_PORT, - parseLogLevel, - parsePort, - serverOrigin, - VALID_LOG_LEVELS, -} from './shared'; - -export interface InstallCliOptions { - port?: string; - logLevel?: string; - force?: boolean; - open?: boolean; - json?: boolean; -} - -export interface JsonCliOptions { - json?: boolean; -} - -export interface LifecycleCommandDeps { - resolveManager(): ServiceManager; - openUrl(url: string): void; - stdout: Pick; - stderr: Pick; -} - -const DEFAULT_DEPS: LifecycleCommandDeps = { - resolveManager: resolveServiceManager, - openUrl: defaultOpenUrl, - stdout: process.stdout, - stderr: process.stderr, -}; - -/** Mount install/uninstall/start/stop/restart/status under a parent command. */ -export function addLifecycleCommands(parent: Command, deps: LifecycleCommandDeps = DEFAULT_DEPS): void { - parent - .command('install') - .description('Install the Pythinker server as an OS-managed service (launchd/systemd/schtasks).') - .option('--port ', `Bind port (default ${DEFAULT_SERVER_PORT})`, String(DEFAULT_SERVER_PORT)) - .option( - '--log-level ', - `Log level: ${VALID_LOG_LEVELS.join('|')} (default ${DEFAULT_LOG_LEVEL})`, - DEFAULT_LOG_LEVEL, - ) - .option('--force', 'Reinstall and overwrite if already installed', false) - .option('--no-open', 'Do not open the web UI after install.', true) - .option('--json', 'Output JSON', false) - .action(async (opts: InstallCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const args: InstallArgs = { - host: DEFAULT_SERVER_HOST, - port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), - logLevel: parseLogLevel(opts.logLevel), - force: opts.force === true, - }; - const result = await mgr.install(args); - const status = await readStatus(mgr); - const enriched = withStatusDetails({ - ok: true, - action: 'install', - status: result.status, - plistPath: result.plistPath, - unitPath: result.unitPath, - taskName: result.taskName, - message: result.message, - }, status, args); - if (opts.json !== true && opts.open !== false && enriched.running === true && typeof enriched.url === 'string') { - deps.openUrl(enriched.url); - } - return enriched; - }); - }); - - parent - .command('uninstall') - .description('Uninstall the Pythinker server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.uninstall(); - return { ok: result.ok, action: 'uninstall', message: result.message }; - }); - }); - - parent - .command('start') - .description('Start the Pythinker server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.start(); - const status = await readStatus(mgr); - return withStatusDetails({ ok: result.ok, action: 'start', message: result.message }, status); - }); - }); - - parent - .command('stop') - .description('Stop the Pythinker server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.stop(); - return { ok: result.ok, action: 'stop', message: result.message }; - }); - }); - - parent - .command('restart') - .description('Restart the Pythinker server service.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const result = await mgr.restart(); - const status = await readStatus(mgr); - return withStatusDetails({ ok: result.ok, action: 'restart', message: result.message }, status); - }); - }); - - parent - .command('status') - .description('Show Pythinker server service status and connectivity.') - .option('--json', 'Output JSON', false) - .action(async (opts: JsonCliOptions) => { - await runLifecycle(deps, opts.json === true, async (mgr) => { - const status: ServiceStatus = await mgr.status(); - return withStatusDetails({ ok: true, action: 'status', ...status }, status); - }); - }); -} - -async function runLifecycle( - deps: LifecycleCommandDeps, - json: boolean, - body: (mgr: ServiceManager) => Promise>, -): Promise { - try { - const mgr = deps.resolveManager(); - const result = await body(mgr); - if (json) { - deps.stdout.write(`${JSON.stringify(result)}\n`); - return; - } - deps.stdout.write(formatHuman(result)); - } catch (error) { - if (error instanceof ServiceUnavailableError || error instanceof ServiceUnsupportedError) { - const payload = { - ok: false, - action: error instanceof ServiceUnavailableError ? 'unavailable' : 'unsupported', - platform: error.platform, - message: error.message, - }; - if (json) { - deps.stdout.write(`${JSON.stringify(payload)}\n`); - } else { - deps.stderr.write(`${error.message}\n`); - } - await exitLifecycle(deps, 2); - return; - } - if (json) { - deps.stdout.write( - `${JSON.stringify({ ok: false, message: error instanceof Error ? error.message : String(error) })}\n`, - ); - } else { - deps.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - } - await exitLifecycle(deps, 1); - } -} - -/** - * Exit with `code` after flushing any output written to the real process - * streams, so error/result output is not lost when the lifecycle command - * fails. - */ -async function exitLifecycle(deps: LifecycleCommandDeps, code: number): Promise { - const drains: Promise[] = []; - if (deps.stdout === process.stdout) drains.push(drainWritable(process.stdout)); - if (deps.stderr === process.stderr) drains.push(drainWritable(process.stderr)); - try { - await Promise.all(drains); - } finally { - process.exit(code); - } -} - -function formatHuman(result: Record): string { - const rawAction = result['action']; - const action = typeof rawAction === 'string' ? rawAction : 'action'; - const rawMessage = result['message']; - const message = typeof rawMessage === 'string' ? `: ${rawMessage}` : ''; - const lines = [`${action}${message}`]; - - const url = result['url']; - if (typeof url === 'string') lines.push(`URL: ${url}`); - - const running = result['running']; - if (typeof running === 'boolean') lines.push(`Status: ${running ? 'running' : 'not running'}`); - - const logPath = result['logPath']; - if (typeof logPath === 'string') lines.push(`Log: ${logPath}`); - - const notes = result['notes']; - if (Array.isArray(notes)) { - for (const note of notes) { - if (typeof note === 'string' && note.length > 0) lines.push(`Note: ${note}`); - } - } - - return `${lines.join('\n')}\n`; -} - -async function readStatus(mgr: ServiceManager): Promise { - try { - return await mgr.status(); - } catch { - return undefined; - } -} - -function withStatusDetails( - result: Record, - status: ServiceStatus | undefined, - fallback?: { host: string; port: number }, -): Record & { url?: string; running?: boolean } { - const host = status?.host ?? fallback?.host; - const port = status?.port ?? fallback?.port; - const url = host !== undefined && port !== undefined ? formatServiceUrl(host, port) : undefined; - return { - ...result, - url, - running: status?.running, - host, - port, - logPath: status?.logPath, - notes: status?.notes, - }; -} - -function formatServiceUrl(host: string, port: number): string { - return serverOrigin(host === '0.0.0.0' ? DEFAULT_SERVER_HOST : host, port); -} diff --git a/apps/pythinker-code/src/cli/sub/server/ps.ts b/apps/pythinker-code/src/cli/sub/server/ps.ts deleted file mode 100644 index 335bafd7..00000000 --- a/apps/pythinker-code/src/cli/sub/server/ps.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * `pythinker server ps` — list clients currently connected to the running server. - * - * Talks to the running server over HTTP (`GET /api/v1/connections`) using the - * single-instance lock (`~/.pythinker-code/server/lock`) to discover its origin — - * the same way `pythinker web` locates the daemon. - */ - -import chalk from 'chalk'; -import type { Command } from 'commander'; - -import { getLiveLock } from '@pymodel/server'; - -import { writeAndDrain } from '../../output'; -import { lockConnectHost } from './daemon'; -import { isServerHealthy, serverOrigin } from './shared'; - -/** Wire shape of a single connection returned by `GET /api/v1/connections`. */ -interface ConnectionInfo { - id: string; - connected_at: string; - remote_address: string | null; - user_agent: string | null; - has_client_hello: boolean; - subscriptions: string[]; -} - -interface ConnectionsEnvelope { - code: number; - msg: string; - data?: { connections?: ConnectionInfo[] }; -} - -const HEALTH_TIMEOUT_MS = 1500; -const FETCH_TIMEOUT_MS = 5000; -const USER_AGENT_MAX_WIDTH = 40; - -export function registerPsCommand(server: Command): void { - server - .command('ps') - .description('List clients currently connected to the running Pythinker server.') - .option('--json', 'Print the raw connection list as JSON.') - .action(async (opts: { json?: boolean }) => { - try { - await handlePsCommand(opts); - } catch (error) { - try { - await writeAndDrain( - process.stderr, - `${error instanceof Error ? error.message : String(error)}\n`, - ); - } finally { - process.exit(1); - } - } - }); -} - -async function handlePsCommand(opts: { json?: boolean }): Promise { - const lock = getLiveLock(); - if (!lock) { - throw new Error( - 'No running Pythinker server. Start one with `pythinker server run` or `pythinker web`.', - ); - } - - const origin = serverOrigin(lockConnectHost(lock), lock.port); - if (!(await isServerHealthy(origin, HEALTH_TIMEOUT_MS))) { - throw new Error(`Pythinker server at ${origin} is not responding.`); - } - - const connections = await fetchConnections(origin); - - if (opts.json) { - process.stdout.write(`${JSON.stringify(connections, null, 2)}\n`); - return; - } - process.stdout.write(formatTable(connections)); -} - -async function fetchConnections(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, FETCH_TIMEOUT_MS); - try { - const res = await fetch(`${origin}/api/v1/connections`, { - signal: controller.signal, - }); - if (!res.ok) { - throw new Error(`Failed to list clients: HTTP ${String(res.status)} from ${origin}.`); - } - const body = (await res.json()) as ConnectionsEnvelope; - if (body.code !== 0) { - throw new Error(`Failed to list clients: ${body.msg}`); - } - return body.data?.connections ?? []; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw new Error(`Timed out listing clients from ${origin}.`, { cause: error }); - } - throw error; - } finally { - clearTimeout(timeout); - } -} - -function formatTable(connections: ConnectionInfo[]): string { - if (connections.length === 0) { - return 'No active clients.\n'; - } - - const header = ['ID', 'CONNECTED', 'REMOTE', 'USER_AGENT', 'SESSIONS', 'HELLO']; - const rows = connections.map((c) => [ - c.id, - formatAge(c.connected_at), - c.remote_address ?? '-', - truncate(c.user_agent ?? '-', USER_AGENT_MAX_WIDTH), - String(c.subscriptions.length), - c.has_client_hello ? 'yes' : 'no', - ]); - - const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]!.length))); - const formatRow = (cells: string[]): string => - cells.map((cell, i) => cell + ' '.repeat(Math.max(0, widths[i]! - cell.length))).join(' '); - - const lines = [chalk.bold(formatRow(header)), ...rows.map(formatRow)]; - return `${lines.join('\n')}\n`; -} - -function formatAge(iso: string): string { - const ms = Date.now() - Date.parse(iso); - if (!Number.isFinite(ms) || ms < 0) return '-'; - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${String(seconds)}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${String(minutes)}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${String(hours)}h`; - const days = Math.floor(hours / 24); - return `${String(days)}d`; -} - -function truncate(value: string, max: number): string { - if (value.length <= max) return value; - if (max <= 1) return value.slice(0, max); - return `${value.slice(0, max - 1)}…`; -} diff --git a/apps/pythinker-code/src/cli/sub/server/run.ts b/apps/pythinker-code/src/cli/sub/server/run.ts deleted file mode 100644 index 1509f37d..00000000 --- a/apps/pythinker-code/src/cli/sub/server/run.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * `pythinker server run` — starts the local server. - * - * By default this ensures a single background daemon is running (spawning a - * detached `pythinker server run --daemon` child when needed) and returns once it is - * healthy. Pass `--foreground` to run the server in-process and keep this - * terminal attached until SIGINT/SIGTERM. OS-managed background operation - * (launchd / systemd / schtasks) lives in `pythinker server install` + `pythinker server start`. - * - * `pythinker web` is an alias of this command with `--open` defaulted to `true`, - * registered in `./web-alias.ts`. - */ - -import { join } from 'node:path'; - -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import { shutdownTelemetry, track } from '@pymodel/pythinker-telemetry'; -import { startServer, type RunningServer } from '@pymodel/server'; -import chalk from 'chalk'; -import { Option, type Command } from 'commander'; - -import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_UI_MODE } from '#/constant/app'; -import { getNativeWebAssetsDir } from '#/native/web-assets'; -import { - buildLogoHeaderRows, - PYTHINKER_LOGO_COLORS, - PYTHINKER_LOGO_WIDTH, - renderPythinkerLogo, - renderPythinkerLogoLine, -} from '#/tui/components/chrome/pythinker-logo'; -import { darkColors } from '#/tui/theme/colors'; -import { openUrl as defaultOpenUrl } from '#/utils/open-url'; - -import { drainWritable, writeAndDrain } from '../../output'; -import { initializeServerTelemetry } from '../../telemetry'; -import { createPythinkerCodeHostIdentity, getHostPackageRoot, getVersion } from '../../version'; -import { ensureDaemon } from './daemon'; -import { - DEFAULT_FOREGROUND_LOG_LEVEL, - DEFAULT_SERVER_PORT, - parseServerOptions, - VALID_LOG_LEVELS, - type ParsedServerOptions, - type ServerCliOptions, -} from './shared'; - -const WEB_ASSETS_DIR = 'dist-web'; -const READY_PANEL_WIDTH = 72; - -export interface RunCliOptions extends ServerCliOptions { - open?: boolean; - /** Run the server in-process instead of spawning a background daemon. */ - foreground?: boolean; -} - -export interface StartForegroundHooks { - /** Fires once the server is listening, before the foreground runner blocks. */ - onReady?: (origin: string) => void; -} - -export interface ServerSignalProcess { - on(signal: NodeJS.Signals, listener: () => void): unknown; - off(signal: NodeJS.Signals, listener: () => void): unknown; - exit(code?: number): never | void; -} - -/** - * Conventional exit status for a shutdown reason: 128 + signal number, - * and 0 for the daemon's idle self-termination. - */ -type ServerShutdownReason = NodeJS.Signals | 'idle'; - -export function serverShutdownExitCode(reason: ServerShutdownReason): number { - if (reason === 'idle') return 0; - if (reason === 'SIGHUP') return 129; - if (reason === 'SIGINT') return 130; - if (reason === 'SIGTERM') return 143; - return 1; -} - -export interface RunCommandDeps { - startServerBackground(options: ParsedServerOptions): Promise<{ origin: string }>; - /** Foreground runner; defaults to the real in-process runner when omitted. */ - startServerForeground?: ( - options: ParsedServerOptions, - hooks?: StartForegroundHooks, - ) => Promise; - openUrl(url: string): void; - stdout: Pick; - stderr: Pick; -} - -/** Build the `run` subcommand, mounted under a parent (`server` or top-level). */ -export function buildRunCommand(cmd: Command, options: { defaultOpen: boolean }): Command { - return cmd - .option( - '--port ', - `Bind port (default ${DEFAULT_SERVER_PORT})`, - String(DEFAULT_SERVER_PORT), - ) - .option( - '--log-level ', - `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, - ) - .option( - '--debug-endpoints', - 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', - false, - ) - .option( - '--foreground', - 'Run the server in the foreground and keep this terminal attached until SIGINT/SIGTERM (do not daemonize).', - false, - ) - .option( - options.defaultOpen ? '--no-open' : '--open', - options.defaultOpen - ? 'Do not open the web UI in the default browser.' - : 'Open the web UI in the default browser once the server is healthy.', - options.defaultOpen, - ) - .addOption( - new Option('--daemon', 'Run as an idle-exiting background daemon (internal).').hideHelp(), - ) - .addOption( - new Option( - '--idle-grace-ms ', - 'Idle-shutdown grace in ms (daemon mode, internal).', - ).hideHelp(), - ) - .action(async (opts: RunCliOptions) => { - try { - await handleRunCommand(opts); - } catch (error) { - try { - await writeAndDrain( - process.stderr, - `${error instanceof Error ? error.message : String(error)}\n`, - ); - } finally { - // Errors that declare an exit code mean it: ServerLockedError uses 2 so - // callers can tell a single-instance conflict from a generic failure. - const declared = (error as { readonly exitCode?: unknown }).exitCode; - process.exit(typeof declared === 'number' ? declared : 1); - } - } - }); -} - -export async function handleRunCommand( - opts: RunCliOptions, - deps: RunCommandDeps = DEFAULT_RUN_COMMAND_DEPS, -): Promise { - const parsed = parseServerOptions(opts); - if (parsed.daemon) { - await startServerDaemon(parsed); - return; - } - const startedAt = Date.now(); - if (opts.foreground === true) { - const run = deps.startServerForeground ?? startServerForeground; - await run(parsed, { - onReady: (origin) => { - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Pythinker server: ${origin}\n`, - ); - if (opts.open === true) { - deps.openUrl(origin); - } - }, - }); - return; - } - const { origin } = await deps.startServerBackground(parsed); - const readyMs = Date.now() - startedAt; - deps.stdout.write( - parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL - ? formatReadyBanner(origin, readyMs) - : `Pythinker server: ${origin}\n`, - ); - if (opts.open === true) { - deps.openUrl(origin); - } -} - -/** - * `pythinker server run` (non-daemon) — ensures a background daemon is running - * (spawning a detached `pythinker server run --daemon` child if needed), then - * returns its origin so the caller can print the ready banner and exit. The - * server keeps running in the background after this returns. - */ -export async function startServerBackground( - options: ParsedServerOptions, -): Promise<{ origin: string }> { - const { origin } = await ensureDaemon({ - port: options.port, - logLevel: options.logLevel, - debugEndpoints: options.debugEndpoints, - idleGraceMs: options.idleGraceMs, - }); - return { origin }; -} - -/** - * `pythinker server run --daemon` — runs the local server as a background daemon. - * - * Spawned as a detached child by {@link startServerBackground}. The process is - * expected to be detached (no controlling terminal) and self-terminates after - * the last web client disconnects and a grace period elapses. The grace timer - * is driven by the WS connection count reported through `wsGatewayOptions`. - * Resolves only via `process.exit`. - */ -export async function startServerDaemon(options: ParsedServerOptions): Promise { - return runServerInProcess(options, { daemon: true }); -} - -/** - * `pythinker server run --foreground` — runs the local server in-process, attached - * to the current terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). - */ -export async function startServerForeground( - options: ParsedServerOptions, - hooks: StartForegroundHooks = {}, -): Promise { - return runServerInProcess(options, { daemon: false }, hooks.onReady); -} - -/** - * Route termination signals into a single graceful shutdown. The first - * signal invokes `shutdown`; a second signal while shutdown is still - * pending unregisters the handlers and exits immediately with the - * second signal's conventional code, so a stuck close cannot hang the - * process forever. Returns a function that removes the handlers. - */ -export function installServerTerminationHandlers( - shutdown: (signal: NodeJS.Signals) => void, - serverProcess: ServerSignalProcess = process, -): () => void { - let terminationSignal: NodeJS.Signals | undefined; - let removed = false; - const handlers = new Map void>(); - const remove = (): void => { - if (removed) return; - removed = true; - for (const [signal, handler] of handlers) serverProcess.off(signal, handler); - }; - const handleSignal = (signal: NodeJS.Signals): void => { - if (terminationSignal === undefined) { - terminationSignal = signal; - shutdown(signal); - return; - } - remove(); - serverProcess.exit(serverShutdownExitCode(signal)); - }; - for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { - const handler = (): void => { - handleSignal(signal); - }; - handlers.set(signal, handler); - serverProcess.on(signal, handler); - } - return remove; -} - -/** - * Start the server in the current process and block until shutdown. Shared by - * the detached daemon (`daemon: true`, with idle-exit) and the foreground - * runner (`daemon: false`). `onReady` fires once the server is listening. - */ -async function runServerInProcess( - options: ParsedServerOptions, - mode: { daemon: boolean }, - onReady?: (origin: string) => void, -): Promise { - const version = getVersion(); - const telemetry = initializeServerTelemetry({ version }); - - let running: RunningServer | undefined; - let stopping = false; - let shutdownCode = 0; - let removeTerminationHandlers = (): void => {}; - - const idle = mode.daemon - ? createIdleShutdownHandler({ - graceMs: options.idleGraceMs, - onIdle: () => { - void shutdown('idle'); - }, - }) - : undefined; - - async function shutdown(reason: ServerShutdownReason): Promise { - if (reason !== 'idle') shutdownCode = serverShutdownExitCode(reason); - if (stopping) return; - stopping = true; - idle?.cancel(); - running?.logger.info({ reason }, 'server shutting down'); - try { - try { - await running?.close(); - } finally { - await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); - } - } catch (error) { - running?.logger.error( - { err: error instanceof Error ? error : new Error(String(error)) }, - 'server shutdown error', - ); - } - try { - await Promise.all([ - drainWritable(process.stdout), - drainWritable(process.stderr), - ]); - } finally { - removeTerminationHandlers(); - process.exit(shutdownCode); - } - } - - removeTerminationHandlers = installServerTerminationHandlers((signal) => { - void shutdown(signal); - }); - - try { - running = await startServer({ - host: options.host, - port: options.port, - logLevel: options.logLevel, - debugEndpoints: options.debugEndpoints, - webAssetsDir: serverWebAssetsDir(), - coreProcessOptions: { - identity: createPythinkerCodeHostIdentity(version), - telemetry, - }, - wsGatewayOptions: { - telemetry, - onConnectionCountChange: idle - ? (size) => { - idle.onConnectionCountChange(size); - } - : undefined, - }, - }); - } catch (error) { - removeTerminationHandlers(); - throw error; - } - - track('server_started', { ui_mode: WEB_UI_MODE, daemon: mode.daemon }); - - const readyFields = mode.daemon - ? { address: running.address, idleGraceMs: options.idleGraceMs } - : { address: running.address }; - running.logger.info(readyFields, mode.daemon ? 'daemon ready' : 'server ready'); - - onReady?.(running.address); - - return new Promise(() => { - // Keeps the event loop alive; the process ends via shutdown()/process.exit. - }); -} - -/** - * Pure idle-shutdown state machine, exported for tests. - * - * Watches the live WS connection count and fires `onIdle` exactly once, after - * the count has dropped back to zero for `graceMs` ms *and* at least one - * client had connected since startup. A reconnect before the grace elapses - * cancels the pending exit. The initial "no clients yet" state never arms the - * timer (so a freshly-spawned daemon is not killed before anyone connects). - */ -export function createIdleShutdownHandler(opts: { graceMs: number; onIdle: () => void }): { - onConnectionCountChange(size: number): void; - cancel(): void; -} { - let timer: NodeJS.Timeout | undefined; - let seenClient = false; - - const cancel = (): void => { - if (timer !== undefined) { - clearTimeout(timer); - timer = undefined; - } - }; - - return { - onConnectionCountChange(size: number): void { - if (size > 0) { - seenClient = true; - cancel(); - return; - } - if (seenClient) { - cancel(); - timer = setTimeout(opts.onIdle, opts.graceMs); - } - }, - cancel, - }; -} - -function serverWebAssetsDir(): string { - return resolveServerWebAssetsDir(); -} - -export function resolveServerWebAssetsDir( - nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), -): string { - return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); -} - -function formatReadyBanner(origin: string, readyMs: number): string { - const primary = (text: string): string => chalk.hex(darkColors.primary)(text); - const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); - const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); - const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); - const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); - const accent = (text: string): string => chalk.hex(PYTHINKER_LOGO_COLORS.accent)(text); - const url = chalk.hex(darkColors.accent)(displayOrigin(origin)); - const width = READY_PANEL_WIDTH; - const innerWidth = width - 4; - const pad = ' '; - const gap = ' '; - const minSideBySideInner = PYTHINKER_LOGO_WIDTH + gap.length + 20; - - const sideText = { - eyebrow: accent('◉ ') + muted('PYTHINKER CODE'), - title: title('Pythinker server ready'), - tagline: dim('Local web UI is available from this machine.'), - prompt: '', - }; - - const headerLines = - innerWidth >= minSideBySideInner - ? buildLogoHeaderRows( - Math.max(4, innerWidth - PYTHINKER_LOGO_WIDTH - gap.length), - sideText, - (index) => renderPythinkerLogoLine(index), - ) - : [ - ...renderPythinkerLogo(), - '', - sideText.eyebrow, - sideText.title, - sideText.tagline, - ].map((line) => truncateToWidth(line, innerWidth, '…')); - - const infoLines = [ - label('URL: ') + url, - label('Network: ') + muted('local only'), - label('Logs: ') + muted('off') + dim(' use --log-level info to enable'), - label('Stop: ') + muted('pythinker server kill'), - label('Ready: ') + muted(`${String(Math.max(0, readyMs))} ms`), - label('Version: ') + muted(getVersion()), - ]; - const contentLines = ['', ...headerLines, '', ...infoLines]; - - const lines = [ - '', - primary('╭' + '─'.repeat(width - 2) + '╮'), - primary('│') + ' '.repeat(width - 2) + primary('│'), - ]; - - for (const content of contentLines) { - const truncated = truncateToWidth(content, innerWidth, '…'); - const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); - } - - lines.push(primary('│') + ' '.repeat(width - 2) + primary('│'), primary('╰' + '─'.repeat(width - 2) + '╯'), ''); - return lines.join('\n'); -} - -function displayOrigin(origin: string): string { - return origin.endsWith('/') ? origin : `${origin}/`; -} - -const DEFAULT_RUN_COMMAND_DEPS: RunCommandDeps = { - startServerBackground, - startServerForeground, - openUrl: defaultOpenUrl, - stdout: process.stdout, - stderr: process.stderr, -}; diff --git a/apps/pythinker-code/src/cli/sub/server/shared.ts b/apps/pythinker-code/src/cli/sub/server/shared.ts deleted file mode 100644 index 37474f1b..00000000 --- a/apps/pythinker-code/src/cli/sub/server/shared.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Shared helpers for `pythinker server …` subcommands. - * - * Owns the default host/port, option parsers, and health/readiness probes that - * `run`, `web`, and `status` all use. - */ - -import type { ServerLogLevel } from '@pymodel/server'; - -export const DEFAULT_SERVER_HOST = '127.0.0.1'; -export const DEFAULT_SERVER_PORT = 58627; -export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); - -export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; -export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; - -/** - * Default idle-shutdown grace for the background daemon: once the last web - * client disconnects, the daemon waits this long before exiting. Overridable - * via the internal `--idle-grace-ms` flag (used by tests). - */ -export const DEFAULT_IDLE_GRACE_MS = 60_000; - -export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ - 'fatal', - 'error', - 'warn', - 'info', - 'debug', - 'trace', - 'silent', -]; - -export interface ParsedServerOptions { - host: string; - port: number; - logLevel: ServerLogLevel; - debugEndpoints: boolean; - /** Internal: run as an idle-exiting background daemon instead of foreground. */ - daemon: boolean; - /** Internal: idle-shutdown grace in ms (daemon mode only). */ - idleGraceMs: number; -} - -export interface ServerCliOptions { - host?: string; - port?: string; - logLevel?: string; - debugEndpoints?: boolean; - /** Internal flag set by the daemon spawner (`pythinker web`). */ - daemon?: boolean; - /** Internal flag set by the daemon spawner / tests. */ - idleGraceMs?: string; -} - -export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { - return { - host: opts.host ?? DEFAULT_SERVER_HOST, - port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), - logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), - debugEndpoints: opts.debugEndpoints === true, - daemon: opts.daemon === true, - idleGraceMs: parseIdleGraceMs(opts.idleGraceMs), - }; -} - -function parseIdleGraceMs(raw: string | undefined): number { - if (raw === undefined) return DEFAULT_IDLE_GRACE_MS; - const n = Number.parseInt(raw, 10); - if (!Number.isFinite(n) || n < 0) { - throw new Error(`error: invalid --idle-grace-ms value: ${raw}`); - } - return n; -} - -export function parsePort(raw: string | undefined, label: string, fallback: number): number { - if (raw === undefined) return fallback; - const n = Number.parseInt(raw, 10); - if (!Number.isFinite(n) || n < 0 || n > 65535) { - throw new Error(`error: invalid ${label} value: ${raw}`); - } - return n; -} - -export function parseLogLevel(raw: string | undefined): ServerLogLevel { - if (raw === undefined) return DEFAULT_LOG_LEVEL; - if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { - return raw as ServerLogLevel; - } - throw new Error( - `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, - ); -} - -export function serverOrigin(host: string, port: number): string { - return `http://${host}:${port}`; -} - -/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ -export function normalizeServerOrigin(value: string): string { - const url = new URL(value); - url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); - url.search = ''; - url.hash = ''; - return url.toString().replace(/\/$/, ''); -} - -/** Single probe of `/api/v1/healthz`. Returns true if the response envelope reports `code: 0`. */ -export async function isServerHealthy(origin: string, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, timeoutMs); - try { - const response = await fetch(`${origin}/api/v1/healthz`, { - signal: controller.signal, - }); - if (!response.ok) return false; - const body = (await response.json()) as { code?: unknown }; - return body.code === 0; - } catch { - return false; - } finally { - clearTimeout(timeout); - } -} - -/** Poll `/api/v1/healthz` until it reports healthy or `timeoutMs` elapses. */ -export async function waitForServerHealthy(origin: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - do { - if (await isServerHealthy(origin, 500)) { - return true; - } - await new Promise((resolve) => { - setTimeout(resolve, 200); - }); - } while (Date.now() < deadline); - return false; -} - -/** - * Probe `/` and confirm the bundled web UI is being served. - * - * A different build that runs on the same port serves its own bundle — opening - * a browser at that origin lands on stale code. Catching that here lets the - * caller surface a clear "stop the running server" message instead of silently - * handing the user the wrong UI. - */ -export async function ensureServerWebReady(origin: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, 3000); - try { - const response = await fetch(`${origin}/`, { - headers: { accept: 'text/html' }, - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - const body = await response.text(); - if (!body.includes('
Promise; + ) => Promise; readonly promptForInstallChoice: ( options: InstallPromptOptions, ) => Promise; - readonly readUpdateInstallState: () => Promise; - readonly writeUpdateInstallState: (state: UpdateInstallState) => Promise; - readonly tryAcquireUpdateInstallLock: ( - request: UpdateInstallLockRequest, - ) => Promise; readonly platform: NodeJS.Platform; readonly stdout: WritableLike; readonly stderr: WritableLike; @@ -102,20 +85,6 @@ export async function handleUpgrade( } const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); - // A native install consumes the manifest's platform artifact; without one - // the update cannot succeed, so take the same exit as being up to date. - if (!isTargetInstallable(source, cache.manifest)) { - trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { - current_version: currentVersion, - }); - logUpgradeInfo(deps.logger, 'manual upgrade no update', { - currentVersion, - }); - deps.stdout.write( - `${formatDisplayVersion(target.version)} is published but has no build for this platform yet.\n`, - ); - return 0; - } const installCommand = installCommandFor(source, target.version, deps.platform); if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { @@ -162,37 +131,13 @@ export async function handleUpgrade( return 0; } - // The foreground install holds the update-install lock for its whole run: - // another live installer (usually a detached background one) must never be - // raced by this path, which writes the same executable. A fresh active - // record or a held lock means an install is already in flight — refuse. - const installState = await deps.readUpdateInstallState().catch(() => emptyUpdateInstallState()); - if (hasFreshActiveInstall(installState)) { - return refuseForegroundInstall(deps, currentVersion, target, source, installState.active?.version); - } - const lock = await deps.tryAcquireUpdateInstallLock({ version: target.version }); - if (lock === null) { - return refuseForegroundInstall(deps, currentVersion, target, source, undefined); - } - try { trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { current_version: currentVersion, target_version: target.version, source, }); - const outcome = await deps.installUpdate(source, target.version, deps.platform); - await deps.writeUpdateInstallState({ - ...installState, - active: null, - lastFailure: null, - lastSuccess: { - version: target.version, - installedAt: nowIso(), - notifiedAt: null, - unverified: outcome.unverified, - }, - }).catch(() => {}); + await deps.installUpdate(source, target.version, deps.platform); trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { current_version: currentVersion, target_version: target.version, @@ -206,18 +151,6 @@ export async function handleUpgrade( deps.stdout.write(renderInstallSuccessMessage(target)); return 0; } catch (error) { - const attempts = failureAttemptsFor(installState, target, 'install') + 1; - await deps.writeUpdateInstallState({ - ...installState, - active: null, - lastFailure: { - version: target.version, - failedAt: nowIso(), - attempts, - operation: 'install', - message: formatErrorMessage(error), - }, - }).catch(() => {}); trackUpgradeEvent(deps.track, 'upgrade_command_failed', { current_version: currentVersion, target_version: target.version, @@ -236,8 +169,6 @@ export async function handleUpgrade( `${formatErrorMessage(error)}\n`, ); return 1; - } finally { - await lock.release().catch(() => {}); } } @@ -247,9 +178,6 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), installUpdate: overrides.installUpdate ?? installUpdateForeground, promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, - readUpdateInstallState: overrides.readUpdateInstallState ?? (() => readUpdateInstallState()), - writeUpdateInstallState: overrides.writeUpdateInstallState ?? writeUpdateInstallState, - tryAcquireUpdateInstallLock: overrides.tryAcquireUpdateInstallLock ?? tryAcquireUpdateInstallLock, platform: overrides.platform ?? process.platform, stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, @@ -263,39 +191,6 @@ function formatDisplayVersion(version: string): string { return version.startsWith('v') ? version : `v${version}`; } -function nowIso(): string { - return new Date().toISOString(); -} - -/** - * Refuse the foreground install because another install is already in - * flight. The active-record case names the version being installed; the - * lock-held case cannot know it, so the message stays generic. - */ -function refuseForegroundInstall( - deps: UpgradeDeps, - currentVersion: string, - target: UpdateTarget, - source: InstallSource, - activeVersion: string | undefined, -): number { - trackUpgradeEvent(deps.track, 'upgrade_command_failed', { - current_version: currentVersion, - target_version: target.version, - source, - stage: 'install', - reason: 'another update install is already in progress', - }); - const suffix = activeVersion === undefined - ? '' - : ` (${formatDisplayVersion(activeVersion)})`; - deps.stderr.write( - `error: another update install is already in progress${suffix}; ` + - 'try again once it finishes.\n', - ); - return 1; -} - function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/apps/pythinker-code/src/cli/sub/vis.ts b/apps/pythinker-code/src/cli/sub/vis.ts new file mode 100644 index 00000000..a7cff4fa --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/vis.ts @@ -0,0 +1,158 @@ +/** + * `pythinker vis` sub-command. + * + * CLI glue only: resolves the pythinker home, starts the in-process session + * visualizer server (auto-picking a free port by default), prints the URL, + * optionally opens the browser (with an optional session deep-link), then + * waits for Ctrl-C and shuts the server down. The visualizer server itself + * lives in `@pymodel/vis-server`. + */ + +import type { Command } from 'commander'; + +import { createCliTelemetryBootstrap } from '#/cli/telemetry'; +import { openUrl } from '#/utils/open-url'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface StartedVisServer { + readonly port: number; + readonly host: string; + readonly url: string; + readonly close: () => Promise; +} + +export interface StartVisServerArgs { + readonly homeDir: string; + readonly port: number; + readonly host?: string; + readonly webAsset?: { gzipped: Uint8Array }; +} + +export interface VisDeps { + readonly getHomeDir: () => string; + readonly startVisServer: (opts: StartVisServerArgs) => Promise; + readonly openUrl: (url: string) => Promise; + readonly waitForShutdown: () => Promise; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +export interface VisOptions { + readonly open: boolean; + readonly port?: number; + readonly host?: string; + readonly sessionId?: string; +} + +export async function handleVis(deps: VisDeps, opts: VisOptions): Promise { + const homeDir = deps.getHomeDir(); + + // Lazily load the embedded single-file SPA so normal `pythinker` startup never + // pays for it. The module is generated at build time (prebuild). When running + // from source without a build — e.g. tests — the generated value module is + // absent and the dynamic import throws; in that case the server falls back to + // its own static `public/` directory. + let webAsset: { gzipped: Uint8Array } | undefined; + try { + const { VIS_WEB_GZIP_B64 } = await import('#/generated/vis-web-asset'); + if (VIS_WEB_GZIP_B64.length > 0) { + webAsset = { gzipped: new Uint8Array(Buffer.from(VIS_WEB_GZIP_B64, 'base64')) }; + } + } catch { + // Embedded asset not generated in this context — fall back to filesystem. + } + + let server: StartedVisServer; + try { + server = await deps.startVisServer({ + homeDir, + port: opts.port ?? 0, + ...(opts.host === undefined ? {} : { host: opts.host }), + ...(webAsset === undefined ? {} : { webAsset }), + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + deps.stderr.write(`Failed to start pythinker vis: ${msg}\n`); + return deps.exit(1); + } + + const target = + opts.sessionId === undefined + ? server.url + : `${server.url}sessions/${encodeURIComponent(opts.sessionId)}`; + + deps.stdout.write(`pythinker vis is running at ${server.url}\n`); + deps.stdout.write('Press Ctrl-C to stop.\n'); + + if (opts.open) { + try { + await deps.openUrl(target); + } catch { + deps.stderr.write(`Could not open a browser; visit ${target} manually.\n`); + } + } + + await deps.waitForShutdown(); + await server.close(); +} + +export function registerVisCommand(parent: Command, overrides?: Partial): void { + parent + .command('vis') + .description('Launch the session visualizer in your browser.') + .option('--port ', 'Port to bind. Default: auto-pick a free port.') + .option('--host ', 'Host to bind. Default: 127.0.0.1.') + .option('--no-open', 'Do not open the browser automatically.') + .argument('[sessionId]', 'Open directly to this session.') + .action( + async ( + sessionId: string | undefined, + options: { port?: string; host?: string; open?: boolean }, + ) => { + const port = options.port === undefined ? undefined : Number.parseInt(options.port, 10); + await handleVis(createDefaultVisDeps(overrides), { + open: options.open !== false, + ...(port === undefined || Number.isNaN(port) ? {} : { port }), + ...(options.host === undefined ? {} : { host: options.host }), + ...(sessionId === undefined ? {} : { sessionId }), + }); + }, + ); +} + +function createDefaultVisDeps(overrides: Partial = {}): VisDeps { + return { + getHomeDir: overrides.getHomeDir ?? (() => createCliTelemetryBootstrap().homeDir), + startVisServer: + overrides.startVisServer ?? + (async (opts) => { + // Dynamic import keeps the vis server (and Hono) out of the hot path. + const { startVisServer } = await import('@pymodel/vis-server/start'); + return startVisServer(opts); + }), + // `openUrl` is a synchronous fire-and-forget; adapt it to the async dep. + openUrl: + overrides.openUrl ?? + (async (url: string) => { + openUrl(url); + }), + waitForShutdown: overrides.waitForShutdown ?? waitForSigint, + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function waitForSigint(): Promise { + return new Promise((resolve) => { + const onSig = (): void => { + process.off('SIGINT', onSig); + resolve(); + }; + process.on('SIGINT', onSig); + }); +} diff --git a/apps/pythinker-code/src/cli/sub/web/access-urls.ts b/apps/pythinker-code/src/cli/sub/web/access-urls.ts new file mode 100644 index 00000000..1875d418 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/access-urls.ts @@ -0,0 +1,85 @@ +/** + * Build the clickable/copyable access URLs for the running server. + * + * Shared by the `pythinker web` ready banner and `pythinker web rotate-token` so both + * show the same Local/Network links. When a token is known it rides in the + * `#token=` fragment (never sent to the server, so never logged), letting a + * user open the link on another device and be authenticated automatically. + */ + +import { formatHostForUrl, listNetworkAddresses, type NetworkAddress } from './networks'; + +/** + * Build a directly-openable server URL. When the token is known it is appended + * as `#token=`; otherwise the bare origin (with a trailing slash) is + * returned. + */ +export function buildOpenableUrl(bareOrigin: string, token: string | undefined): string { + const base = bareOrigin.endsWith('/') ? bareOrigin.slice(0, -1) : bareOrigin; + return token === undefined ? `${base}/` : `${base}/#token=${token}`; +} + +/** + * Split a full URL into the part before `#token=` and the `#token=…` fragment + * itself, so callers can render the fragment in a de-emphasized color. Returns + * `[fullUrl, '']` when there is no token fragment. + */ +export function splitTokenFragment(fullUrl: string): [string, string] { + const marker = '#token='; + const idx = fullUrl.indexOf(marker); + return idx < 0 ? [fullUrl, ''] : [fullUrl.slice(0, idx), fullUrl.slice(idx)]; +} + +export interface AccessUrlLine { + /** Fixed-width label including trailing padding, e.g. `"Local: "`. */ + label: string; + /** Full URL, carrying `#token=` when a token is known. */ + url: string; +} + +function isWildcard(host: string): boolean { + return host === '' || host === '0.0.0.0' || host === '::'; +} + +/** True when `host` is a loopback address (this host only). */ +export function isLoopbackHost(host: string): boolean { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +function hostOrigin(host: string, port: number): string { + const family = host.includes(':') ? 'IPv6' : 'IPv4'; + return `http://${formatHostForUrl(host, family)}:${port}`; +} + +/** + * Compute the access-URL lines for a bind host/port. + * + * - wildcard (`0.0.0.0` / `::` / empty): a `Local:` line (localhost) plus one + * `Network:` line per non-loopback interface. + * - loopback: a single `Local:` line. + * - specific host: a single `URL:` line. + */ +export function accessUrlLines( + host: string, + port: number, + token: string | undefined, + networkAddresses?: NetworkAddress[], +): AccessUrlLine[] { + if (isWildcard(host)) { + const lines: AccessUrlLine[] = [ + { label: 'Local: ', url: buildOpenableUrl(`http://localhost:${port}`, token) }, + ]; + const addrs = networkAddresses ?? listNetworkAddresses(); + for (const addr of addrs) { + lines.push({ + label: 'Network: ', + url: buildOpenableUrl(`http://${formatHostForUrl(addr.address, addr.family)}:${port}`, token), + }); + } + return lines; + } + if (isLoopbackHost(host)) { + return [{ label: 'Local: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; + } + return [{ label: 'URL: ', url: buildOpenableUrl(hostOrigin(host, port), token) }]; +} diff --git a/apps/pythinker-code/src/cli/sub/web/deprecated-server.ts b/apps/pythinker-code/src/cli/sub/web/deprecated-server.ts new file mode 100644 index 00000000..b6edd385 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/deprecated-server.ts @@ -0,0 +1,38 @@ +/** + * Deprecated `pythinker server` shim. + * + * The `pythinker server` command tree was replaced by `pythinker web` (a foreground + * server opened in the browser). Any `pythinker server …` invocation — bare or + * with any legacy subcommand/flags — lands here, prints the deprecation + * notice, and exits 1. The shim itself is scheduled for removal in the next + * major version of Pythinker Code. + * + * One subcommand stays functional: `pythinker server kill`, the cleanup path for + * background servers started by pre-0.28.0 builds (recorded in the legacy + * single-instance lock, which the instance registry never sees). + */ + +import type { Command } from 'commander'; + +import { registerLegacyKillCommand } from './legacy-kill'; + +export const DEPRECATED_SERVER_NOTICE = + '`pythinker server` has been deprecated and no longer works.\n' + + 'Use `pythinker web` instead — it runs the local server in the foreground and opens the web UI (`--no-open` to skip).\n' + + 'To stop a server started by a version before 0.28.0, use `pythinker server kill`.\n' + + 'This notice will be removed in the next major version of Pythinker Code.\n'; + +export function registerDeprecatedServerCommand(program: Command): void { + const server = program + .command('server') + .description('Deprecated — use `pythinker web` instead.') + // Swallow every legacy subcommand/flag (`run`, `kill`, `--port`, …) so + // they all land in the same notice instead of a commander parse error. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(() => { + process.stderr.write(DEPRECATED_SERVER_NOTICE); + process.exit(1); + }); + registerLegacyKillCommand(server); +} diff --git a/apps/pythinker-code/src/cli/sub/web/index.ts b/apps/pythinker-code/src/cli/sub/web/index.ts new file mode 100644 index 00000000..2fa13cea --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/index.ts @@ -0,0 +1,27 @@ +/** + * `pythinker web` — run the local Pythinker server (REST + WebSocket + web UI) in the + * foreground and open the web UI in the default browser. + * + * The command itself is the runner (`pythinker web` = start the server + open the + * browser; `--no-open` to skip). The server stays attached to the terminal + * and stops with Ctrl+C, so there is no kill/ps subcommand; the only + * management subcommand is `web rotate-token` (rotate the home-wide bearer + * token). Servers left behind by pre-0.28.0 builds are cleaned up with + * `pythinker server kill`. + */ + +import type { Command } from 'commander'; + +import { registerDeprecatedServerCommand } from './deprecated-server'; +import { registerRotateTokenCommand } from './rotate-token'; +import { buildWebCommand } from './run'; + +export function registerWebCommand(program: Command): void { + const web = buildWebCommand( + program + .command('web') + .description('Run the local Pythinker server and open the web UI.'), + ); + registerRotateTokenCommand(web); + registerDeprecatedServerCommand(program); +} diff --git a/apps/pythinker-code/src/cli/sub/web/legacy-kill.ts b/apps/pythinker-code/src/cli/sub/web/legacy-kill.ts new file mode 100644 index 00000000..c96cd7d7 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/legacy-kill.ts @@ -0,0 +1,263 @@ +/** + * `pythinker server kill` — deprecated; only stops a server started by an old + * (pre-`pythinker web`, i.e. before 0.28.0) build. + * + * Servers started by current builds run in the foreground attached to a + * terminal (Ctrl+C stops them), so they need no kill command. Builds before + * the `pythinker web` command tree could leave a background daemon behind; those + * recorded themselves in the legacy single-instance lock at + * `/server/lock`, which the instance registry never sees. + * This command is the cleanup path for exactly those servers. + * + * The kill combines two independent mechanisms so the server dies even if one + * path fails: + * + * 1. API path — `POST /api/v1/shutdown` for a graceful, in-process shutdown + * (best-effort; old builds may not have the route, or may not + * answer at all). + * 2. PID path — signal the pid recorded in the lock (SIGTERM → wait → + * SIGKILL). SIGKILL is the hard guarantee: it cannot be + * caught or ignored. + * + * The lock file is removed once the recorded pid is confirmed dead (or was + * dead already), so the cleanup is complete after one run. + */ + +import { readFile, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { Command } from 'commander'; + +import { getDataDir } from '#/utils/paths'; + +import { authHeaders, serverOrigin, tryResolveServerToken } from './shared'; + +/** How long to wait for the graceful API shutdown request. */ +const API_TIMEOUT_MS = 2000; +/** Grace period after SIGTERM before escalating to SIGKILL. */ +const TERM_GRACE_MS = 3000; +/** Grace period after SIGKILL before giving up. */ +const KILL_GRACE_MS = 2000; +/** Poll cadence while waiting for the pid to exit. */ +const POLL_INTERVAL_MS = 100; + +/** + * The first release whose servers run in the foreground (`pythinker web`) and + * register under `server/instances/`. Servers from older builds are the only + * ones this command can — and should — kill. + */ +export const LEGACY_SERVER_MAX_VERSION = '0.28.0'; + +/** Deprecation notice printed on every `pythinker server kill` run. */ +export const DEPRECATED_KILL_NOTICE = + '`pythinker server kill` is deprecated: it only stops servers started by a version before 0.28.0. Servers started by `pythinker web` run in the foreground — stop them with Ctrl+C.\n'; + +/** + * The fields of the legacy `/server/lock` this command needs. The full + * on-disk shape also carried `started_at` / `host_version` / `entry`, which + * are irrelevant to killing the process. + */ +export interface LegacyServerLock { + pid: number; + host?: string; + port?: number; +} + +export interface LegacyKillDeps { + /** Read and parse the legacy lock; undefined when missing or unparseable. */ + readLock(): Promise; + /** Delete the lock file. Best-effort semantics live with the caller. */ + removeLock(): Promise; + requestShutdown(origin: string, token: string | undefined): Promise; + /** Best-effort read of the persistent bearer token; undefined on miss. */ + resolveToken(): string | undefined; + signalPid(pid: number, signal: NodeJS.Signals): boolean; + pidAlive(pid: number): boolean; + sleep(ms: number): Promise; + stdout: Pick; + stderr: Pick; + now(): number; +} + +export function registerLegacyKillCommand(server: Command): void { + server + .command('kill') + .description( + 'Deprecated — stop a server started by a version before 0.28.0 (recorded in the legacy server lock). Servers started by `pythinker web` run in the foreground — stop them with Ctrl+C.', + ) + // Swallow legacy argument shapes (`pythinker server kill `, flags): + // the legacy lock records a single server, so they carry no meaning here. + .allowUnknownOption(true) + .allowExcessArguments(true) + .action(async () => { + try { + await handleLegacyKillCommand(DEFAULT_LEGACY_KILL_DEPS); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleLegacyKillCommand(deps: LegacyKillDeps): Promise { + deps.stderr.write(DEPRECATED_KILL_NOTICE); + + const lock = await deps.readLock(); + if (lock === undefined) { + deps.stdout.write('No running legacy Pythinker server.\n'); + return; + } + + if (!deps.pidAlive(lock.pid)) { + // Stale lock from a server that died without releasing it; sweep it so the + // cleanup is done in one run. + await deps.removeLock().catch(() => {}); + deps.stdout.write('No running legacy Pythinker server.\n'); + return; + } + + const outcome = await killLegacyServer(lock, deps); + await deps.removeLock().catch(() => {}); + deps.stdout.write(`Legacy Pythinker server (pid ${String(lock.pid)}) ${outcome}.\n`); +} + +/** + * Kill the locked server via the API path (best-effort graceful shutdown) + * followed by the PID path (SIGTERM → wait → SIGKILL). Resolves with how the + * process went down; throws when the pid survives SIGKILL. + */ +async function killLegacyServer( + lock: LegacyServerLock, + deps: LegacyKillDeps, +): Promise<'stopped' | 'killed'> { + const { pid } = lock; + + // 1. API path — best-effort graceful shutdown. Ignore every outcome: an old + // build may not have the route, may be wedged, or may drop the connection + // as it exits. The bearer token is best-effort too: if it can't be read + // the API call 401s and the PID path below still guarantees the kill. + if (lock.port !== undefined) { + const origin = serverOrigin(lock.host ?? '127.0.0.1', lock.port); + await deps.requestShutdown(origin, deps.resolveToken()).catch(() => {}); + } + + // 2. PID path — SIGTERM, wait, then SIGKILL. + deps.signalPid(pid, 'SIGTERM'); + + if (await waitForExit(pid, TERM_GRACE_MS, deps)) { + return 'stopped'; + } + + deps.signalPid(pid, 'SIGKILL'); + + if (await waitForExit(pid, KILL_GRACE_MS, deps)) { + return 'killed'; + } + + throw new Error( + `Failed to stop legacy Pythinker server (pid ${String(pid)}); insufficient permissions?`, + ); +} + +async function waitForExit( + pid: number, + timeoutMs: number, + deps: Pick, +): Promise { + const deadline = deps.now() + timeoutMs; + do { + if (!deps.pidAlive(pid)) return true; + await deps.sleep(POLL_INTERVAL_MS); + } while (deps.now() < deadline); + return !deps.pidAlive(pid); +} + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it. Treat as alive. + return true; + } +} + +/** Send `signal` to `pid`. Returns false if the signal could not be sent. */ +export function signalPid(pid: number, signal: NodeJS.Signals): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** POST the shutdown endpoint; resolves once the request completes or times out. */ +export async function requestShutdownViaApi( + origin: string, + token: string | undefined, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, API_TIMEOUT_MS); + try { + await fetch(`${origin}/api/v1/shutdown`, { + method: 'POST', + headers: token !== undefined ? authHeaders(token) : undefined, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +/** Path of the legacy single-instance lock under the CLI's data dir. */ +export function legacyLockPath(homeDir: string): string { + return join(homeDir, 'server', 'lock'); +} + +/** Read + decode the legacy lock; undefined on missing/unparseable input. */ +export async function readLegacyLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, 'utf8'); + } catch { + return undefined; + } + try { + const parsed = JSON.parse(raw) as Partial<{ pid: unknown; host: unknown; port: unknown }>; + // Only accept a positive safe-integer pid: on POSIX, 0 and negative pids + // have process-GROUP semantics, so signaling a corrupt lock's pid could + // hit this CLI's own group or an unrelated one. + if (typeof parsed.pid !== 'number' || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0) { + return undefined; + } + return { + pid: parsed.pid, + host: typeof parsed.host === 'string' ? parsed.host : undefined, + port: typeof parsed.port === 'number' ? parsed.port : undefined, + }; + } catch { + return undefined; + } +} + +const DEFAULT_LEGACY_KILL_DEPS: LegacyKillDeps = { + readLock: () => readLegacyLock(legacyLockPath(getDataDir())), + removeLock: () => unlink(legacyLockPath(getDataDir())), + requestShutdown: requestShutdownViaApi, + resolveToken: () => tryResolveServerToken(getDataDir()), + signalPid, + pidAlive, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }), + stdout: process.stdout, + stderr: process.stderr, + now: () => Date.now(), +}; diff --git a/apps/pythinker-code/src/cli/sub/web/networks.ts b/apps/pythinker-code/src/cli/sub/web/networks.ts new file mode 100644 index 00000000..39ca7d08 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/networks.ts @@ -0,0 +1,84 @@ +/** + * Enumerate this machine's non-loopback network interface addresses, used to + * print `Network: http://:/` hints (à la Vite) when the server + * binds a wildcard host (`0.0.0.0` / `::`). + */ + +import { networkInterfaces } from 'node:os'; + +export interface NetworkAddress { + /** Raw IP address (IPv4 or IPv6); IPv6 is NOT bracket-wrapped here. */ + address: string; + family: 'IPv4' | 'IPv6'; +} + +/** + * List non-internal interface addresses, IPv4 first then IPv6, preserving + * interface order within each family. + * + * Like Vite, this lists the machine's own interface addresses — LAN + * (192.168/10/172.16) plus any directly-assigned public address. It does not + * (and cannot, without an external service) discover a NAT-translated WAN IP, + * and we deliberately avoid any network call for a startup hint. + */ +export function listNetworkAddresses(): NetworkAddress[] { + const raw: NetworkAddress[] = []; + for (const entries of Object.values(networkInterfaces())) { + for (const info of entries ?? []) { + if (info.internal) { + continue; + } + if (info.family === 'IPv4') { + raw.push({ address: info.address, family: 'IPv4' }); + } else if (info.family === 'IPv6') { + raw.push({ address: info.address, family: 'IPv6' }); + } + } + } + return filterDisplayAddresses(raw); +} + +/** + * Drop addresses that are not useful as a connect target and de-duplicate. + * + * IPv6 link-local (`fe80::/10`) is filtered out: it is only reachable with a + * zone id (e.g. `fe80::1%en0`), which our bare URL cannot carry, so showing it + * is pure noise — and it is the bulk of what `os.networkInterfaces()` reports + * on a typical machine. Duplicates (the same address reported on more than one + * interface) are collapsed. The result is IPv4 first, then IPv6, preserving + * order within each family. + */ +export function filterDisplayAddresses( + addrs: readonly NetworkAddress[], +): NetworkAddress[] { + const seen = new Set(); + const kept: NetworkAddress[] = []; + for (const addr of addrs) { + if (addr.family === 'IPv6' && isLinkLocalV6(addr.address)) { + continue; + } + if (seen.has(addr.address)) { + continue; + } + seen.add(addr.address); + kept.push(addr); + } + return [ + ...kept.filter((a) => a.family === 'IPv4'), + ...kept.filter((a) => a.family === 'IPv6'), + ]; +} + +/** True for IPv6 link-local addresses (`fe80::/10`, i.e. `fe80::`–`febf::`). */ +function isLinkLocalV6(address: string): boolean { + const first = Number.parseInt(address.split(':')[0] ?? '', 16); + return first >= 0xfe80 && first <= 0xfebf; +} + +/** + * Format an address for use as a URL host: bracket-wrap IPv6 per RFC 3986, + * return IPv4 as-is. + */ +export function formatHostForUrl(address: string, family: NetworkAddress['family']): string { + return family === 'IPv6' ? `[${address}]` : address; +} diff --git a/apps/pythinker-code/src/cli/sub/web/rotate-token.ts b/apps/pythinker-code/src/cli/sub/web/rotate-token.ts new file mode 100644 index 00000000..0745efba --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/rotate-token.ts @@ -0,0 +1,56 @@ +/** + * `pythinker web rotate-token` — generate a new persistent server token. + * + * Rewrites `/server.token` (0600, atomic). The previous token + * stops working immediately: a running server re-reads the file on its next + * auth check, so rotation takes effect without a restart. + */ + +import { getLiveServerInstance, rotateServerToken } from '@pymodel/kap-server'; +import chalk from 'chalk'; +import type { Command } from 'commander'; + +import { darkColors } from '#/tui/theme/colors'; +import { getDataDir } from '#/utils/paths'; + +import { accessUrlLines, splitTokenFragment } from './access-urls'; + +export function registerRotateTokenCommand(server: Command): void { + server + .command('rotate-token') + .description( + 'Generate a new persistent server token; the previous token stops working immediately.', + ) + .action(async () => { + try { + const token = await rotateServerToken(getDataDir()); + process.stdout.write( + 'The previous token is now invalid. A running server picks up the new token automatically.\n', + ); + + // Token in the middle: indented and set off by blank lines (no color + // highlight), so it is easy to spot without dominating the output. + process.stdout.write(`\n ${chalk.bold('New server token:')} ${token}\n\n`); + + // Re-print the access links with the new token so the user can + // reconnect immediately. When a server is running its bind host/port + // come from the instance registry; otherwise there is nothing to + // connect to yet. + const instance = await getLiveServerInstance(); + if (instance !== undefined) { + for (const { label, url: href } of accessUrlLines(instance.host, instance.port, token)) { + // De-emphasize the `#token=…` fragment so the host/port stands out. + const [base, frag] = splitTokenFragment(href); + const rendered = + frag === '' + ? chalk.hex(darkColors.accent)(base) + : chalk.hex(darkColors.accent)(base) + chalk.hex(darkColors.textDim)(frag); + process.stdout.write(` ${chalk.dim(label)}${rendered}\n`); + } + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/pythinker-code/src/cli/sub/web/run.ts b/apps/pythinker-code/src/cli/sub/web/run.ts new file mode 100644 index 00000000..c5b40d40 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/run.ts @@ -0,0 +1,443 @@ +/** + * `pythinker web` — run the local server in the foreground and open the web UI. + * + * The server always runs in the current process, attached to the terminal, + * and shuts down cleanly on SIGINT/SIGTERM. `--no-open` skips the browser. + * Multiple instances can share the home directory: each registers itself in + * the instance registry and takes the next free port (see kap-server's + * `startServer`). + */ + +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { createServerLogger, startServer, type ServerLogger } from '@pymodel/kap-server'; +import { shutdownTelemetry, track } from '@pymodel/pythinker-telemetry'; +import chalk from 'chalk'; +import { type Command } from 'commander'; + +import { CLI_SHUTDOWN_TIMEOUT_MS, WEB_USER_AGENT_SUFFIX } from '#/constant/app'; +import { getNativeWebAssetsDir } from '#/native/web-assets'; +import { darkColors } from '#/tui/theme/colors'; +import { openUrl as defaultOpenUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; + +import { initializeServerTelemetry } from '../../telemetry'; +import { + createPythinkerCodeHostIdentity, + getHostPackageRoot, + getVersion, +} from '../../version'; +import { + accessUrlLines, + buildOpenableUrl, + isLoopbackHost, + splitTokenFragment, +} from './access-urls'; +import { type NetworkAddress } from './networks'; +import { + DEFAULT_FOREGROUND_LOG_LEVEL, + DEFAULT_LAN_HOST, + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + parseServerOptions, + tryResolveServerToken, + VALID_LOG_LEVELS, + type ParsedServerOptions, + type ServerCliOptions, +} from './shared'; + +const WEB_ASSETS_DIR = 'dist-web'; + +/** + * Minimal surface `runServerInProcess` needs from the server. kap-server's + * `RunningServer` is adapted to it (it returns `{ host, port, close }` + * instead of `{ address, logger, close }`). + */ +interface RoutedServer { + readonly address: string; + readonly logger: ServerLogger; + close(): Promise; +} + +export interface WebCliOptions extends ServerCliOptions { + open?: boolean; +} + +export interface StartForegroundHooks { + /** Fires once the server is listening, before the foreground runner blocks. */ + onReady?: (origin: string) => void; +} + +export interface WebCommandDeps { + /** Foreground runner; defaults to the real in-process runner when omitted. */ + startServerForeground?: ( + options: ParsedServerOptions, + hooks?: StartForegroundHooks, + ) => Promise; + openUrl(url: string): void; + /** + * Best-effort read of the server's persistent bearer token. When it returns + * a token, the ready banner prints it and the opened Web UI URL carries it in + * the `#token=` fragment (M5.5). Optional so callers/tests that don't supply + * it simply print/open the plain origin. + */ + resolveToken?: () => string | undefined; + /** + * Non-loopback interface addresses to display for a wildcard bind. Defaults + * to the machine's own interfaces (`listNetworkAddresses()`); inject a fixed + * list in tests for deterministic output. + */ + networkAddresses?: NetworkAddress[]; + stdout: Pick; + stderr: Pick; +} + +/** + * Build the Web UI URL, carrying the bearer token in the URL fragment. + * + * The token rides in `#token=` — a client-side fragment that is never + * sent to the server (so it never appears in server access logs) and is not + * logged by proxies. The Web UI reads it from `location.hash` after load. + */ +export function buildWebUrl(origin: string, token: string): string { + return buildOpenableUrl(origin, token); +} + +/** Build the `web` command, mounting the runner action on `cmd` itself. */ +export function buildWebCommand(cmd: Command): Command { + return cmd + .option( + '--port ', + `Bind port (default ${DEFAULT_SERVER_PORT})`, + String(DEFAULT_SERVER_PORT), + ) + .option( + '--host [host]', + `Bind host. Omit to bind ${DEFAULT_SERVER_HOST} (this machine only); pass --host to bind ${DEFAULT_LAN_HOST} (all interfaces), or --host for a specific host. The bearer token is printed at startup.`, + ) + .option( + '--allowed-host ', + 'Extra Host header value to allow through the DNS-rebinding check. Repeat or comma-separate; a leading dot matches a domain suffix (e.g. .example.com).', + ) + .option( + '--insecure-no-tls', + 'Allow a non-loopback bind without a TLS-terminating reverse proxy. Defaults to true; only relevant for non-loopback binds.', + true, + ) + .option( + '--allow-remote-shutdown', + 'On a non-loopback bind, keep POST /api/v1/shutdown enabled (default: route is disabled → 404).', + false, + ) + .option( + '--allow-remote-terminals', + 'On a non-loopback bind, keep the PTY /api/v1/terminals/* routes enabled (default: disabled → 404). Remote shell is high risk.', + false, + ) + .option( + '--dangerous-bypass-auth', + 'Disable bearer-token auth on every REST and WebSocket route, and advertise it via /api/v1/meta so the web UI connects without a token. Only use on a trusted network or behind your own authenticating proxy.', + false, + ) + .option( + '--log-level ', + `Server log level: ${VALID_LOG_LEVELS.join('|')}. Omit to keep logs off.`, + ) + .option( + '--debug-endpoints', + 'Mount /api/v1/debug/* routes for test introspection. OFF by default; production callers leave this unset.', + false, + ) + .option( + '--web-title ', + 'Set a custom browser tab title for this web UI instance (default: "<workspace dir> | Pythinker Code").', + ) + .option('--no-open', 'Do not open the web UI in the default browser.', true) + .action(async (opts: WebCliOptions) => { + try { + await handleWebCommand(opts); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } + }); +} + +export async function handleWebCommand( + opts: WebCliOptions, + deps: WebCommandDeps = DEFAULT_WEB_COMMAND_DEPS, +): Promise<void> { + const parsed = parseServerOptions(opts); + const run = deps.startServerForeground ?? startServerForeground; + await run(parsed, { + onReady: (origin) => { + // Resolve the persistent token only once the server is up: a fresh + // server writes `server.token` on first boot, so reading it beforehand + // would miss first-time starts and the browser would hit the auth gate. + // It is printed in the ready banner and rides in the opened Web UI + // URL's `#token=` fragment (M5.5); falls back to the plain origin / no + // token line when unavailable. When auth is bypassed, the token is + // meaningless and is intentionally NOT shown or carried in the URL. + const token = parsed.dangerousBypassAuth ? undefined : deps.resolveToken?.(); + deps.stdout.write( + parsed.logLevel === DEFAULT_FOREGROUND_LOG_LEVEL + ? formatReadyBanner(origin, parsed.host, { + token, + networkAddresses: deps.networkAddresses, + dangerousBypassAuth: parsed.dangerousBypassAuth, + }) + : formatReadyLine(origin, token, parsed.dangerousBypassAuth), + ); + if (opts.open === true) { + deps.openUrl(token !== undefined ? buildWebUrl(origin, token) : origin); + } + }, + }); +} + +function formatReadyLine( + origin: string, + token: string | undefined, + dangerousBypassAuth = false, +): string { + const notice = dangerousBypassAuth + ? `${formatDangerNoticeLines().join('\n')}\n` + : ''; + return `${notice}Pythinker server: ${buildOpenableUrl(origin, token)}\n`; +} + +/** + * Red, impossible-to-miss notice emitted when `--dangerous-bypass-auth` + * disables the bearer-token gate. Shared by the full ready banner and the + * compact one-line output so the warning always shows regardless of log level. + */ +function formatDangerNoticeLines(): string[] { + const danger = (text: string): string => chalk.hex(darkColors.error)(text); + const dangerBold = (text: string): string => chalk.bold.hex(darkColors.error)(text); + return [ + ` ${dangerBold('⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).')}`, + ` ${danger('Anyone who can reach this port gets full access. Only continue if you understand the risk.')}`, + ` ${danger('If you are unsure, stop this process now with ')}${dangerBold('Ctrl+C')}${danger('.')}`, + ]; +} + +/** + * `pythinker web` — runs the local server in-process, attached to the current + * terminal. Resolves only via `process.exit` (SIGINT/SIGTERM). + */ +export async function startServerForeground( + options: ParsedServerOptions, + hooks: StartForegroundHooks = {}, +): Promise<never> { + return runServerInProcess(options, hooks.onReady); +} + +/** + * Start the server in the current process and block until shutdown. + * `onReady` fires once the server is listening. + */ +async function runServerInProcess( + options: ParsedServerOptions, + onReady?: (origin: string) => void, +): Promise<never> { + const version = getVersion(); + // Registers the telemetry provider for `track` / `shutdownTelemetry`; the + // client itself is not passed into kap-server. + initializeServerTelemetry({ version }); + + let running: RoutedServer | undefined; + let stopping = false; + + async function shutdown(reason: string): Promise<void> { + if (stopping) return; + stopping = true; + running?.logger.info({ reason }, 'server shutting down'); + try { + await running?.close(); + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); + } catch (error) { + running?.logger.error( + { err: error instanceof Error ? error : new Error(String(error)) }, + 'server shutdown error', + ); + } + process.exit(0); + } + + // kap-server (the DI × Scope engine server) is the only server flavor. Its + // `startServer` returns `{ host, port, close }` rather than `{ address, + // logger, close }`, so adapt it to the `RoutedServer` surface the rest of + // this runner consumes. + const logger = createServerLogger({ level: options.logLevel }); + const webAssetsDir = serverWebAssetsDir(); + if (webAssetsDir === undefined) { + logger.info( + 'dev mode: web assets not built; starting the API server without the web UI', + ); + } + const v2 = await startServer({ + host: options.host, + port: options.port, + // Report the CLI's product version as `server_version` (/meta, web UI) + // rather than kap-server's private package version. + serverVersion: version, + // The CLI's host identity: feeds the engine's bootstrap client identity + // and the derived outbound headers (User-Agent + X-Msh-*), so web-UI + // OAuth flows and model / WebSearch requests carry the CLI identity. The + // `web` User-Agent suffix distinguishes web-UI traffic from direct CLI + // runs upstream (same product token, same platform). + hostIdentity: { + ...createPythinkerCodeHostIdentity(version), + userAgentSuffix: WEB_USER_AGENT_SUFFIX, + }, + logLevel: options.logLevel, + logger, + debugEndpoints: options.debugEndpoints, + insecureNoTls: options.insecureNoTls, + allowRemoteShutdown: options.allowRemoteShutdown, + allowRemoteTerminals: options.allowRemoteTerminals, + allowedHosts: options.allowedHosts, + disableAuth: options.dangerousBypassAuth, + webTitle: options.webTitle, + // Attach the engine's cloud telemetry appender (still gated by the config + // `telemetry` toggle). Complements the v1 client registered above, which + // only covers host-level events. + telemetry: true, + webAssetsDir, + }); + logger.info('serving the REST/WS API and the bundled web UI'); + running = { + address: `http://${v2.host}:${v2.port}`, + logger, + close: () => v2.close(), + }; + + track('server_started', { daemon: false }); + + process.once('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.once('SIGTERM', () => { + void shutdown('SIGTERM'); + }); + + running.logger.info({ address: running.address }, 'server ready'); + + onReady?.(running.address); + + return new Promise<never>(() => { + // Keeps the event loop alive; the process ends via shutdown()/process.exit. + }); +} + +/** + * Resolve the web assets directory passed to kap-server. In dev mode + * (`PYTHINKER_CODE_DEV_SERVER=1`, set by the repo's `dev:server` / `dev:kap-server*` + * scripts) a missing `dist-web` build is tolerated: the server starts API-only + * and the web UI is expected to come from a Vite dev server (the web UI source lives in the code-app repo). + * Outside dev mode the directory is always returned and kap-server keeps + * failing fast when the assets are missing. + */ +export function serverWebAssetsDir( + env: NodeJS.ProcessEnv = process.env, + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string | undefined { + const dir = resolveServerWebAssetsDir(nativeWebAssetsDir); + if (env['PYTHINKER_CODE_DEV_SERVER'] === '1' && !existsSync(join(dir, 'index.html'))) { + return undefined; + } + return dir; +} + +export function resolveServerWebAssetsDir( + nativeWebAssetsDir: string | null = getNativeWebAssetsDir(), +): string { + return nativeWebAssetsDir ?? join(getHostPackageRoot(), WEB_ASSETS_DIR); +} + +interface FormatReadyBannerOptions { + /** Persistent bearer token to print; omitted when unresolvable. */ + token?: string; + /** Non-loopback interface addresses to list for a wildcard bind. */ + networkAddresses?: NetworkAddress[]; + /** When true, render a red danger notice (auth is disabled). */ + dangerousBypassAuth?: boolean; +} + +export function formatReadyBanner( + origin: string, + host: string, + opts: FormatReadyBannerOptions = {}, +): string { + const primary = (text: string): string => chalk.hex(darkColors.primary)(text); + const title = (text: string): string => chalk.bold.hex(darkColors.primary)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const muted = (text: string): string => chalk.hex(darkColors.textMuted)(text); + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const url = (text: string): string => chalk.hex(darkColors.accent)(text); + // Render the `#token=…` fragment in a de-emphasized gray so the host/port + // stands out while the full URL stays selectable for copying. + const urlWithDimToken = (href: string): string => { + const [base, frag] = splitTokenFragment(href); + return frag === '' ? url(base) : url(base) + dim(frag); + }; + + const port = Number(new URL(origin).port); + // Borderless header: the Pythinker sprite (the little mascot with eyes) sits next + // to the title, keeping the brand without the enclosing box. + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const lines: string[] = [ + '', + ` ${primary(logo[0])} ${title('Pythinker server ready')} ${dim(getVersion())}`, + ` ${primary(logo[1])} ${dim('Local web UI is available from this machine.')}`, + '', + ]; + + if (opts.dangerousBypassAuth === true) { + // Red, impossible-to-miss notice: the bearer-token gate is off, so anyone + // who can reach this port gets full session / filesystem / shell access. + lines.push(...formatDangerNoticeLines(), ''); + } + + // Access links. + for (const { label: text, url: href } of accessUrlLines( + host, + port, + opts.token, + opts.networkAddresses, + )) { + lines.push(` ${label(text)}${urlWithDimToken(href)}`); + } + // On a loopback bind there is no network URL; show how to enable one. + if (isLoopbackHost(host)) { + lines.push(` ${label('Network: ')}${muted('off')}${dim(' use --host to enable')}`); + } + if (opts.token !== undefined) { + // Set the token off with surrounding whitespace rather than color, so it is + // easy to spot without being highlighted. + lines.push(''); + lines.push(` ${label('Token: ')}${opts.token}`); + lines.push(''); + } + + // Auxiliary controls last. + lines.push(` ${label('Logs: ')}${muted('off')}${dim(' use --log-level info to enable')}`); + // The server always runs in the foreground attached to this terminal. + lines.push(` ${label('Stop: ')}${muted('Ctrl+C')}`); + lines.push(''); + return lines.join('\n'); +} + +const DEFAULT_WEB_COMMAND_DEPS: WebCommandDeps = { + startServerForeground, + openUrl: defaultOpenUrl, + resolveToken: () => { + // Read the persistent `<homeDir>/server.token` written on first boot + // (M5.1). Best-effort: a missing/older server yields undefined and the + // caller opens the plain origin. + return tryResolveServerToken(getDataDir()); + }, + stdout: process.stdout, + stderr: process.stderr, +}; diff --git a/apps/pythinker-code/src/cli/sub/web/shared.ts b/apps/pythinker-code/src/cli/sub/web/shared.ts new file mode 100644 index 00000000..3f049c95 --- /dev/null +++ b/apps/pythinker-code/src/cli/sub/web/shared.ts @@ -0,0 +1,169 @@ +/** + * Shared helpers for `pythinker web` and its subcommands. + * + * Owns the default host/port, option parsers, and health/readiness probes. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ServerLogLevel } from '@pymodel/kap-server'; + +export const LOCAL_SERVER_HOST = '127.0.0.1'; +export const DEFAULT_LAN_HOST = '0.0.0.0'; +export const DEFAULT_SERVER_HOST = LOCAL_SERVER_HOST; +export const DEFAULT_SERVER_PORT = 58627; +export const DEFAULT_SERVER_ORIGIN = serverOrigin(DEFAULT_SERVER_HOST, DEFAULT_SERVER_PORT); + +/** Filename (under PYTHINKER_CODE_HOME) of the persistent server bearer token. */ +export const SERVER_TOKEN_FILE = 'server.token'; + +export const DEFAULT_LOG_LEVEL: ServerLogLevel = 'info'; +export const DEFAULT_FOREGROUND_LOG_LEVEL: ServerLogLevel = 'silent'; + +export const VALID_LOG_LEVELS: readonly ServerLogLevel[] = [ + 'fatal', + 'error', + 'warn', + 'info', + 'debug', + 'trace', + 'silent', +]; + +export interface ParsedServerOptions { + host: string; + port: number; + logLevel: ServerLogLevel; + debugEndpoints: boolean; + /** Allow a non-loopback bind without a TLS-terminating reverse proxy. */ + insecureNoTls: boolean; + /** Allow `POST /api/v1/shutdown` on a non-loopback bind. */ + allowRemoteShutdown: boolean; + /** Allow PTY `/api/v1/terminals/*` routes on a non-loopback bind. */ + allowRemoteTerminals: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth: boolean; + /** Extra `Host` header values to allow through the DNS-rebinding check. */ + allowedHosts: readonly string[]; + /** Custom browser tab title for this web UI instance (`--web-title`). */ + webTitle?: string; +} + +export interface ServerCliOptions { + host?: string | boolean; + port?: string; + logLevel?: string; + debugEndpoints?: boolean; + /** Allow a non-loopback bind without TLS (`--insecure-no-tls`). */ + insecureNoTls?: boolean; + /** Allow remote shutdown on a non-loopback bind (`--allow-remote-shutdown`). */ + allowRemoteShutdown?: boolean; + /** Allow remote terminals on a non-loopback bind (`--allow-remote-terminals`). */ + allowRemoteTerminals?: boolean; + /** Disable bearer-token auth on every route (`--dangerous-bypass-auth`). */ + dangerousBypassAuth?: boolean; + /** Extra `Host` header values to allow (`--allowed-host`). */ + allowedHost?: string[]; + /** Custom browser tab title for this web UI instance (`--web-title`). */ + webTitle?: string; +} + +export function parseServerOptions(opts: ServerCliOptions): ParsedServerOptions { + return { + host: parseHost(opts.host), + port: parsePort(opts.port, '--port', DEFAULT_SERVER_PORT), + logLevel: parseLogLevel(opts.logLevel ?? DEFAULT_FOREGROUND_LOG_LEVEL), + debugEndpoints: opts.debugEndpoints === true, + insecureNoTls: opts.insecureNoTls !== false, + allowRemoteShutdown: opts.allowRemoteShutdown === true, + allowRemoteTerminals: opts.allowRemoteTerminals === true, + dangerousBypassAuth: opts.dangerousBypassAuth === true, + allowedHosts: parseAllowedHostArgs(opts.allowedHost), + webTitle: opts.webTitle, + }; +} + +export function parseAllowedHostArgs(raw: readonly string[] | undefined): string[] { + if (raw === undefined) return []; + return raw + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseHost(raw: string | boolean | undefined): string { + if (raw === undefined || raw === false) return DEFAULT_SERVER_HOST; + if (raw === true || raw === '') return DEFAULT_LAN_HOST; + return raw; +} + +export function parsePort(raw: string | undefined, label: string, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number.parseInt(raw, 10); + if (!Number.isFinite(n) || n < 0 || n > 65535) { + throw new Error(`error: invalid ${label} value: ${raw}`); + } + return n; +} + +export function parseLogLevel(raw: string | undefined): ServerLogLevel { + if (raw === undefined) return DEFAULT_LOG_LEVEL; + if ((VALID_LOG_LEVELS as readonly string[]).includes(raw)) { + return raw as ServerLogLevel; + } + throw new Error( + `error: invalid --log-level value: ${raw} (allowed: ${VALID_LOG_LEVELS.join(', ')})`, + ); +} + +export function serverOrigin(host: string, port: number): string { + return `http://${host}:${port}`; +} + +/** Strip `/api/v1` and trailing slashes so user-supplied origins are uniform. */ +export function normalizeServerOrigin(value: string): string { + const url = new URL(value); + url.pathname = url.pathname.replace(/\/api\/v1\/?$/, '').replace(/\/$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +/** + * Read the persistent bearer token for the server. + * + * The server writes `<homeDir>/server.token` (0600) on first boot and reuses + * it across restarts (ROADMAP M5.1); CLI commands that hit a gated REST route + * read it back here and send it as `Authorization: Bearer <token>`. `homeDir` + * is the CLI's own PYTHINKER_CODE_HOME resolution (`getDataDir()`). + * + * Throws a clear error when the file is missing/unreadable — the usual cause + * is a server that has never been started (no token file yet), or an older + * build that predates token auth. + */ +export function resolveServerToken(homeDir: string): string { + const tokenPath = join(homeDir, SERVER_TOKEN_FILE); + try { + return readFileSync(tokenPath, 'utf8').trim(); + } catch (error) { + throw new Error( + `unable to read server token at ${tokenPath}; has the server been started at least once?`, + { cause: error }, + ); + } +} + +/** Best-effort token read: returns `undefined` instead of throwing. */ +export function tryResolveServerToken(homeDir: string): string | undefined { + try { + return resolveServerToken(homeDir); + } catch { + return undefined; + } +} + +/** An `Authorization: Bearer <token>` header bag for `fetch`. */ +export function authHeaders(token: string): { Authorization: string } { + return { Authorization: `Bearer ${token}` }; +} diff --git a/apps/pythinker-code/src/cli/telemetry.ts b/apps/pythinker-code/src/cli/telemetry.ts index 400b5a36..e02588f8 100644 --- a/apps/pythinker-code/src/cli/telemetry.ts +++ b/apps/pythinker-code/src/cli/telemetry.ts @@ -1,12 +1,14 @@ -import { createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; +import { createPythinkerDeviceId, PYTHINKER_CODE_PROVIDER_NAME } from '@pymodel/pythinker-code-oauth'; import { + PythinkerAuthFacade, loadRuntimeConfigSafe, resolveConfigPath, resolvePythinkerHome, type PythinkerConfig, - type PythinkerHarness, type TelemetryClient, } from '@pymodel/pythinker-code-sdk'; + +import type { PromptHarness } from './prompt-session'; import { initializeTelemetry, setTelemetryContext, @@ -16,6 +18,7 @@ import { import { CLI_USER_AGENT_PRODUCT, WEB_UI_MODE } from '#/constant/app'; +import { createPythinkerCodeHostIdentity } from './version'; export interface CliTelemetryBootstrap { readonly homeDir: string; @@ -24,12 +27,13 @@ export interface CliTelemetryBootstrap { } export interface InitializeCliTelemetryOptions { - readonly harness: PythinkerHarness; + readonly harness: PromptHarness; readonly bootstrap: CliTelemetryBootstrap; readonly config: Pick<PythinkerConfig, 'defaultModel' | 'telemetry'>; readonly version: string; readonly uiMode: string; readonly model?: string; + readonly sessionId?: string; } export function createCliTelemetryBootstrap(): CliTelemetryBootstrap { @@ -52,6 +56,9 @@ export function initializeCliTelemetry(options: InitializeCliTelemetryOptions): version: options.version, uiMode: options.uiMode, model: options.model ?? options.config.defaultModel, + sessionId: options.sessionId, + getAccessToken: async () => + (await options.harness.auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, }); if (options.bootstrap.firstLaunch) { options.harness.track('first_launch'); @@ -63,7 +70,7 @@ export interface InitializeServerTelemetryOptions { } /** - * Bootstrap telemetry for the `pythinker web` / `pythinker server run` host. + * Bootstrap telemetry for the `pythinker web` host. * * Mirrors {@link initializeCliTelemetry}: mints the device id, reads config to * honor the `telemetry` toggle and pick up the default model, attaches the @@ -84,6 +91,11 @@ export function initializeServerTelemetry( const bootstrap = createCliTelemetryBootstrap(); const configPath = resolveConfigPath({ homeDir: bootstrap.homeDir }); const config = readServerTelemetryConfig(configPath); + const auth = new PythinkerAuthFacade({ + homeDir: bootstrap.homeDir, + configPath, + identity: createPythinkerCodeHostIdentity(options.version), + }); initializeTelemetry({ homeDir: bootstrap.homeDir, @@ -93,6 +105,7 @@ export function initializeServerTelemetry( version: options.version, uiMode: WEB_UI_MODE, model: config.defaultModel, + getAccessToken: async () => (await auth.getCachedAccessToken(PYTHINKER_CODE_PROVIDER_NAME)) ?? null, }); return { diff --git a/apps/pythinker-code/src/cli/update/activation.ts b/apps/pythinker-code/src/cli/update/activation.ts deleted file mode 100644 index feef7428..00000000 --- a/apps/pythinker-code/src/cli/update/activation.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { gte, valid } from 'semver'; - -import { getUpdateInstallLogFile } from '#/utils/paths'; - -import { - activateHomebrewUpdate, - PreparedHomebrewUpdateInvalidError, -} from './homebrew'; -import { formatErrorMessage } from './format-error'; -import { tryAcquireUpdateInstallLock, type UpdateInstallLockHandle } from './install-lock'; -import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; -import { detectInstallSource } from './source'; -import type { InstallSource, UpdateInstallState, UpdatePreparedHomebrew } from './types'; - -const ACTIVATION_FAILURE_LIMIT = 2; - -export interface ActivatePendingUpdateDeps { - readonly readState: () => Promise<UpdateInstallState>; - readonly writeState: (state: UpdateInstallState) => Promise<void>; - readonly acquireLock: ( - request: { readonly version: string }, - ) => Promise<UpdateInstallLockHandle | null>; - readonly activateHomebrew: ( - prepared: UpdatePreparedHomebrew, - ) => Promise<{ readonly version: string; readonly executable: string }>; - readonly detectSource: () => Promise<InstallSource>; - readonly now: () => Date; - readonly pid: number; -} - -export interface ActivatePendingUpdateOptions { - readonly enabled: boolean; - readonly automaticEnabled: boolean; - readonly deps?: Partial<ActivatePendingUpdateDeps>; -} - -function resolveDeps(overrides: Partial<ActivatePendingUpdateDeps> = {}): ActivatePendingUpdateDeps { - return { - readState: overrides.readState ?? (() => readUpdateInstallState()), - writeState: overrides.writeState ?? ((state) => writeUpdateInstallState(state)), - acquireLock: overrides.acquireLock ?? ((request) => tryAcquireUpdateInstallLock(request)), - activateHomebrew: - overrides.activateHomebrew ?? - ((prepared) => activateHomebrewUpdate(prepared, { logFile: getUpdateInstallLogFile() })), - detectSource: overrides.detectSource ?? (() => detectInstallSource()), - now: overrides.now ?? (() => new Date()), - pid: overrides.pid ?? process.pid, - }; -} - -function activationAttempts(state: UpdateInstallState, version: string): number { - const failure = state.lastFailure; - return failure?.version === version && failure.operation === 'activate' ? failure.attempts : 0; -} - -function isRunningPreparedVersion(currentVersion: string, preparedVersion: string): boolean { - return ( - valid(currentVersion) !== null && - valid(preparedVersion) !== null && - gte(currentVersion, preparedVersion) - ); -} - -export async function activatePendingUpdate( - currentVersion: string, - options: ActivatePendingUpdateOptions, -) { - if (!options.enabled) return { status: 'none' as const }; - const deps = resolveDeps(options.deps); - let state = await deps.readState(); - const pending = state.pending; - if (pending === null) return { status: 'none' as const }; - if (pending.requestedBy === 'automatic' && !options.automaticEnabled) { - return { status: 'none' as const }; - } - - if (await deps.detectSource() !== pending.source) { - await deps.writeState({ ...state, active: null, pending: null }); - return { status: 'invalidated' as const, version: pending.version }; - } - - if (isRunningPreparedVersion(currentVersion, pending.version)) { - const installedAt = deps.now().toISOString(); - await deps.writeState({ - active: null, - pending: null, - lastFailure: null, - lastSuccess: { - version: currentVersion, - installedAt, - notifiedAt: null, - }, - }); - return { status: 'finalized' as const, version: currentVersion }; - } - - if (activationAttempts(state, pending.version) >= ACTIVATION_FAILURE_LIMIT) { - // Terminal: drop the pending record (keeping lastFailure for preflight) - // so later launches stop retrying and reporting an in-progress update. - await deps.writeState({ ...state, pending: null }); - return { - status: 'failed' as const, - version: pending.version, - message: `Automatic activation failed ${String(ACTIVATION_FAILURE_LIMIT)} times`, - }; - } - - const lock = await deps.acquireLock({ version: pending.version }); - if (lock === null) return { status: 'in-progress' as const, version: pending.version }; - - try { - state = await deps.readState(); - if (state.pending?.jobId !== pending.jobId) return { status: 'none' as const }; - const startedAt = deps.now().toISOString(); - const activatingState: UpdateInstallState = { - ...state, - active: { - version: pending.version, - source: pending.source, - operation: 'activate', - jobId: pending.jobId, - startedAt, - pid: deps.pid, - }, - }; - await deps.writeState(activatingState); - - try { - const activated = await deps.activateHomebrew(pending); - await deps.writeState({ - ...activatingState, - active: null, - lastFailure: null, - }); - return { - status: 'activated' as const, - version: activated.version, - executable: activated.executable, - }; - } catch (error) { - const message = formatErrorMessage(error); - if (error instanceof PreparedHomebrewUpdateInvalidError) { - // Carry the cumulative prepare-failure count so repeated invalid - // artifacts can reach the auto-install failure threshold. - const priorFailure = activatingState.lastFailure; - const prepareAttempts = - priorFailure?.version === pending.version && priorFailure.operation === 'prepare' - ? priorFailure.attempts + 1 - : 1; - await deps.writeState({ - ...activatingState, - active: null, - pending: null, - lastFailure: { - version: pending.version, - failedAt: deps.now().toISOString(), - attempts: prepareAttempts, - operation: 'prepare', - message, - }, - }); - return { status: 'invalidated' as const, version: pending.version }; - } - const attempts = activationAttempts(activatingState, pending.version) + 1; - await deps.writeState({ - ...activatingState, - active: null, - lastFailure: { - version: pending.version, - failedAt: deps.now().toISOString(), - attempts, - operation: 'activate', - message, - }, - }); - return { status: 'failed' as const, version: pending.version, message }; - } - } finally { - await lock.release().catch(() => {}); - } -} diff --git a/apps/pythinker-code/src/cli/update/cdn.ts b/apps/pythinker-code/src/cli/update/cdn.ts index 0f59d86e..eddf84b9 100644 --- a/apps/pythinker-code/src/cli/update/cdn.ts +++ b/apps/pythinker-code/src/cli/update/cdn.ts @@ -1,7 +1,7 @@ -import { lt, valid } from 'semver'; +import { valid } from 'semver'; import { z } from 'zod'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; import type { UpdateManifest } from './types'; @@ -12,28 +12,11 @@ const RolloutBatchSchema = z.object({ delaySeconds: z.number().int().min(0), }); -const UpdateManifestPlatformSchema = z.object({ - url: z - .string() - .refine( - (value) => { - try { - const url = new URL(value); - return url.protocol === 'http:' || url.protocol === 'https:'; - } catch { - return false; - } - }, - { error: 'invalid url' }, - ), - sha256: z.string().regex(/^[a-f0-9]{64}$/u), -}); - /** * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown * fields are ignored so future manifest additions never break shipped - * clients. Hard-failing on unexpected content bricks the update path for - * every already-installed client, which is unrecoverable from our side. + * clients (the plain-text `/latest` taught us that hard-failing on + * unexpected content bricks the update path forever). */ export const UpdateManifestSchema = z.object({ version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), @@ -41,30 +24,15 @@ export const UpdateManifestSchema = z.object({ .string() .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), rollout: z.array(RolloutBatchSchema).readonly().default([]), - /** - * Resolved per-platform artifacts, keyed `<platform>-<arch>`. A malformed - * value drops only this field via `.catch(undefined)` so `version` and - * `publishedAt` still parse — failing the whole manifest would cost the - * client its update over one unreadable field. - */ - platforms: z - .record(z.string(), UpdateManifestPlatformSchema) - .readonly() - .optional() - .catch(undefined), - /** - * Lowest version that can still work against the current services. A - * malformed value drops only this field via `.catch(undefined)` so - * `version` and `publishedAt` still parse — a client below the floor must - * not lose its update because the declaration is unreadable. - */ - minRequiredVersion: z - .string() - .refine((value) => valid(value) !== null, { error: 'invalid semver' }) - .optional() - .catch(undefined), }); +export interface FetchLatestResult { + /** Raw newest version — what `pythinker upgrade` installs, never rollout-gated. */ + readonly latest: string; + /** Null when the JSON manifest was unavailable and we fell back to plain text. */ + readonly manifest: UpdateManifest | null; +} + async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> { const controller = new AbortController(); const timeout = setTimeout(() => { @@ -78,68 +46,52 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise } /** - * Fetch the CDN update manifest — the client's only source of update truth. + * Fetch the latest published Pythinker Code version from the CDN. * - * **Throws** on any failure (network error, non-2xx, unparseable body). Callers - * must catch: `refreshUpdateCache` deliberately lets the error propagate so the - * existing cache stays intact instead of being overwritten on a transient blip. - * - * There is deliberately no fallback to the plain-text `/latest` endpoint, which - * still exists for `install.sh`. That endpoint carries no per-platform artifact - * data, so falling back to it turns "cannot verify this platform has a build" - * into "verified" and re-opens the hole `platforms` exists to close. It also - * cannot fail independently: both files come from the same generator in the same - * deploy, and the manifest schema already tolerates unknown fields and a - * malformed `platforms` value without failing the parse. + * **Throws** on any failure (network error, non-2xx, empty body, non-semver + * text). Callers must catch — `refreshUpdateCache` deliberately lets the + * error propagate so the existing cache stays intact instead of being + * overwritten with a null `latest` on a transient blip. * * `fetchImpl` is injectable for tests; defaults to the global `fetch`. */ -export async function fetchUpdateManifest( +export async function fetchLatestVersionFromCdn( fetchImpl: typeof fetch = fetch, -): Promise<UpdateManifest> { - const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL); +): Promise<string> { + const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_URL); if (!response.ok) { - throw new Error(`CDN /latest.json returned HTTP ${response.status}`); + throw new Error(`CDN /latest returned HTTP ${response.status}`); } - return UpdateManifestSchema.parse(JSON.parse(await response.text())); + const raw = (await response.text()).trim(); + if (valid(raw) === null) { + throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`); + } + return raw; } -export type ArtifactAvailability = 'available' | 'unavailable'; - -/** - * Whether the manifest advertises an artifact for `target`. Unknown — a - * null manifest or one that predates artifact addressing — resolves to - * 'available': a CDN blip must never stop a working update, while a - * manifest that explicitly omits the target platform is a definitive - * denial. - */ -export function manifestArtifactAvailability( - manifest: UpdateManifest | null, - target: string = `${process.platform}-${process.arch}`, -): ArtifactAvailability { - if (manifest === null) { - return 'available'; - } - if (manifest.platforms === undefined) { - return 'available'; +async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> { + const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL); + if (!response.ok) { + throw new Error(`CDN /latest.json returned HTTP ${response.status}`); } - return Object.hasOwn(manifest.platforms, target) ? 'available' : 'unavailable'; + return UpdateManifestSchema.parse(JSON.parse(await response.text())); } /** - * Whether the running version is below the manifest's declared floor, which - * makes its update mandatory rather than merely available: the staged rollout - * delay exists for ordinary releases, not for one a client cannot skip. + * Fetch the rollout manifest, falling back to the plain-text `/latest` when + * `latest.json` is unavailable or malformed. The fallback removes any + * deployment-order coupling between client releases and the CDN file, and a + * null manifest means "fully rolled out" — exactly the pre-rollout behavior. * - * An absent, unreadable or non-semver floor answers false — a declaration we - * cannot understand must not escalate an update on its own. + * **Throws** only when both sources fail; callers must catch (see above). */ -export function isBelowMinRequiredVersion( - manifest: UpdateManifest | null, - currentVersion: string, -): boolean { - const floor = manifest?.minRequiredVersion; - if (floor === undefined) return false; - if (valid(currentVersion) === null || valid(floor) === null) return false; - return lt(currentVersion, floor); +export async function fetchLatestFromCdn( + fetchImpl: typeof fetch = fetch, +): Promise<FetchLatestResult> { + const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); + if (manifest !== null) { + return { latest: manifest.version, manifest }; + } + const latest = await fetchLatestVersionFromCdn(fetchImpl); + return { latest, manifest: null }; } diff --git a/apps/pythinker-code/src/cli/update/format-error.ts b/apps/pythinker-code/src/cli/update/format-error.ts deleted file mode 100644 index 9c36a53b..00000000 --- a/apps/pythinker-code/src/cli/update/format-error.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Shared failure-message formatter for update install/prepare/activate state. */ -export function formatErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/pythinker-code/src/cli/update/homebrew.ts b/apps/pythinker-code/src/cli/update/homebrew.ts deleted file mode 100644 index 866cc2c2..00000000 --- a/apps/pythinker-code/src/cli/update/homebrew.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { constants as fsConstants, createReadStream } from 'node:fs'; -import { access, mkdir, open, readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import { valid } from 'semver'; -import { z } from 'zod'; - -import type { UpdatePreparedHomebrew } from './types'; - -const HOMEBREW_FORMULA = 'pythinker-code'; -const COMMAND_ERROR_TAIL_LENGTH = 2_000; - -const HomebrewInfoSchema = z.object({ - formulae: z.array(z.object({ - name: z.literal(HOMEBREW_FORMULA), - versions: z.object({ stable: z.string().min(1) }), - urls: z.object({ - stable: z.object({ - url: z.url(), - checksum: z.string().regex(/^[a-f0-9]{64}$/u), - }), - }), - linked_keg: z.string().nullable(), - pinned: z.boolean(), - })).length(1), -}); - -export interface HomebrewCommandOptions { - readonly capture?: boolean; - readonly inheritOutput?: boolean; - readonly env?: NodeJS.ProcessEnv; - readonly logFile?: string; -} - -export interface HomebrewCommandResult { - readonly stdout: string; - readonly stderr: string; -} - -export type HomebrewCommandRunner = ( - args: readonly string[], - options?: HomebrewCommandOptions, -) => Promise<HomebrewCommandResult>; - -export class PreparedHomebrewUpdateInvalidError extends Error {} - -interface HomebrewSnapshot { - readonly version: string; - readonly formulaUrl: string; - readonly artifactSha256: string; - readonly formulaFileSha256: string; - readonly artifactPath: string; - readonly linkedVersion: string | null; - readonly pinned: boolean; - readonly executable: string; -} - -export interface HomebrewUpdateDeps { - readonly run: HomebrewCommandRunner; - readonly hashFile: (filePath: string) => Promise<string>; - readonly readFormula: (filePath: string) => Promise<string>; - readonly ensureExecutable: (filePath: string) => Promise<void>; - readonly now: () => Date; -} - -const NO_AUTO_UPDATE_ENV: NodeJS.ProcessEnv = { - HOMEBREW_NO_AUTO_UPDATE: '1', -}; - -const ACTIVATION_ENV: NodeJS.ProcessEnv = { - ...NO_AUTO_UPDATE_ENV, - HOMEBREW_NO_INSTALL_CLEANUP: '1', - HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK: '1', -}; - -function commandError( - command: string, - code: number | null, - signal: NodeJS.Signals | null, - stderr: string, -) { - const outcome = signal === null ? `code ${String(code)}` : `signal ${signal}`; - const detail = stderr.trim().slice(-COMMAND_ERROR_TAIL_LENGTH); - return new Error(`${command} exited with ${outcome}${detail === '' ? '' : `: ${detail}`}`); -} - -export async function runHomebrewCommand( - args: readonly string[], - options: HomebrewCommandOptions = {}, -): Promise<HomebrewCommandResult> { - const capture = options.capture ?? true; - const command = ['brew', ...args].join(' '); - const logPath = options.logFile; - const logFile = logPath === undefined - ? undefined - : await (async () => { - try { - await mkdir(dirname(logPath), { recursive: true }); - return await open(logPath, 'a', 0o600); - } catch { - return undefined; - } - })(); - let logWrites = Promise.resolve(); - const appendLog = (chunk: string | Uint8Array): void => { - if (logFile === undefined) return; - // Normalize to bytes: FileHandle.write has separate string/buffer - // overloads that reject the union type. - const data = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; - logWrites = logWrites - .then(async () => { - await logFile.write(data); - }) - .catch(() => {}); - }; - appendLog(`\n[${new Date().toISOString()}] $ ${command}\n`); - - const stdout: Buffer[] = []; - const stderr: Buffer[] = []; - try { - await new Promise<void>((resolve, reject) => { - const child = spawn('brew', [...args], { - cwd: homedir(), - env: { ...process.env, ...options.env }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - child.stdout.on('data', (chunk: Buffer) => { - if (capture) stdout.push(chunk); - if (options.inheritOutput === true) process.stdout.write(chunk); - appendLog(chunk); - }); - child.stderr.on('data', (chunk: Buffer) => { - stderr.push(chunk); - if (options.inheritOutput === true) process.stderr.write(chunk); - appendLog(chunk); - }); - child.once('error', reject); - child.once('close', (code, signal) => { - if (code === 0) { - resolve(); - return; - } - reject(commandError(command, code, signal, Buffer.concat(stderr).toString('utf-8'))); - }); - }); - } finally { - await logWrites; - await logFile?.close().catch(() => {}); - } - - return { - stdout: Buffer.concat(stdout).toString('utf-8'), - stderr: Buffer.concat(stderr).toString('utf-8'), - }; -} - -async function sha256File(filePath: string) { - const hash = createHash('sha256'); - await new Promise<void>((resolve, reject) => { - const stream = createReadStream(filePath); - stream.on('data', (chunk) => { hash.update(chunk); }); - stream.once('error', reject); - stream.once('end', resolve); - }); - return hash.digest('hex'); -} - -function resolveDeps(overrides: Partial<HomebrewUpdateDeps> = {}): HomebrewUpdateDeps { - return { - run: overrides.run ?? runHomebrewCommand, - hashFile: overrides.hashFile ?? sha256File, - readFormula: overrides.readFormula ?? ((filePath) => readFile(filePath, 'utf-8')), - ensureExecutable: - overrides.ensureExecutable ?? ((filePath) => access(filePath, fsConstants.X_OK)), - now: overrides.now ?? (() => new Date()), - }; -} - -async function inspectHomebrewFormula( - deps: HomebrewUpdateDeps, - logFile: string | undefined, -): Promise<HomebrewSnapshot> { - const commandOptions: HomebrewCommandOptions = { - env: NO_AUTO_UPDATE_ENV, - logFile, - }; - const info = HomebrewInfoSchema.parse(JSON.parse( - (await deps.run(['info', '--json=v2', HOMEBREW_FORMULA], commandOptions)).stdout, - )); - const formula = info.formulae[0]; - if (formula === undefined) throw new Error('Homebrew formula metadata is missing'); - if (valid(formula.versions.stable) === null) { - throw new Error(`Homebrew returned an invalid version: ${formula.versions.stable}`); - } - - const formulaPath = (await deps.run(['formula', HOMEBREW_FORMULA], commandOptions)).stdout.trim(); - const artifactPath = ( - await deps.run( - ['--cache', '--build-from-source', '--formula', HOMEBREW_FORMULA], - commandOptions, - ) - ).stdout.trim(); - const prefix = (await deps.run(['--prefix', HOMEBREW_FORMULA], commandOptions)).stdout.trim(); - if (formulaPath === '' || artifactPath === '' || prefix === '') { - throw new Error('Homebrew returned an empty update path'); - } - - return { - version: formula.versions.stable, - formulaUrl: formula.urls.stable.url, - artifactSha256: formula.urls.stable.checksum, - formulaFileSha256: createHash('sha256') - .update(await deps.readFormula(formulaPath), 'utf-8') - .digest('hex'), - artifactPath, - linkedVersion: formula.linked_keg, - pinned: formula.pinned, - executable: join(prefix, 'bin', 'pythinker'), - }; -} - -function assertSamePreparedFormula( - prepared: UpdatePreparedHomebrew, - snapshot: HomebrewSnapshot, -): void { - if ( - snapshot.version !== prepared.version || - snapshot.formulaUrl !== prepared.formulaUrl || - snapshot.artifactSha256 !== prepared.artifactSha256 || - snapshot.formulaFileSha256 !== prepared.formulaFileSha256 || - snapshot.artifactPath !== prepared.artifactPath - ) { - throw new PreparedHomebrewUpdateInvalidError( - 'Homebrew formula changed after the update was prepared', - ); - } -} - -async function verifyPreparedArtifact( - prepared: UpdatePreparedHomebrew, - deps: HomebrewUpdateDeps, -): Promise<void> { - const actual = await deps.hashFile(prepared.artifactPath); - if (actual !== prepared.artifactSha256) { - throw new PreparedHomebrewUpdateInvalidError( - 'Prepared Homebrew artifact failed SHA-256 verification', - ); - } -} - -export interface PrepareHomebrewUpdateRequest { - readonly jobId: string; - readonly requestedVersion: string; - readonly requestedBy: UpdatePreparedHomebrew['requestedBy']; -} - -export async function prepareHomebrewUpdate( - request: PrepareHomebrewUpdateRequest, - options: { readonly logFile?: string; readonly deps?: Partial<HomebrewUpdateDeps> } = {}, -): Promise<UpdatePreparedHomebrew> { - if (valid(request.requestedVersion) === null) { - throw new Error(`Invalid requested update version: ${request.requestedVersion}`); - } - const deps = resolveDeps(options.deps); - await deps.run(['update'], { capture: false, logFile: options.logFile }); - - const before = await inspectHomebrewFormula(deps, options.logFile); - if (before.pinned) throw new Error('The Homebrew formula is pinned'); - if (before.version !== request.requestedVersion) { - throw new PreparedHomebrewUpdateInvalidError( - `Homebrew formula ${before.version} does not match requested update ${request.requestedVersion}`, - ); - } - - await deps.run( - ['fetch', '--build-from-source', '--retry', '--formula', HOMEBREW_FORMULA], - { capture: false, env: NO_AUTO_UPDATE_ENV, logFile: options.logFile }, - ); - const after = await inspectHomebrewFormula(deps, options.logFile); - const prepared: UpdatePreparedHomebrew = { - jobId: request.jobId, - source: 'homebrew', - version: before.version, - preparedAt: deps.now().toISOString(), - requestedBy: request.requestedBy, - formulaUrl: before.formulaUrl, - artifactKind: 'source', - artifactSha256: before.artifactSha256, - formulaFileSha256: before.formulaFileSha256, - artifactPath: before.artifactPath, - }; - assertSamePreparedFormula(prepared, after); - await verifyPreparedArtifact(prepared, deps); - return prepared; -} - -export async function activateHomebrewUpdate( - prepared: UpdatePreparedHomebrew, - options: { readonly logFile?: string; readonly deps?: Partial<HomebrewUpdateDeps> } = {}, -) { - const deps = resolveDeps(options.deps); - const before = await inspectHomebrewFormula(deps, options.logFile); - assertSamePreparedFormula(prepared, before); - await verifyPreparedArtifact(prepared, deps); - - if (before.linkedVersion !== prepared.version) { - await deps.run( - ['upgrade', '--formula', '--build-from-source', '--no-ask', HOMEBREW_FORMULA], - { - capture: false, - inheritOutput: true, - env: ACTIVATION_ENV, - logFile: options.logFile, - }, - ); - } - - const after = await inspectHomebrewFormula(deps, options.logFile); - assertSamePreparedFormula(prepared, after); - if (after.linkedVersion !== prepared.version) { - throw new Error( - `Homebrew linked ${after.linkedVersion ?? 'no version'} instead of ${prepared.version}`, - ); - } - await deps.ensureExecutable(after.executable); - return { version: prepared.version, executable: after.executable }; -} diff --git a/apps/pythinker-code/src/cli/update/install-lock.ts b/apps/pythinker-code/src/cli/update/install-lock.ts index 8f27d4f0..0b6f3834 100644 --- a/apps/pythinker-code/src/cli/update/install-lock.ts +++ b/apps/pythinker-code/src/cli/update/install-lock.ts @@ -1,16 +1,9 @@ -import { randomUUID } from 'node:crypto'; -import { link, mkdir, open, readFile, unlink } from 'node:fs/promises'; +import { mkdir, open, readFile, unlink } from 'node:fs/promises'; import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; -import { isLeaseFresh, type LeaseLimits } from './lease'; - -const LOCK_LEASE_LIMITS: LeaseLimits = { - pidCeilingMs: 6 * 60 * 60 * 1000, - pidlessTtlMs: 30 * 60 * 1000, - clockSkewMs: 5 * 60 * 1000, -}; +const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; export interface UpdateInstallLockRequest { readonly version: string; @@ -22,13 +15,6 @@ export interface UpdateInstallLockHandle { release(): Promise<void>; } -interface LockSnapshot { - readonly raw: string; - readonly ownerId?: string; - readonly pid?: number; - readonly startedAt?: string; -} - function isNotFound(error: unknown): boolean { return ( typeof error === 'object' && error !== null && (error as { code?: string }).code === 'ENOENT' @@ -41,58 +27,20 @@ function isAlreadyExists(error: unknown): boolean { ); } -async function readLockSnapshot(filePath: string): Promise<LockSnapshot | null> { +async function isStaleLock(filePath: string, now: Date): Promise<boolean> { try { const raw = await readFile(filePath, 'utf-8'); - try { - const parsed = JSON.parse(raw) as unknown; - if (typeof parsed !== 'object' || parsed === null) return { raw }; - const lock = parsed as { - readonly ownerId?: unknown; - readonly pid?: unknown; - readonly startedAt?: unknown; - }; - return { - raw, - ownerId: typeof lock.ownerId === 'string' ? lock.ownerId : undefined, - pid: typeof lock.pid === 'number' ? lock.pid : undefined, - startedAt: typeof lock.startedAt === 'string' ? lock.startedAt : undefined, - }; - } catch (error) { - if (error instanceof SyntaxError) return { raw }; - throw error; - } - } catch (error) { - if (isNotFound(error)) return null; - throw error; - } -} - -function isStaleLock(snapshot: LockSnapshot, now: Date): boolean { - return !isLeaseFresh(snapshot, LOCK_LEASE_LIMITS, now); -} - -function hasSameOwner(current: LockSnapshot, expected: LockSnapshot): boolean { - if (expected.ownerId !== undefined) return current.ownerId === expected.ownerId; - return current.ownerId === undefined && current.raw === expected.raw; -} - -/** - * Remove the lock file only when its content still matches `expected`. - * Between two concurrent reclaimers one of them may already have - * replaced the file; deleting a replacement owner's lock would break - * its lease, so mismatches are treated as "not ours". - */ -async function unlinkIfOwned(filePath: string, expected: LockSnapshot): Promise<boolean> { - const current = await readLockSnapshot(filePath); - if (current === null) return true; - if (!hasSameOwner(current, expected)) return false; - try { - await unlink(filePath); - return true; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return true; + const lock = parsed as { readonly startedAt?: unknown }; + if (typeof lock.startedAt !== 'string') return true; + const startedAt = Date.parse(lock.startedAt); + if (!Number.isFinite(startedAt)) return true; + return now.getTime() - startedAt > UPDATE_INSTALL_LOCK_STALE_MS; } catch (error) { if (isNotFound(error)) return true; - throw error; + if (error instanceof SyntaxError) return true; + return false; } } @@ -101,36 +49,23 @@ async function createLockFile( request: UpdateInstallLockRequest, ): Promise<UpdateInstallLockHandle> { const now = request.now ?? new Date(); - const ownerId = randomUUID(); - const stagedPath = `${filePath}.${ownerId}.tmp`; - const file = await open(stagedPath, 'wx', 0o600); + const file = await open(filePath, 'wx', 0o600); try { - try { - await file.writeFile(`${JSON.stringify({ - version: request.version, - ownerId, - pid: process.pid, - startedAt: now.toISOString(), - }, null, 2)}\n`, 'utf-8'); - await file.sync(); - } finally { - await file.close(); - } - // Publish a fully-written record atomically. Creating the destination with - // open('wx') and filling it afterward lets a concurrent reader mistake the - // transient empty file for a stale lock and unlink a live owner's lease. - await link(stagedPath, filePath); + await file.writeFile(`${JSON.stringify({ + version: request.version, + pid: process.pid, + startedAt: now.toISOString(), + }, null, 2)}\n`, 'utf-8'); } finally { - await unlink(stagedPath).catch(() => {}); + await file.close(); } - let released = false; return { filePath, release: async (): Promise<void> => { - if (released) return; - released = true; - await unlinkIfOwned(filePath, { raw: '', ownerId }); + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); }, }; } @@ -146,46 +81,15 @@ export async function tryAcquireUpdateInstallLock( if (!isAlreadyExists(error)) throw error; } - const now = request.now ?? new Date(); - const existing = await readLockSnapshot(filePath); - if (existing === null) { - try { - return await createLockFile(filePath, request); - } catch (error) { - if (isAlreadyExists(error)) return null; - throw error; - } - } - if (!isStaleLock(existing, now)) return null; + if (!(await isStaleLock(filePath, request.now ?? new Date()))) return null; + await unlink(filePath).catch((error: unknown) => { + if (!isNotFound(error)) throw error; + }); - // Reclaim via a sidecar lock: it serializes concurrent reclaimers so - // exactly one of them gets to replace the stale lock file. - const recoveryFilePath = `${filePath}.reclaim`; - let recoveryLock: UpdateInstallLockHandle; try { - recoveryLock = await createLockFile(recoveryFilePath, request); + return await createLockFile(filePath, request); } catch (error) { - if (!isAlreadyExists(error)) throw error; - const abandonedRecovery = await readLockSnapshot(recoveryFilePath); - if (abandonedRecovery !== null && isStaleLock(abandonedRecovery, now)) { - await unlinkIfOwned(recoveryFilePath, abandonedRecovery); - } - return null; - } - - try { - const current = await readLockSnapshot(filePath); - if (current !== null) { - if (!isStaleLock(current, now)) return null; - if (!(await unlinkIfOwned(filePath, current))) return null; - } - try { - return await createLockFile(filePath, request); - } catch (error) { - if (isAlreadyExists(error)) return null; - throw error; - } - } finally { - await recoveryLock.release().catch(() => {}); + if (isAlreadyExists(error)) return null; + throw error; } } diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index 891c61f6..7edc1b67 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -3,87 +3,7 @@ import { z } from 'zod'; import { getUpdateInstallStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; -import { isLeaseFresh, type LeaseLimits } from './lease'; -import { emptyUpdateInstallState, type InstallSource, type UpdateInstallOperation, type UpdateInstallProgress, type UpdateInstallState, type UpdateTarget } from './types'; - -const ACTIVE_LEASE_LIMITS: LeaseLimits = { - pidCeilingMs: 6 * 60 * 60 * 1000, - pidlessTtlMs: 6 * 60 * 60 * 1000, - clockSkewMs: 5 * 60 * 1000, -}; - -/** - * Whether an install is still in flight. It lives here, next to the record it - * reads, because both the preflight and the foreground upgrade command must - * answer it the same way — a second copy of this predicate is how the - * foreground paths came to ignore the lease at all. - */ -export function hasFreshActiveInstall( - state: UpdateInstallState, - now: Date = new Date(), -): boolean { - const active = state.active; - return active !== null && isLeaseFresh(active, ACTIVE_LEASE_LIMITS, now); -} - -/** - * The number of recorded failures for a target. Threshold gates omit - * `operation`: any failure kind at the limit parks the version. Increment - * sites pass their operation so a counter never resumes from another - * operation's attempts. Legacy records without `operation` count toward any - * operation. - */ -export function failureAttemptsFor( - state: UpdateInstallState, - target: UpdateTarget, - operation?: UpdateInstallOperation, -): number { - const failure = state.lastFailure; - if (failure?.version !== target.version) return 0; - if ( - operation !== undefined && - failure.operation !== undefined && - failure.operation !== operation - ) { - return 0; - } - return failure.attempts; -} - -const ABANDONED_INSTALL_MESSAGE = - 'The background install was abandoned: the process that started it exited before recording an outcome.'; - -/** - * One startup reconciliation for an install record whose owner never recorded - * an outcome. The background installer's terminal state write lives in the - * parent process, so when the parent dies the `active` record stays behind and - * no failure is recorded; without this, a version that cannot succeed is - * retried on every launch instead of being parked by the failure counter. - * - * A fresh record is a live lease and is left alone; an abandoned one is - * cleared and recorded as one more failure for its version and operation, so - * the existing threshold logic parks the version after enough of them. - */ -export async function reconcileAbandonedInstall( - state: UpdateInstallState, - now: Date = new Date(), -): Promise<UpdateInstallState> { - const active = state.active; - if (active === null || hasFreshActiveInstall(state, now)) return state; - const reconciled: UpdateInstallState = { - ...state, - active: null, - lastFailure: { - version: active.version, - failedAt: now.toISOString(), - attempts: failureAttemptsFor(state, { version: active.version }, active.operation) + 1, - operation: active.operation, - message: ABANDONED_INSTALL_MESSAGE, - }, - }; - await writeUpdateInstallState(reconciled).catch(() => {}); - return reconciled; -} +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types'; const InstallSourceSchema: z.ZodType<InstallSource> = z.enum([ 'npm-global', @@ -95,19 +15,6 @@ const InstallSourceSchema: z.ZodType<InstallSource> = z.enum([ 'unsupported', ]); -const UpdateInstallOperationSchema = z.enum(['install', 'prepare', 'activate']); -const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); - -const UpdateInstallProgressSchema: z.ZodType<UpdateInstallProgress> = z - .object({ - state: z.enum(['downloading', 'waiting', 'done', 'failed']), - percent: z.number().int().min(0).max(100).optional(), - transferred: z.number().int().nonnegative().optional(), - total: z.number().int().nonnegative().optional(), - updatedAt: z.string().min(1), - }) - .strict(); - const UpdateInstallStateSchema: z.ZodType<UpdateInstallState> = z .object({ active: z @@ -115,36 +22,14 @@ const UpdateInstallStateSchema: z.ZodType<UpdateInstallState> = z version: z.string().min(1), source: InstallSourceSchema, startedAt: z.string().min(1), - pid: z.number().int().positive().optional(), - operation: UpdateInstallOperationSchema.optional(), - jobId: z.uuid().optional(), - progress: UpdateInstallProgressSchema.optional(), }) .strict() .nullable(), - pending: z - .object({ - jobId: z.uuid(), - source: z.literal('homebrew'), - version: z.string().min(1), - preparedAt: z.string().min(1), - requestedBy: z.enum(['automatic', 'manual']), - formulaUrl: z.url(), - artifactKind: z.literal('source'), - artifactSha256: Sha256Schema, - formulaFileSha256: Sha256Schema, - artifactPath: z.string().min(1), - }) - .strict() - .nullable() - .default(null), lastFailure: z .object({ version: z.string().min(1), failedAt: z.string().min(1), attempts: z.number().int().min(1), - operation: UpdateInstallOperationSchema.optional(), - message: z.string().min(1).optional(), }) .strict() .nullable(), @@ -153,7 +38,6 @@ const UpdateInstallStateSchema: z.ZodType<UpdateInstallState> = z version: z.string().min(1), installedAt: z.string().min(1), notifiedAt: z.string().min(1).nullable(), - unverified: z.string().min(1).optional(), }) .strict() .nullable(), @@ -176,5 +60,5 @@ export async function writeUpdateInstallState( value: UpdateInstallState, filePath: string = getUpdateInstallStateFile(), ): Promise<void> { - await writeJsonFile(filePath, UpdateInstallStateSchema, value, { durable: true }); + await writeJsonFile(filePath, UpdateInstallStateSchema, value); } diff --git a/apps/pythinker-code/src/cli/update/lease.ts b/apps/pythinker-code/src/cli/update/lease.ts deleted file mode 100644 index 371a02b4..00000000 --- a/apps/pythinker-code/src/cli/update/lease.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * One rule for "is this install lease still held", shared by the install lock - * file and the active-install record. - * - * Both used to answer it with their own copy of `isProcessRunning` and their own - * age arithmetic, and they drifted: a live pid used to hold either lease forever, - * with no ceiling, so a recycled pid wedged every update path permanently. - */ - -export interface LeaseRecord { - /** Owner process id; absent in leases written before pids were recorded. */ - readonly pid?: number; - readonly startedAt?: string; -} - -export interface LeaseLimits { - /** Ceiling on a lease whose owner pid is still alive. The OS reuses pids. */ - readonly pidCeilingMs: number; - /** Ceiling on a lease with no recorded pid, where age is the only signal. */ - readonly pidlessTtlMs: number; - /** A clock rollback within this tolerance must not orphan a live install. */ - readonly clockSkewMs: number; -} - -/** - * Liveness probe via `kill(pid, 0)`. EPERM means the process exists but is - * owned by another user, which is still "running" for lease purposes. - */ -export function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPERM'; - } -} - -/** - * A lease is held while its owner is alive *and* it is younger than the - * ceiling. A lease with no usable timestamp can never age out, so it is never - * fresh; a far-future timestamp is not fresh either. - */ -export function isLeaseFresh(record: LeaseRecord, limits: LeaseLimits, now: Date): boolean { - const startedAt = record.startedAt === undefined ? Number.NaN : Date.parse(record.startedAt); - if (!Number.isFinite(startedAt)) return false; - const age = now.getTime() - startedAt; - if (age < -limits.clockSkewMs) return false; - if (record.pid !== undefined) { - return isProcessRunning(record.pid) && age < limits.pidCeilingMs; - } - return age < limits.pidlessTtlMs; -} diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index 1e15313e..36d520fa 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -1,31 +1,19 @@ import { spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { homedir } from 'node:os'; -import type { Readable } from 'node:stream'; - -import { gt, gte, valid } from 'semver'; import { log, type Logger } from '@pymodel/pythinker-code-sdk'; import type { TelemetryProperties } from '@pymodel/pythinker-telemetry'; import { + PYTHINKER_CODE_OFFICIAL_INSTALL_URL, NATIVE_INSTALL_COMMAND_UNIX, NATIVE_INSTALL_COMMAND_WIN, - PYTHINKER_CODE_INSTALL_SH_URL, } from '#/constant/app'; import { loadTuiConfig } from '#/tui/config'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { readUpdateCache } from './cache'; -import { formatErrorMessage } from './format-error'; import { tryAcquireUpdateInstallLock } from './install-lock'; -import { - emptyUpdateInstallState, - failureAttemptsFor, - hasFreshActiveInstall, - readUpdateInstallState, - reconcileAbandonedInstall, - writeUpdateInstallState, -} from './install-state'; +import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; import { CHANGELOG_URL, promptForInstallChoice, @@ -33,7 +21,6 @@ import { type InstallPromptOptions, } from './prompt'; import { refreshUpdateCache } from './refresh'; -import { isTargetInstallable, selectUpdateTarget } from './select'; import { appendRolloutDecisionLog, decidePassiveUpdateTarget, @@ -48,25 +35,14 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, - type UpdateInstallProgress, type UpdateInstallState, - type UpdateCache, type UpdateManifest, type UpdatePreflightResult, - type UpdateRequestOrigin, type UpdateTarget, } from './types'; -import { - verifyInstalledVersion, - type InstallOutcome, - type InstallVerification, -} from './verify-install'; export type { UpdatePreflightResult } from './types'; -/** Reused for the paths that never reach verification (a failed install). */ -const OK_VERIFICATION: InstallVerification = { ok: true }; - export interface RunUpdatePreflightOptions { readonly stdout?: { write(chunk: string): boolean }; readonly stderr?: { write(chunk: string): boolean }; @@ -76,8 +52,8 @@ export interface RunUpdatePreflightOptions { } const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; +const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000; -const UPDATE_HELPER_ENV = 'PYTHINKER_CODE_UPDATE_HELPER'; type UpdateLogger = Pick<Logger, 'info' | 'warn'>; @@ -89,33 +65,6 @@ function bunCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'bun.exe' : 'bun'; } -/** - * Node ≥18.20/20.12 refuses to spawn a `.cmd`/`.bat` file directly - * (CVE-2024-27980) and fails with `EINVAL` — which is every npm-family update - * on Windows: `npm.cmd`, `pnpm.cmd`, `yarn.cmd`. The command interpreter runs - * them instead. It is spelled out as argv rather than `shell: true` so the - * exact command line is visible here (and asserted in tests) instead of being - * assembled by Node's string joining. - */ -function viaCommandInterpreter(command: SpawnCommand): SpawnCommand { - return { - ...command, - cmd: process.env['ComSpec'] ?? 'cmd.exe', - args: ['/d', '/s', '/c', command.cmd, ...command.args], - }; -} - -/** True for the Windows package-manager shims that cannot be spawned directly. */ -export function isWindowsShim(cmd: string, platform: NodeJS.Platform): boolean { - if (platform !== 'win32') return false; - const lower = cmd.toLowerCase(); - return lower.endsWith('.cmd') || lower.endsWith('.bat'); -} - -function spawnable(command: SpawnCommand, platform: NodeJS.Platform): SpawnCommand { - return isWindowsShim(command.cmd, platform) ? viaCommandInterpreter(command) : command; -} - export function installCommandFor( source: InstallSource, version: string, @@ -139,9 +88,7 @@ export function installCommandFor( } } -export type AutomaticUpdateMode = 'background-install' | 'restart-install' | 'manual'; - -export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': @@ -149,28 +96,19 @@ export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform case 'bun-global': return true; case 'homebrew': - // Foreground installUpdate() never owns Homebrew. Passive and explicit - // TUI updates use the separate prepare-on-restart lifecycle instead. + // Homebrew upgrade may mutate other dependents and the formula can lag + // behind the CDN release — prompt the user to run `brew upgrade` manually. return false; case 'native': - return true; + return platform !== 'win32'; case 'unsupported': return false; } } -export function automaticUpdateModeFor( - source: InstallSource, - platform: NodeJS.Platform, -): AutomaticUpdateMode { - if (source === 'homebrew') return 'restart-install'; - return canAutoInstall(source, platform) ? 'background-install' : 'manual'; -} - interface SpawnCommand { readonly cmd: string; readonly args: readonly string[]; - readonly env?: Readonly<Record<string, string>>; } export function spawnForSource( @@ -180,57 +118,50 @@ export function spawnForSource( ): SpawnCommand { switch (source) { case 'npm-global': - return spawnable( - { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] }, - platform, - ); + return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; case 'pnpm-global': - return spawnable( - { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }, - platform, - ); + return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; case 'yarn-global': - return spawnable( - { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] }, - platform, - ); + return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] }; case 'bun-global': return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] }; case 'homebrew': return { cmd: 'brew', args: ['upgrade', 'pythinker-code'] }; case 'native': - if (platform === 'win32') { - // install.ps1 reads $env:PYTHINKER_VERSION when set instead of - // fetching the CDN's current latest, so the version this preflight - // decided on is the one actually installed. - return { - cmd: 'powershell.exe', - args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', NATIVE_INSTALL_COMMAND_WIN], - env: { PYTHINKER_VERSION: version }, - }; - } // `curl … | bash` reports only the trailing bash's exit status, so a // failed download (curl can't connect → empty stdin → bash exits 0) // would look like a successful update. `pipefail` makes the pipeline // surface curl's non-zero status so installUpdate() rejects and we warn // instead of printing "Updated …". - // - // `-s -- --version` pins the install to the version this preflight - // decided on, the same guarantee PYTHINKER_VERSION gives on Windows. - // Without it the script installs whatever the CDN currently calls - // latest, which can differ from the rollout's target. - return { - cmd: 'bash', - args: [ - '-c', - `set -o pipefail; curl -fsSL ${PYTHINKER_CODE_INSTALL_SH_URL} | bash -s -- --version ${version}`, - ], - }; + return { cmd: 'bash', args: ['-c', `set -o pipefail; ${NATIVE_INSTALL_COMMAND_UNIX}`] }; case 'unsupported': throw new Error('unsupported install source cannot be auto-installed'); } } +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Resolve a spawn target from `spawnForSource` to an absolute executable path + * via PATH, refusing hits inside the current working directory: the update + * preflight runs before the workspace trust gate, so a package-manager binary + * planted in an untrusted workspace must never be executed. On win32 the + * resolved path is quoted because the spawn goes through cmd.exe (shell: + * true) and paths like `C:\Program Files\...` would otherwise split. Returns + * undefined when the command cannot be safely resolved. + */ +function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | undefined { + const resolved = resolveCommandPath(cmd); + if (resolved === undefined) return undefined; + return platform === 'win32' ? `"${resolved}"` : resolved; +} + +const THIRD_PARTY_SOURCE_NOTE = + '\nNote: Third-party sources may lag behind the official release.\n' + + `For the latest updates, use the official installer: ${PYTHINKER_CODE_OFFICIAL_INSTALL_URL}\n`; + export function renderManualUpdateMessage( currentVersion: string, target: UpdateTarget, @@ -249,30 +180,23 @@ export function renderManualUpdateMessage( sourceDesc = 'homebrew'; break; case 'native': - sourceDesc = 'native install.'; + sourceDesc = 'native (windows). Auto-update is not supported on this platform.'; break; case 'unsupported': sourceDesc = 'unsupported package manager or layout.'; break; } - const homebrewHint = - source === 'homebrew' - ? 'Automatic Homebrew preparation is disabled or could not complete.\n' - : ''; return ( `A newer version of ${NPM_PACKAGE_NAME} is available ` + `(${currentVersion} -> ${target.version}).\n` + `Detected install source: ${sourceDesc}\n` + `To update manually, run: ${installCommand}\n` + - homebrewHint + (source === 'homebrew' ? THIRD_PARTY_SOURCE_NOTE : '') ); } export function renderInstallSuccessMessage(target: UpdateTarget): string { - return ( - `Updated ${NPM_PACKAGE_NAME} to ${target.version}. ` + - 'Close this terminal and open a new one to use the new version.\n' - ); + return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`; } function renderBackgroundInstallSuccessNotice(version: string): string { @@ -280,6 +204,10 @@ function renderBackgroundInstallSuccessNotice(version: string): string { return `Pythinker Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; } +function refreshInBackground(): void { + void refreshUpdateCache().catch(() => {}); +} + /** Telemetry properties describing where this device sits in the rollout. */ interface RolloutTelemetry { readonly rollout_bucket: number; @@ -421,6 +349,18 @@ function nowIso(): string { return new Date().toISOString(); } +function failureAttemptsFor(state: UpdateInstallState, target: UpdateTarget): number { + return state.lastFailure?.version === target.version ? state.lastFailure.attempts : 0; +} + +function hasFreshActiveInstall(state: UpdateInstallState, target: UpdateTarget): boolean { + const active = state.active; + if (active === null || active.version !== target.version) return false; + const startedAt = Date.parse(active.startedAt); + if (!Number.isFinite(startedAt)) return false; + return Date.now() - startedAt < AUTO_INSTALL_ACTIVE_TTL_MS; +} + async function showPendingBackgroundInstallNotice( state: UpdateInstallState, currentVersion: string, @@ -441,6 +381,7 @@ async function showPendingBackgroundInstallNotice( }); const nextState: UpdateInstallState = { ...state, + active: null, lastFailure: null, lastSuccess: { ...success, @@ -452,11 +393,7 @@ async function showPendingBackgroundInstallNotice( } const active = state.active; - if ( - active === null || - active.version !== currentVersion || - hasFreshActiveInstall(state) - ) return state; + if (active === null || active.version !== currentVersion) return state; if (success !== null && success.version === currentVersion && success.notifiedAt !== null) { return state; } @@ -491,13 +428,13 @@ async function showPendingBackgroundInstallNotice( * prompt. Migrated from pythinker-cli, where the variable gated all auto-update * behavior. Accepts the usual truthy values (`1`/`true`/`yes`/`on`). */ -export function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { +function isAutoUpdateDisabledByEnv(env: NodeJS.ProcessEnv = process.env): boolean { const truthy = (value?: string): boolean => ['1', 'true', 'yes', 'on'].includes((value ?? '').trim().toLowerCase()); return truthy(env['PYTHINKER_CODE_NO_AUTO_UPDATE']) || truthy(env['PYTHINKER_CLI_NO_AUTO_UPDATE']); } -export async function shouldAutoInstallUpdates(): Promise<boolean> { +async function shouldAutoInstallUpdates(): Promise<boolean> { try { const config = await loadTuiConfig(); return config.upgrade.autoInstall; @@ -515,8 +452,6 @@ function trackUpdatePrompted( rolloutTelemetry: RolloutTelemetry, ): void { trackUpdateEvent(track, 'update_prompted', { - current: currentVersion, - latest: target.version, current_version: currentVersion, target_version: target.version, source, @@ -558,41 +493,34 @@ async function promptInstall( target: UpdateTarget, source: InstallSource, installCommand: string, - previousFailure: string | undefined, ): Promise<InstallPromptChoiceValue> { const options: InstallPromptOptions = { currentVersion, target, installSource: source, installCommand, - previousFailure, }; return promptForInstallChoice(options); } -/** - * A recorded failure is only worth showing when it is about the version the - * prompt is offering — an older version's failure is stale noise. - */ -function failureMessageFor( - state: UpdateInstallState, - target: UpdateTarget, -): string | undefined { - const failure = state.lastFailure; - if (failure === null || failure.version !== target.version) return undefined; - return failure.message; -} - export async function installUpdate( source: InstallSource, version: string, platform: NodeJS.Platform, -): Promise<InstallOutcome> { - const { cmd, args, env } = spawnForSource(source, version, platform); +): Promise<void> { + const { cmd, args } = spawnForSource(source, version, platform); + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + throw new Error(`${cmd} was not found in PATH; cannot install the update`); + } await new Promise<void>((resolve, reject) => { - const child = spawn(cmd, [...args], { + // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the + // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without + // a shell, so run through the shell on win32. The version is a validated + // semver and the package name is a constant, so args are shell-safe. + const child = spawn(resolvedCmd, [...args], { stdio: 'inherit', - env: env === undefined ? undefined : { ...process.env, ...env }, + shell: platform === 'win32' ? true : undefined, }); child.once('error', reject); child.once('exit', (code, signal) => { @@ -604,243 +532,6 @@ export async function installUpdate( reject(new Error(`${cmd} exited with ${detail}`)); }); }); - // Exit code 0 is the installer's opinion; this is the fact. Rejecting here - // routes a silent no-op install into the same failure reporting a crashed - // installer gets, instead of printing "Updated …" over an unchanged binary. - const verification = await verifyInstalledVersion(source, version); - if (!verification.ok) throw new Error(verification.reason); - // Returned so the caller can record *why* a success is unproven; see - // verify-install.ts for the fail-open rule. - return { unverified: verification.unverified }; -} - -/** Keep the tail only: installers can be chatty, and the state file is small. */ -const INSTALLER_STDERR_TAIL_CHARS = 2000; -/** At most one active-record progress write every 2s; terminal states bypass it. */ -const INSTALLER_PROGRESS_WRITE_INTERVAL_MS = 2_000; -const INSTALLER_PROGRESS_PREFIX = 'progress: '; - -function isInstallerProgressState(value: string): value is UpdateInstallProgress['state'] { - return value === 'downloading' || value === 'waiting' || value === 'done' || value === 'failed'; -} - -/** - * Parse one `progress: key=value key=value` line from the installer's stderr. - * Unknown or malformed keys are skipped — an installer from a different - * release must never crash the parent. Returns null when the line carries no - * usable state. - */ -function parseInstallerProgressLine(line: string): UpdateInstallProgress | null { - let state: UpdateInstallProgress['state'] | undefined; - let percent: number | undefined; - let transferred: number | undefined; - let total: number | undefined; - for (const field of line.slice(INSTALLER_PROGRESS_PREFIX.length).split(/\s+/u)) { - const eq = field.indexOf('='); - if (eq <= 0) continue; - const key = field.slice(0, eq); - const value = field.slice(eq + 1); - switch (key) { - case 'state': - if (isInstallerProgressState(value)) state = value; - break; - case 'percent': - if (/^(?:100|[0-9]{1,2})$/u.test(value)) percent = Number(value); - break; - case 'transferred': - if (/^[0-9]+$/u.test(value)) transferred = Number(value); - break; - case 'total': - if (/^[0-9]+$/u.test(value)) total = Number(value); - break; - default: - break; - } - } - if (state === undefined) return null; - return { state, percent, transferred, total, updatedAt: nowIso() }; -} - -/** - * Read the installer's stderr as lines. `progress: …` lines are parsed and - * handed to `onProgress`; they never enter the failure tail, or a long - * download would evict the very error text the tail exists to preserve. All - * other lines are kept in the trailing `INSTALLER_STDERR_TAIL_CHARS` window. - * Returns a getter for that tail. - */ -function captureStderrTail( - child: ReturnType<typeof spawn>, - onProgress: (update: UpdateInstallProgress) => void, -): () => string | undefined { - // Typed `Readable | null`, but absent entirely when stderr was not piped. - const stream: Readable | null | undefined = child.stderr; - if (stream === null || stream === undefined) return () => undefined; - let tail = ''; - let partial = ''; - stream.setEncoding('utf8'); - stream.on('data', (chunk: string) => { - const lines = (partial + chunk).split('\n'); - partial = lines.pop() ?? ''; - for (const line of lines) { - if (line.startsWith(INSTALLER_PROGRESS_PREFIX)) { - const update = parseInstallerProgressLine(line); - if (update !== null) onProgress(update); - } else { - tail = (tail + line + '\n').slice(-INSTALLER_STDERR_TAIL_CHARS); - } - } - }); - // A detached installer outliving this process must not crash it, and the - // pipe must not hold the event loop open on the way out. `child.stderr` is - // typed as a plain Readable, but the pipe is a Socket at runtime and that is - // where unref lives. - stream.on('error', () => {}); - (stream as Readable & { unref?: () => void }).unref?.(); - return () => { - // A final partial line without a newline is still installer text; include - // it unless it is a truncated progress line. - const complete = partial.length === 0 || partial.startsWith(INSTALLER_PROGRESS_PREFIX) - ? tail - : tail + partial; - const trimmed = complete.trim(); - return trimmed.length === 0 ? undefined : trimmed; - }; -} - -function describeChildExit(cmd: string, code: number | null, signal: NodeJS.Signals | null): string { - const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`; - return `${cmd} exited with ${detail}`; -} - -async function waitForChildSpawn(child: ReturnType<typeof spawn>): Promise<void> { - await new Promise<void>((resolve, reject) => { - const onSpawn = (): void => { - child.off('error', onError); - child.on('error', () => {}); - resolve(); - }; - const onError = (error: Error): void => { - child.off('spawn', onSpawn); - reject(error); - }; - child.once('spawn', onSpawn); - child.once('error', onError); - }); -} - -function updateHelperCommand( - operation: 'prepare-homebrew', - jobId: string, - version: string, - requestedBy: UpdateRequestOrigin, -): SpawnCommand { - const launcherPath = process.argv[1]; - if (launcherPath === undefined) throw new Error('cannot locate the Pythinker Code launcher'); - return { - cmd: process.execPath, - args: [launcherPath, '__update_helper', operation, jobId, version, requestedBy], - }; -} - -function preparedVersionCoversTarget(preparedVersion: string, targetVersion: string): boolean { - return valid(preparedVersion) !== null && valid(targetVersion) !== null && gte(preparedVersion, targetVersion); -} - -/** - * Whether the target is strictly newer than the version an active install is - * working on — the newer update can only start after the running one finishes. - */ -function targetSupersedesInstallingVersion( - installingVersion: string, - targetVersion: string, -): boolean { - return valid(installingVersion) !== null && valid(targetVersion) !== null && gt(targetVersion, installingVersion); -} - -async function startBackgroundHomebrewPreparation( - state: UpdateInstallState, - currentVersion: string, - target: UpdateTarget, - requestedBy: UpdateRequestOrigin, - track: RunUpdatePreflightOptions['track'], - logger: UpdateLogger, - rolloutTelemetry: RolloutTelemetry, -): Promise<boolean> { - const lock = await tryAcquireUpdateInstallLock({ version: target.version }); - if (lock === null) return false; - - try { - const freshState = await readUpdateInstallState().catch(() => state); - if ( - hasFreshActiveInstall(freshState) || - (freshState.pending !== null && preparedVersionCoversTarget(freshState.pending.version, target.version)) || - failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD - ) { - return false; - } - - const jobId = randomUUID(); - // A retained older verified `pending` stays installable if this newer - // preparation fails; the helper's success path replaces it. - const startedState: UpdateInstallState = { - ...freshState, - active: { - version: target.version, - source: 'homebrew', - operation: 'prepare', - jobId, - startedAt: nowIso(), - }, - }; - await writeUpdateInstallState(startedState); - - try { - const { cmd, args } = updateHelperCommand( - 'prepare-homebrew', - jobId, - target.version, - requestedBy, - ); - const child = spawn(cmd, [...args], { - cwd: homedir(), - detached: true, - env: { ...process.env, [UPDATE_HELPER_ENV]: '1' }, - stdio: 'ignore', - }); - await waitForChildSpawn(child); - child.unref(); - } catch (error) { - const attempts = failureAttemptsFor(startedState, target, 'prepare') + 1; - await writeUpdateInstallState({ - ...startedState, - active: null, - lastFailure: { - version: target.version, - failedAt: nowIso(), - attempts, - operation: 'prepare', - message: formatErrorMessage(error), - }, - }).catch(() => {}); - throw error; - } - - trackUpdateEvent(track, 'update_background_prepare_started', { - current_version: currentVersion, - target_version: target.version, - source: 'homebrew', - ...rolloutTelemetry, - }); - logUpdateInfo(logger, 'background update preparation started', { - currentVersion, - targetVersion: target.version, - source: 'homebrew', - jobId, - }); - return true; - } finally { - await lock.release().catch(() => {}); - } } async function startBackgroundInstall( @@ -852,21 +543,20 @@ async function startBackgroundInstall( track: RunUpdatePreflightOptions['track'], logger: UpdateLogger, rolloutTelemetry: RolloutTelemetry, -): Promise<boolean> { +): Promise<void> { const lock = await tryAcquireUpdateInstallLock({ version: target.version }); - if (lock === null) return false; + if (lock === null) return; - let finalizerOwnsLock = false; try { const freshState = await readUpdateInstallState().catch(() => state); if ( - hasFreshActiveInstall(freshState) || + hasFreshActiveInstall(freshState, target) || failureAttemptsFor(freshState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD ) { - return false; + return; } - let startedState: UpdateInstallState = { + const startedState: UpdateInstallState = { ...freshState, active: { version: target.version, @@ -887,45 +577,15 @@ async function startBackgroundInstall( source, }); - const { cmd, args, env } = spawnForSource(source, target.version, platform); - // The child can exit before the pid-persist below finishes, so buffer - // the terminal outcome until the handler is "ready". - let ready = false; + const { cmd, args } = spawnForSource(source, target.version, platform); let settled = false; - let pendingOutcome: { succeeded: boolean; reason: string } | undefined; - // Progress writes are fire-and-forget, and the state file is written as a - // temp file plus rename — so the last rename wins. The installer's terminal - // `state=done` line bypasses the throttle and writes just as the child - // exits, so without ordering that write can land *after* the outcome write - // and restore `active` while dropping `lastSuccess`. The next launch reads - // that as an abandoned install and records a failure for a version that - // installed cleanly. One chain keeps the writes ordered and gives `finish` - // something to drain. - let progressWrites: Promise<void> = Promise.resolve(); - const finish = async (succeeded: boolean, reason: string): Promise<void> => { - if (!ready) { - pendingOutcome ??= { succeeded, reason }; - return; - } + const finish = (succeeded: boolean): void => { if (settled) return; settled = true; - // `settled` already stops new progress writes; drain the ones in flight so - // none of them renames over the outcome below. - await progressWrites; - // An installer that exits 0 without replacing the binary must not be - // recorded as a success: the footer would advertise "restart to apply" - // for a version that never runs, on every launch, forever. - const verification = succeeded - ? await verifyInstalledVersion(source, target.version) - : OK_VERIFICATION; - const installed = succeeded && verification.ok; - const outcomeReason = verification.ok ? reason : verification.reason; - const attempts = failureAttemptsFor(startedState, target, 'install') + 1; - const stderrTail = readStderrTail(); - const message = stderrTail === undefined ? outcomeReason : `${outcomeReason}: ${stderrTail}`; + const attempts = failureAttemptsFor(startedState, target) + 1; - const nextState: UpdateInstallState = installed + const nextState: UpdateInstallState = succeeded ? { ...startedState, active: null, @@ -934,7 +594,6 @@ async function startBackgroundInstall( version: target.version, installedAt: nowIso(), notifiedAt: null, - unverified: verification.ok ? verification.unverified : undefined, }, } : { @@ -944,123 +603,54 @@ async function startBackgroundInstall( version: target.version, failedAt: nowIso(), attempts, - operation: 'install', - message, }, }; - try { - await writeUpdateInstallState(nextState).catch(() => {}); - if (installed) { - trackUpdateEvent(track, 'update_background_install_succeeded', { - target_version: target.version, - source, - }); - logUpdateInfo(logger, 'background update install succeeded', { - targetVersion: target.version, - source, - // Present when the install was recorded without proof, so a report - // of "it says updated but it did not" is answerable from the log. - unverified: verification.ok ? verification.unverified : undefined, - }); - return; - } - trackUpdateEvent(track, 'update_background_install_failed', { + void writeUpdateInstallState(nextState).catch(() => {}); + if (succeeded) { + trackUpdateEvent(track, 'update_background_install_succeeded', { target_version: target.version, source, - attempts, }); - logUpdateWarn(logger, 'background update install failed', { + logUpdateInfo(logger, 'background update install succeeded', { targetVersion: target.version, source, - attempts, - message, }); - } finally { - await lock.release().catch(() => {}); + return; } + trackUpdateEvent(track, 'update_background_install_failed', { + target_version: target.version, + source, + attempts, + }); + logUpdateWarn(logger, 'background update install failed', { + targetVersion: target.version, + source, + attempts, + }); }; - const child = spawn(cmd, [...args], { + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + // The package manager cannot be resolved to an absolute path outside + // the cwd — record a normal install failure instead of spawning a bare + // command name that Windows would resolve into the untrusted workspace. + finish(false); + return; + } + const child = spawn(resolvedCmd, [...args], { detached: true, - // A detached child gets its own console window on Windows regardless - // of stdio; stdio: 'ignore' alone does not suppress it. - windowsHide: platform === 'win32', - // stdout stays discarded (install progress is noise); stderr is piped so - // the installer's machine-readable progress lines can be recorded and a - // failure still keeps the installer's own error text. - stdio: ['ignore', 'ignore', 'pipe'], - env: env === undefined ? undefined : { ...process.env, ...env }, - }); - let lastProgressWriteAt = 0; - const recordInstallerProgress = (update: UpdateInstallProgress): void => { - // Once the outcome is being written, progress is history: writing it would - // undo the terminal record. - if (settled) return; - // Terminal states always persist; intermediate ones at most every 2s. - const terminal = update.state === 'done' || update.state === 'failed'; - if ( - !terminal - && Date.now() - lastProgressWriteAt < INSTALLER_PROGRESS_WRITE_INTERVAL_MS - ) return; - if (startedState.active === null) return; - lastProgressWriteAt = Date.now(); - const nextState: UpdateInstallState = { - ...startedState, - // Carry the pid explicitly: a progress line can arrive while the pid - // write is still in flight, and writing the pre-pid record over it - // would strip the pid this record's liveness check depends on. - active: { - ...startedState.active, - pid: child.pid ?? startedState.active.pid, - progress: update, - }, - }; - progressWrites = progressWrites - .then(() => writeUpdateInstallState(nextState)) - .catch((error) => { - // A progress write is best-effort; it must never reject the spawn path - // and must not break the chain for the writes queued behind it. - logUpdateWarn(logger, 'could not record installer progress', { - targetVersion: target.version, - source, - error: formatErrorMessage(error), - }); - }); - }; - const readStderrTail = captureStderrTail(child, recordInstallerProgress); - child.once('error', (error) => { void finish(false, formatErrorMessage(error)); }); - child.once('exit', (code, signal) => { - void finish(code === 0, describeChildExit(cmd, code, signal)); + stdio: 'ignore', + shell: platform === 'win32' ? true : undefined, + // On Windows a detached child gets its own console window; with shell:true + // that window would flash during a passive background update. Hide it so + // the silent updater stays silent. + windowsHide: platform === 'win32' ? true : undefined, }); - if (child.pid !== undefined && child.pid > 0) { - const stateWithPid: UpdateInstallState = { - ...startedState, - active: startedState.active === null - ? null - : { ...startedState.active, pid: child.pid }, - }; - try { - await writeUpdateInstallState(stateWithPid); - startedState = stateWithPid; - } catch { - // The pre-spawn state remains a conservative lease and eventually - // expires even when persisting the child pid fails. - } - } - // From here on the finalizer owns the lock and releases it only after - // the terminal state write settles, so a concurrent preflight cannot - // start a second install for the same target mid-finalize. + child.once('error', () => { finish(false); }); + child.once('exit', (code) => { finish(code === 0); }); child.unref(); - finalizerOwnsLock = true; - ready = true; - if (pendingOutcome !== undefined) void finish(pendingOutcome.succeeded, pendingOutcome.reason); - return true; - // When startup failed before handoff, release the lock here; the - // finalizer releases it once the terminal state write completes. } finally { - if (!finalizerOwnsLock) { - await lock.release().catch(() => {}); - } + await lock.release().catch(() => {}); } } @@ -1074,41 +664,13 @@ async function tryStartAutomaticBackgroundInstall( logger: UpdateLogger, rolloutTelemetry: RolloutTelemetry, ): Promise<boolean> { - const autoInstallUpdates = await shouldAutoInstallUpdates(); - if (!autoInstallUpdates) return false; + const sourceCanAutoInstall = canAutoInstall(source, platform); + const autoInstallUpdates = sourceCanAutoInstall ? await shouldAutoInstallUpdates() : false; + if (!autoInstallUpdates || !sourceCanAutoInstall) return false; if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { return false; } - if (hasFreshActiveInstall(installState)) return true; - - if (source === 'homebrew') { - if ( - installState.pending !== null && - preparedVersionCoversTarget(installState.pending.version, target.version) - ) return true; - try { - await startBackgroundHomebrewPreparation( - installState, - currentVersion, - target, - 'automatic', - track, - logger, - rolloutTelemetry, - ); - return true; - } catch (error) { - logUpdateWarn(logger, 'background update preparation could not start', { - targetVersion: target.version, - source, - error: formatErrorMessage(error), - }); - return false; - } - } - - if (!canAutoInstall(source, platform)) return false; - try { + if (!hasFreshActiveInstall(installState, target)) { await startBackgroundInstall( installState, currentVersion, @@ -1118,188 +680,9 @@ async function tryStartAutomaticBackgroundInstall( track, logger, rolloutTelemetry, - ); - return true; - } catch (error) { - logUpdateWarn(logger, 'background update install could not start', { - targetVersion: target.version, - source, - error: formatErrorMessage(error), - }); - return false; - } -} - -export type ManualUpdateResult = - | { readonly status: 'up-to-date' } - | { readonly status: 'check-failed'; readonly message: string } - | { readonly status: 'started'; readonly version: string; readonly installOnRestart: boolean } - | { - readonly status: 'in-progress'; - readonly installingVersion: string; - /** Present only when it is newer than the version being installed. */ - readonly targetVersion?: string; - readonly installOnRestart: boolean; - readonly readyToInstall: boolean; - } - | { - readonly status: 'manual'; - readonly version: string; - readonly command: string; - readonly source: InstallSource; - } - | { - readonly status: 'failed'; - readonly version: string; - readonly attempts: number; - readonly failedAt: string; - readonly message?: string; - readonly command: string; - }; - -/** - * Explicit user-requested update (TUI `/update`). Unlike the passive - * preflight it ignores the rollout delay and the `auto_install` preference — - * the user asked, so we install or prepare the Homebrew update — while reusing - * the background lifecycle, lock, and failure bookkeeping. The env kill-switch is also ignored: - * it gates automatic behavior, not explicit requests (matching `pythinker upgrade`). - */ -export async function startManualUpdate( - currentVersion: string, - logger: UpdateLogger = log, -): Promise<ManualUpdateResult> { - let cache: UpdateCache; - try { - cache = await refreshUpdateCache(); - } catch (error) { - return { status: 'check-failed', message: formatErrorMessage(error) }; - } - const target = selectUpdateTarget(currentVersion, cache.latest); - if (target === null) return { status: 'up-to-date' }; - - const platform = process.platform; - const source = await detectInstallSource().catch(() => 'unsupported' as const); - // A native install consumes the manifest's platform artifact; without one - // the update cannot succeed, so treat it as nothing to update. - if (!isTargetInstallable(source, cache.manifest)) { - return { status: 'up-to-date' }; - } - let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); - installState = await reconcileAbandonedInstall(installState); - if (hasFreshActiveInstall(installState)) { - const installingVersion = installState.active?.version ?? target.version; - return { - status: 'in-progress', - installingVersion, - targetVersion: targetSupersedesInstallingVersion(installingVersion, target.version) - ? target.version - : undefined, - installOnRestart: installState.active?.source === 'homebrew', - readyToInstall: false, - }; - } - if ( - source === 'homebrew' && - installState.pending !== null && - preparedVersionCoversTarget(installState.pending.version, target.version) - ) { - const pending = installState.pending; - if (pending.requestedBy === 'automatic') { - try { - await writeUpdateInstallState({ - ...installState, - pending: { ...pending, requestedBy: 'manual' }, - }); - } catch (error) { - return { status: 'check-failed', message: formatErrorMessage(error) }; - } - } - return { - status: 'in-progress', - installingVersion: pending.version, - installOnRestart: true, - readyToInstall: true, - }; - } - // A version parks once the failure counter hits the threshold: the - // background lifecycle refuses to touch it again, so claiming "started" or - // "in-progress" would be a lie and another retry would only burn another - // launch on an install that already failed. Report the recorded failure - // and the copyable command instead. - const failure = installState.lastFailure; - if ( - failure !== null && - failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD - ) { - return { - status: 'failed', - version: target.version, - attempts: failure.attempts, - failedAt: failure.failedAt, - message: failure.message, - command: installCommandFor(source, target.version, platform), - }; - } - - try { - const rolloutTelemetry = rolloutTelemetryFor( - resolveUpdateDeviceId(), - target.version, - cache.manifest, - true, - ); - if (source === 'homebrew') { - const started = await startBackgroundHomebrewPreparation( - installState, - currentVersion, - target, - 'manual', - undefined, - logger, - rolloutTelemetry, - ); - // Another process holds the lock or the under-lock re-check refused: - // nothing new was started, so don't claim it was. - if (!started) { - return { - status: 'in-progress', - installingVersion: target.version, - installOnRestart: true, - readyToInstall: false, - }; - } - return { status: 'started', version: target.version, installOnRestart: true }; - } - if (!canAutoInstall(source, platform)) { - return { - status: 'manual', - version: target.version, - command: installCommandFor(source, target.version, platform), - source, - }; - } - const started = await startBackgroundInstall( - installState, - currentVersion, - target, - source, - platform, - undefined, - logger, - rolloutTelemetry, - ); - if (!started) { - return { - status: 'in-progress', - installingVersion: target.version, - installOnRestart: false, - readyToInstall: false, - }; - } - return { status: 'started', version: target.version, installOnRestart: false }; - } catch (error) { - return { status: 'check-failed', message: formatErrorMessage(error) }; + ).catch(() => {}); } + return true; } export function decideUpdateAction( @@ -1331,7 +714,6 @@ export async function runUpdatePreflight( const deviceId = resolveUpdateDeviceId(); const bypassRollout = isRolloutBypassedByExperimentalEnv(); let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); - installState = await reconcileAbandonedInstall(installState); if (isInteractive) { installState = await showPendingBackgroundInstallNotice( installState, @@ -1373,11 +755,28 @@ export async function runUpdatePreflight( ? 'unsupported' : await detectInstallSource().catch(() => 'unsupported' as const); - // The cached target above only decides whether anything is worth - // refreshing for; the bounded refresh below is this launch's single - // decision. Everything after it uses the refreshed target and manifest, - // with the cached pair as the fallback when the refresh fails or times - // out. A null refreshed target means the refresh offers nothing. + const decision = decideUpdateAction(target, isInteractive, source, platform); + if (decision === 'none') { + refreshInBackground(); + return 'continue'; + } + + if ( + await tryStartAutomaticBackgroundInstall( + installState, + currentVersion, + target, + source, + platform, + options.track, + logger, + rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), + ) + ) { + refreshInBackground(); + return 'continue'; + } + const userVisibleUpdate = await refreshUserVisibleUpdateTarget( currentVersion, deviceId, @@ -1393,19 +792,6 @@ export async function runUpdatePreflight( userVisibleUpdate.manifest, bypassRollout, ); - - // A native install consumes the manifest's platform artifact; without one - // the update cannot succeed, so do not offer or start it. Non-native - // sources install from the registry/formula and are never gated here. - if (!isTargetInstallable(source, userVisibleUpdate.manifest)) { - return 'continue'; - } - - const decision = decideUpdateAction(userVisibleTarget, isInteractive, source, platform); - if (decision === 'none') { - return 'continue'; - } - if ( await tryStartAutomaticBackgroundInstall( installState, @@ -1434,62 +820,19 @@ export async function runUpdatePreflight( return 'continue'; } - // An install that is already in flight must not be prompted for a second - // time: the user would get a confusing double message for work that is - // already running elsewhere. - if (hasFreshActiveInstall(installState)) return 'continue'; - - const choice = await promptInstall( - currentVersion, - userVisibleTarget, - source, - installCommand, - failureMessageFor(installState, userVisibleTarget), - ); + const choice = await promptInstall(currentVersion, userVisibleTarget, source, installCommand); if (choice === 'skip') return 'continue'; - // Take the lock only after the prompt resolves: holding it across an - // indefinite interactive wait would block the background path as long as - // the prompt sits unanswered. A null handle means another installer is - // already running — do not install on top of it. - const lock = await tryAcquireUpdateInstallLock({ version: userVisibleTarget.version }); - if (lock === null) return 'continue'; - try { - const outcome = await installUpdate(source, userVisibleTarget.version, platform); - await writeUpdateInstallState({ - ...installState, - active: null, - lastFailure: null, - lastSuccess: { - version: userVisibleTarget.version, - installedAt: nowIso(), - notifiedAt: null, - unverified: outcome.unverified, - }, - }).catch(() => {}); + await installUpdate(source, userVisibleTarget.version, platform); stdout.write(renderInstallSuccessMessage(userVisibleTarget)); return 'exit'; } catch (error) { - const attempts = failureAttemptsFor(installState, userVisibleTarget, 'install') + 1; - await writeUpdateInstallState({ - ...installState, - active: null, - lastFailure: { - version: userVisibleTarget.version, - failedAt: nowIso(), - attempts, - operation: 'install', - message: formatErrorMessage(error), - }, - }).catch(() => {}); stderr.write( `warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` + `${formatErrorMessage(error)}\n`, ); return 'continue'; - } finally { - await lock.release().catch(() => {}); } } catch { return 'continue'; diff --git a/apps/pythinker-code/src/cli/update/prompt.ts b/apps/pythinker-code/src/cli/update/prompt.ts index a3a44890..05e5b728 100644 --- a/apps/pythinker-code/src/cli/update/prompt.ts +++ b/apps/pythinker-code/src/cli/update/prompt.ts @@ -2,7 +2,7 @@ import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from 'node:readli import chalk from 'chalk'; -import { PRODUCT_NAME, PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; +import { PRODUCT_NAME } from '#/constant/app'; import { HIDE_CURSOR, SHOW_CURSOR } from '#/constant/terminal'; import { UPDATE_PROMPT_MUTED, @@ -14,7 +14,7 @@ import { import { type InstallSource, type UpdateTarget } from './types'; -export const CHANGELOG_URL = PYTHINKER_CODE_CHANGELOG_URL; +export const CHANGELOG_URL = 'https://code.pythinker.com/pythinker-code/en/release-notes/changelog.html'; export type InstallPromptChoiceValue = 'install' | 'skip'; @@ -28,30 +28,10 @@ export interface InstallPromptOptions { readonly target: UpdateTarget; readonly installCommand: string; readonly installSource: InstallSource; - /** - * Why the last automatic install of this same version failed. A silently - * failed background install otherwise looks identical to one that never - * started, and the prompt is where the user decides what to do about it. - */ - readonly previousFailure?: string; readonly input?: NodeJS.ReadStream; readonly output?: NodeJS.WriteStream; } -/** Keep the prompt readable: one line, the first line of the recorded error. */ -const FAILURE_SUMMARY_MAX_CHARS = 120; - -export function summarizeInstallFailure(message: string): string | undefined { - const firstLine = message - .split('\n') - .map((line) => line.trim()) - .find((line) => line.length > 0); - if (firstLine === undefined) return undefined; - return firstLine.length > FAILURE_SUMMARY_MAX_CHARS - ? `${firstLine.slice(0, FAILURE_SUMMARY_MAX_CHARS - 1)}…` - : firstLine; -} - const INSTALL_HINT = 'Install update now'; const SKIP_HINT = 'Continue with current version'; @@ -98,25 +78,10 @@ function renderInstallPrompt( `${label('Target ')} ${targetVersion}`, `${label('Source ')} ${sourceLabel}`, `${label('Command')} ${command}`, - ]; - - const failureSummary = - options.previousFailure === undefined - ? undefined - : summarizeInstallFailure(options.previousFailure); - if (failureSummary !== undefined) { - lines.push( - '', - chalk.hex(UPDATE_PROMPT_WARNING).bold('The last automatic update failed'), - chalk.hex(UPDATE_PROMPT_MUTED)(failureSummary), - ); - } - - lines.push( '', chalk.hex(UPDATE_PROMPT_MUTED)('↑↓ choose · Enter confirm · Esc continue'), '', - ); + ]; for (let i = 0; i < choices.length; i++) { const choice = choices[i]; diff --git a/apps/pythinker-code/src/cli/update/refresh.ts b/apps/pythinker-code/src/cli/update/refresh.ts index dbc842c1..938a4a0f 100644 --- a/apps/pythinker-code/src/cli/update/refresh.ts +++ b/apps/pythinker-code/src/cli/update/refresh.ts @@ -1,13 +1,14 @@ import { writeUpdateCache } from './cache'; -import { fetchUpdateManifest } from './cdn'; -import { type UpdateCache, type UpdateManifest } from './types'; +import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; +import { type UpdateCache } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the CDN update manifest. **Throws** on any failure — callers - * (including the default background invocation in preflight) must catch. - * Errors intentionally skip `writeCache` so a transient CDN blip does not - * overwrite a previously known `latest` with `null`. */ - readonly fetchManifest: () => Promise<UpdateManifest>; + /** Resolves with the latest version + rollout manifest. **Throws** on any + * failure — callers (including the default background invocation in + * preflight) must catch. Errors intentionally skip `writeCache` so a + * transient CDN blip does not overwrite a previously known `latest` with + * `null`. */ + readonly fetchLatest: () => Promise<FetchLatestResult>; readonly writeCache: (cache: UpdateCache) => Promise<void>; readonly now: () => Date; } @@ -16,16 +17,16 @@ export async function refreshUpdateCache( overrides: Partial<RefreshUpdateCacheDeps> = {}, ): Promise<UpdateCache> { const resolved: RefreshUpdateCacheDeps = { - fetchManifest: overrides.fetchManifest ?? (() => fetchUpdateManifest()), + fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const manifest = await resolved.fetchManifest(); + const { latest, manifest } = await resolved.fetchLatest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), - latest: manifest.version, + latest, manifest, }; await resolved.writeCache(cache); diff --git a/apps/pythinker-code/src/cli/update/rollout.ts b/apps/pythinker-code/src/cli/update/rollout.ts index d4865ffd..2c2d2d7d 100644 --- a/apps/pythinker-code/src/cli/update/rollout.ts +++ b/apps/pythinker-code/src/cli/update/rollout.ts @@ -7,7 +7,6 @@ import { resolvePythinkerHome } from '@pymodel/pythinker-code-sdk'; import { getUpdateRolloutLogFile } from '#/utils/paths'; -import { isBelowMinRequiredVersion } from './cdn'; import { selectUpdateTarget } from './select'; import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; @@ -73,8 +72,6 @@ export type PassiveUpdateReason = | 'held' /** Gated and the batch delay has elapsed: update is visible. */ | 'eligible' - /** Manifest floor: client is below minRequiredVersion, rollout bypassed. */ - | 'required' /** PYTHINKER_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ | 'experimental'; @@ -142,19 +139,6 @@ export function decidePassiveUpdateTarget( const eligibleAt = Number.isFinite(publishedAt) ? new Date(publishedAt + delaySeconds * 1000).toISOString() : null; - // A client below the manifest floor must take the update now: the staged - // delay exists for ordinary releases, not for one the client cannot skip. - // Bucket, delay and eligibleAt stay populated so the telemetry and the - // decision log still describe where the device sits in the plan. - if (isBelowMinRequiredVersion(manifest, currentVersion)) { - return { - target, - reason: 'required', - bucket, - delaySeconds, - eligibleAt, - }; - } const eligible = isRolloutEligible(manifest, deviceId, now); return { target: eligible ? target : null, diff --git a/apps/pythinker-code/src/cli/update/select.ts b/apps/pythinker-code/src/cli/update/select.ts index c5ab033c..bf241ed8 100644 --- a/apps/pythinker-code/src/cli/update/select.ts +++ b/apps/pythinker-code/src/cli/update/select.ts @@ -1,7 +1,6 @@ import { gt, valid } from 'semver'; -import { manifestArtifactAvailability } from './cdn'; -import { type InstallSource, type UpdateManifest, type UpdateTarget } from './types'; +import { type UpdateTarget } from './types'; export function selectUpdateTarget( currentVersion: string, @@ -12,19 +11,3 @@ export function selectUpdateTarget( if (!gt(latest, currentVersion)) return null; return { version: latest }; } - -/** - * Whether an update can be installed from this source at all. Only the native - * install path consumes a platform artifact, so only it must be suppressed - * when the manifest does not advertise one: - * - npm-family sources (`npm-global`, `pnpm-global`, `yarn-global`, - * `bun-global`) install from the npm registry, where the published version - * *is* the artifact — a missing native zip says nothing about them, and - * suppressing their update would be a regression. - * - `homebrew` installs through its own formula. - * - `unsupported` never installs anyway. - */ -export function isTargetInstallable(source: InstallSource, manifest: UpdateManifest | null): boolean { - if (source !== 'native') return true; - return manifestArtifactAvailability(manifest) === 'available'; -} diff --git a/apps/pythinker-code/src/cli/update/source.ts b/apps/pythinker-code/src/cli/update/source.ts index 35113c42..464e3231 100644 --- a/apps/pythinker-code/src/cli/update/source.ts +++ b/apps/pythinker-code/src/cli/update/source.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; import { getHostPackageRoot } from '#/cli/version'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { NPM_PACKAGE_NAME, type InstallSource } from './types'; @@ -76,30 +77,26 @@ function npmCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'npm.cmd' : 'npm'; } -function execFileText( - command: string, - args: readonly string[], - platform: NodeJS.Platform = process.platform, -): Promise<string> { - // `npm.cmd` cannot be spawned directly on Node ≥18.20/20.12 - // (CVE-2024-27980): it fails with EINVAL, and every npm-family Windows - // install then classifies as `unsupported` and never auto-updates. - const viaInterpreter = platform === 'win32' && command.toLowerCase().endsWith('.cmd'); - const spawnCommand = viaInterpreter ? process.env['ComSpec'] ?? 'cmd.exe' : command; - const spawnArgs = viaInterpreter ? ['/d', '/s', '/c', command, ...args] : [...args]; +// The install-source detection runs before the workspace trust gate, so the +// npm binary must be resolved through PATH to an absolute path — a bare name +// would let cmd.exe pick up an `npm.cmd` planted in the current directory. +function npmGlobalPrefix(platform: NodeJS.Platform): Promise<string> { + const resolved = resolveCommandPath(npmCommand(platform)); + if (resolved === undefined) { + return Promise.reject(new Error('npm was not found in PATH')); + } + return execFileText(resolved, ['prefix', '-g']).then((text) => text.trim()); +} + +function execFileText(command: string, args: readonly string[]): Promise<string> { return new Promise((resolveOutput, reject) => { - execFile( - spawnCommand, - spawnArgs, - { encoding: 'utf-8', windowsHide: true }, - (error, stdout) => { - if (error) { - reject(error); - return; - } - resolveOutput(stdout); - }, - ); + execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolveOutput(stdout); + }); }); } @@ -155,22 +152,14 @@ export async function detectInstallSource( getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot, getGlobalPrefix: deps.getGlobalPrefix ?? - (() => - execFileText(npmCommand(platform), ['prefix', '-g'], platform).then((text) => text.trim())), + (() => npmGlobalPrefix(platform)), detectNative: deps.detectNative ?? detectNativeInstall, platform, }; if (resolved.detectNative()) return 'native'; - // A layout with no reachable `package.json` cannot be classified, and this - // runs on every launch — it reports "unsupported" rather than throwing. - let packageRoot: string; - try { - packageRoot = resolved.getPackageRoot(); - } catch { - return 'unsupported'; - } + const packageRoot = resolved.getPackageRoot(); const heuristic = classifyByPathHeuristic(packageRoot); if (heuristic !== null) return heuristic; diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index 03e0da0d..b03af767 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -22,11 +22,6 @@ export interface RolloutBatch { readonly delaySeconds: number; } -export interface UpdateManifestPlatform { - readonly url: string; - readonly sha256: string; -} - /** * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array * order; an empty array means the release is fully rolled out immediately. @@ -35,17 +30,6 @@ export interface UpdateManifest { readonly version: string; readonly publishedAt: string; readonly rollout: readonly RolloutBatch[]; - /** - * Resolved per-platform artifacts, keyed `<platform>-<arch>`. Absent on - * manifests published before artifact addressing shipped. - */ - readonly platforms?: Readonly<Record<string, UpdateManifestPlatform>>; - /** - * Lowest version that can still work against the current services. A - * client below it must take the update without waiting for its rollout - * batch. Absent on ordinary releases. - */ - readonly minRequiredVersion?: string; } export interface UpdateCache { @@ -56,73 +40,26 @@ export interface UpdateCache { readonly manifest: UpdateManifest | null; } -export type UpdateInstallOperation = 'install' | 'prepare' | 'activate'; -export type UpdateRequestOrigin = 'automatic' | 'manual'; - -/** - * Latest machine-readable progress line from the background installer's - * stderr, as recorded on the active install record. - */ -export interface UpdateInstallProgress { - readonly state: 'downloading' | 'waiting' | 'done' | 'failed'; - /** Integer 0..100; absent while the download size is unknown. */ - readonly percent?: number; - readonly transferred?: number; - readonly total?: number; - readonly updatedAt: string; -} - export interface UpdateInstallActive { readonly version: string; readonly source: InstallSource; - /** Installer process id; absent in records persisted by older versions. */ readonly startedAt: string; - readonly pid?: number; - readonly operation?: UpdateInstallOperation; - readonly jobId?: string; - /** - * Latest progress line from the installer; absent until the installer - * emits one or in records persisted by older versions. - */ - readonly progress?: UpdateInstallProgress; -} - -export interface UpdatePreparedHomebrew { - readonly jobId: string; - readonly source: 'homebrew'; - readonly version: string; - readonly preparedAt: string; - readonly requestedBy: UpdateRequestOrigin; - readonly formulaUrl: string; - readonly artifactKind: 'source'; - readonly artifactSha256: string; - readonly formulaFileSha256: string; - readonly artifactPath: string; } export interface UpdateInstallFailure { readonly version: string; readonly failedAt: string; readonly attempts: number; - readonly operation?: UpdateInstallOperation; - readonly message?: string; } export interface UpdateInstallSuccess { readonly version: string; readonly installedAt: string; readonly notifiedAt: string | null; - /** - * Why this success was recorded without proof that the new version runs. - * Absent when the installed binary was probed and matched. `doctor` prints - * it, so "it says updated but it did not" is answerable in one command. - */ - readonly unverified?: string; } export interface UpdateInstallState { readonly active: UpdateInstallActive | null; - readonly pending: UpdatePreparedHomebrew | null; readonly lastFailure: UpdateInstallFailure | null; readonly lastSuccess: UpdateInstallSuccess | null; } @@ -142,7 +79,6 @@ export function emptyUpdateCache(): UpdateCache { export function emptyUpdateInstallState(): UpdateInstallState { return { active: null, - pending: null, lastFailure: null, lastSuccess: null, }; diff --git a/apps/pythinker-code/src/cli/update/update-helper.ts b/apps/pythinker-code/src/cli/update/update-helper.ts deleted file mode 100644 index a373972c..00000000 --- a/apps/pythinker-code/src/cli/update/update-helper.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { appendFile, mkdir, stat, writeFile } from 'node:fs/promises'; -import { dirname } from 'node:path'; - -import { valid } from 'semver'; -import { z } from 'zod'; - -import { getUpdateInstallLogFile } from '#/utils/paths'; - -import { formatErrorMessage } from './format-error'; -import { prepareHomebrewUpdate } from './homebrew'; -import { readUpdateInstallState, writeUpdateInstallState } from './install-state'; -import type { UpdateInstallActive, UpdateInstallState } from './types'; - -const UPDATE_INSTALL_LOG_MAX_BYTES = 1024 * 1024; - -const PrepareHomebrewArgsSchema = z.tuple([ - z.literal('prepare-homebrew'), - z.uuid(), - z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), - z.enum(['automatic', 'manual']), -]); - -async function rotateHelperLogIfNeeded(): Promise<void> { - const filePath = getUpdateInstallLogFile(); - try { - await mkdir(dirname(filePath), { recursive: true }); - const size = await stat(filePath).then((entry) => entry.size, () => 0); - if (size >= UPDATE_INSTALL_LOG_MAX_BYTES) { - await writeFile(filePath, '', { encoding: 'utf-8', mode: 0o600 }); - } - } catch { - // Diagnostics must not change the update outcome. - } -} - -async function appendHelperLog(message: string): Promise<void> { - const filePath = getUpdateInstallLogFile(); - try { - await mkdir(dirname(filePath), { recursive: true }); - const line = `[${new Date().toISOString()}] ${message}\n`; - await appendFile(filePath, line, { encoding: 'utf-8', mode: 0o600 }); - } catch { - // Diagnostics must not change the update outcome. - } -} - -function prepareFailureAttempts(state: UpdateInstallState, version: string): number { - const failure = state.lastFailure; - return failure?.version === version && failure.operation === 'prepare' ? failure.attempts : 0; -} - -function ownsPrepareJob( - state: UpdateInstallState, - jobId: string, - requestedVersion: string, -): state is UpdateInstallState & { readonly active: UpdateInstallActive } { - const active = state.active; - return ( - active?.jobId === jobId && - active.operation === 'prepare' && - active.source === 'homebrew' && - active.version === requestedVersion - ); -} - -export function dispatchUpdateHelperIfRequested(): boolean { - if (process.env['PYTHINKER_CODE_UPDATE_HELPER'] !== '1') return false; - const commandIndex = process.argv[2] === '__update_helper' - ? 2 - : process.argv[1] === '__update_helper' - ? 1 - : -1; - if (commandIndex < 0) return false; - void runUpdateHelper(process.argv.slice(commandIndex + 1)) - .then((code) => { - process.exitCode = code; - }) - .catch((error: unknown) => { - process.stderr.write(`Update helper failed: ${formatErrorMessage(error)}\n`); - process.exitCode = 1; - }); - return true; -} - -export async function runUpdateHelper(args: readonly string[]): Promise<number> { - const parsed = PrepareHomebrewArgsSchema.safeParse(args); - if (!parsed.success) { - await appendHelperLog('update helper rejected invalid arguments'); - return 2; - } - const [, jobId, requestedVersion, requestedBy] = parsed.data; - await rotateHelperLogIfNeeded(); - let state = await readUpdateInstallState(); - if (!ownsPrepareJob(state, jobId, requestedVersion)) { - await appendHelperLog(`prepare job ${jobId} is no longer active`); - return 0; - } - - try { - state = { - ...state, - active: { - ...state.active, - pid: process.pid, - }, - }; - await writeUpdateInstallState(state); - await appendHelperLog(`prepare job ${jobId} started for ${requestedVersion}`); - - const prepared = await prepareHomebrewUpdate( - { jobId, requestedVersion, requestedBy }, - { logFile: getUpdateInstallLogFile() }, - ); - const latest = await readUpdateInstallState(); - if (!ownsPrepareJob(latest, jobId, requestedVersion)) { - await appendHelperLog(`prepare job ${jobId} lost ownership before completion`); - return 0; - } - // Keep `lastFailure` so prepare attempts accumulate when a "successful" - // preparation later turns out invalid at activation; a fully activated - // update clears it in `activatePendingUpdate`. - await writeUpdateInstallState({ - ...latest, - active: null, - pending: prepared, - }); - await appendHelperLog(`prepare job ${jobId} verified ${prepared.version}`); - return 0; - } catch (error) { - const latest = await readUpdateInstallState(); - if (!ownsPrepareJob(latest, jobId, requestedVersion)) return 1; - const message = formatErrorMessage(error); - await writeUpdateInstallState({ - ...latest, - active: null, - lastFailure: { - version: requestedVersion, - failedAt: new Date().toISOString(), - attempts: prepareFailureAttempts(latest, requestedVersion) + 1, - operation: 'prepare', - message, - }, - }).catch(() => {}); - await appendHelperLog(`prepare job ${jobId} failed: ${message}`); - return 1; - } -} diff --git a/apps/pythinker-code/src/cli/update/verify-install.ts b/apps/pythinker-code/src/cli/update/verify-install.ts deleted file mode 100644 index 63642929..00000000 --- a/apps/pythinker-code/src/cli/update/verify-install.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Post-install verification. - * - * An installer exit code of 0 only says the installer believed it finished. - * It does not say the bytes that will run next launch are the target version: - * a Windows report had `install.ps1` exit 0 repeatedly while the executable on - * disk stayed on the old version, so the footer advertised - * "restart to apply" forever and the recorded outcome was a lie. - * - * Only a `native` install is verified, and only against the artifact the - * installer replaces — the packaged binary at `process.execPath`, probed with - * `--version` (Commander prints and exits before any preflight runs). The - * npm family is deliberately left unverified: a global reinstall rewrites the - * very directory this process was loaded from, so a read there proves nothing - * about the next launch and a wrong answer would park a healthy version. - * - * It fails **open**: a probe that times out, cannot run, or prints no version - * reports `ok` with an `unverified` note for the caller to log. A slow - * antivirus scan must never turn a good install into a recorded failure. Only - * a version read successfully *and* disagreeing with the target is a mismatch. - */ - -import { execFile } from 'node:child_process'; -import { valid } from 'semver'; - -import { formatErrorMessage } from './format-error'; -import type { InstallSource } from './types'; - -/** Bound on the `--version` probe: a native binary starts in well under this. */ -const VERSION_PROBE_TIMEOUT_MS = 20_000; - -/** - * What a *completed* install still could not prove. A mismatch is never one of - * these — it is thrown by the installer path — so callers cannot mistake a - * failed verification for a success they are free to record. - */ -export interface InstallOutcome { - readonly unverified?: string; -} - -export type InstallVerification = - /** Installed as expected, or not checkable — `unverified` says which. */ - | { readonly ok: true; readonly unverified?: string } - | { readonly ok: false; readonly reason: string }; - -export interface VerifyInstalledVersionDeps { - /** Path of the packaged binary to probe (native installs only). */ - readonly execPath: string; - /** Runs `<exe> --version` and resolves its stdout. */ - readonly probeExecutableVersion: (execPath: string) => Promise<string>; -} - -function unverified(note: string): InstallVerification { - return { ok: true, unverified: note }; -} - -/** - * Extract the first `x.y.z` from a `--version` output. Commander prints the - * bare version, but a wrapper is free to add a banner around it. - */ -export function parseVersionOutput(output: string): string | null { - // No leading `\b`: a `v` prefix is a word character, so `v1.2.3` would not - // match. A digit or dot before the first number still disqualifies it. - const match = /(?<![\d.])\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/u.exec(output); - return match?.[0] ?? null; -} - -function sameVersion(found: string, expected: string): boolean { - const normalize = (value: string): string => value.replace(/^v/u, '').trim(); - return normalize(found) === normalize(expected); -} - -async function defaultProbeExecutableVersion(execPath: string): Promise<string> { - return new Promise<string>((resolve, reject) => { - execFile( - execPath, - ['--version'], - { - timeout: VERSION_PROBE_TIMEOUT_MS, - windowsHide: true, - encoding: 'utf-8', - // The probe must not check for updates, install anything, or touch the - // install state this verification is about to write. - env: { ...process.env, PYTHINKER_CODE_NO_AUTO_UPDATE: '1' }, - }, - (error, stdout) => { - if (error) { - reject(error); - return; - } - resolve(stdout); - }, - ); - }); -} - -/** - * Verify that `expectedVersion` is what an install of `source` actually left - * behind. See the module comment for the fail-open rule. - */ -export async function verifyInstalledVersion( - source: InstallSource, - expectedVersion: string, - overrides: Partial<VerifyInstalledVersionDeps> = {}, -): Promise<InstallVerification> { - // Non-native sources are not checkable from here (see the module comment), - // and a note on every npm install would be noise rather than a signal. - if (source !== 'native' || valid(expectedVersion) === null) return { ok: true }; - - const execPath = overrides.execPath ?? process.execPath; - const probe = overrides.probeExecutableVersion ?? defaultProbeExecutableVersion; - - let output: string; - try { - output = await probe(execPath); - } catch (error) { - return unverified(`${execPath} could not be run: ${formatErrorMessage(error)}`); - } - - const found = parseVersionOutput(output); - if (found === null) return unverified(`${execPath} printed no version`); - if (sameVersion(found, expectedVersion)) return { ok: true }; - return { - ok: false, - reason: - `the installer reported success but ${execPath} still reports ` + - `${found} (expected ${expectedVersion})`, - }; -} diff --git a/apps/pythinker-code/src/cli/v2/run-v2-print.ts b/apps/pythinker-code/src/cli/v2/run-v2-print.ts new file mode 100644 index 00000000..ba58b689 --- /dev/null +++ b/apps/pythinker-code/src/cli/v2/run-v2-print.ts @@ -0,0 +1,880 @@ +/** + * Native v2 `pythinker -p` (print mode) runner. + * + * Unlike the v1 path (and the former `V2PromptHarness` / `V2Session` shim), this + * runner talks to agent-core-v2's native DI services directly — no + * `PromptHarness`, no SDK-shaped session, no v2→v1 event translation. It: + * - `bootstrap()`s the app scope, + * - creates / resumes a session and its main agent via native services, + * - subscribes to the main agent's per-agent `IEventBus` and renders the + * native `Event2` stream (payloads are already v1-protocol-shaped), + * - drives a turn through `IAgentPromptService.enqueue()` and awaits + * `Turn.result` for authoritative completion, + * - applies the print-mode background policy (config-driven, v1-aligned: + * `exit` / `drain` / `steer`) before exiting. + * + * Selected by `runPrompt` unless `PYTHINKER_CODE_LEGACY_FLAG` is truthy. + */ + +import { readFile } from 'node:fs/promises'; + +import { + IAgentGoalService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentTaskService, + IAuthSummaryService, + IBootstrapService, + IConfigService, + IEventBus, + IOAuthToolkit, + ISessionCronService, + ISessionIndex, + ISessionManager, + ITelemetryService, + PRINT_MAX_TURNS_DEFAULT, + PRINT_WAIT_CEILING_S_DEFAULT, + applyPrintModeConfigDefaults, + bootstrap, + createCloudAppender, + ensureMainAgent, + resumeSessionById, + logSeed, + parseAgentFileText, + resolveAgentPath, + resolveAgentTaskConfig, + resolvePythinkerHome, + resolveLoggingConfig, + resolvePrintBackgroundMode, + setClampedTimeout, + type Event2, + type IAgentScopeHandle, + type ISessionScopeHandle, + type LoopRunResult, + type PrintBackgroundMode, + type Scope, +} from '@pymodel/agent-core-v2'; +import { createPythinkerDefaultHeaders, createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; +import type { GoalUpdated } from '@pymodel/agent-core-v2/agent/goal/goalOps'; +import type { TurnEnded } from '@pymodel/agent-core-v2/agent/loop/turnOps'; +import type { + AssistantDelta, + ThinkingDelta, + ToolCallDelta, +} from '@pymodel/agent-core-v2/agent/loop/turnEvents'; +import type { TurnStepRetrying } from '@pymodel/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { HookResult } from '@pymodel/agent-core-v2/agent/externalHooks/externalHooksService'; +import type { + ToolCallStarted, + ToolProgress, + ToolResultEvent, +} from '@pymodel/agent-core-v2/agent/toolExecutor/toolExecutorEvents'; +import { resolve } from 'pathe'; + +import { + CLI_SHUTDOWN_TIMEOUT_MS, + CLI_USER_AGENT_PRODUCT, + PROMPT_CLEANUP_TIMEOUT_MS, +} from '#/constant/app'; + +import { + formatGoalSummaryText, + goalExitCode, + goalSummaryJson, + parseHeadlessGoalCreate, + type HeadlessGoalCreate, +} from '../goal-prompt'; +import { + type PromptRunIO, + configuredModel, + installPromptTerminationCleanup, + raceWithTimeout, + requireConfiguredModel, +} from '../run-prompt'; +import { createPythinkerCodeHostIdentity } from '../version'; + +import { resolveOutputFormat } from '../options'; +import type { CLIOptions, PromptOutputFormat } from '../options'; +import { + type PromptOutput, + PromptJsonWriter, + type PromptTurnWriter, + PromptTranscriptWriter, + writeExperimentalVersion, + writeResumeHint, +} from '../prompt-render'; + +const PROMPT_UI_MODE = 'print'; +/** Re-check `goalActive` at least this often while waiting for goal turns. */ +const GOAL_WAIT_POLL_MS = 250; +/** + * Slack on top of a scheduled cron fire time while waiting for the steered + * turn: covers the 1s tick poll interval plus fire → inject → turn-launch + * latency. + */ +const CRON_FIRE_GRACE_MS = 5_000; + +export async function runV2Print( + opts: CLIOptions, + version: string, + io: PromptRunIO = {}, +): Promise<void> { + const startedAt = Date.now(); + const stdout = io.stdout ?? process.stdout; + const stderr = io.stderr ?? process.stderr; + const promptProcess = io.process ?? process; + const outputFormat = resolveOutputFormat(opts); + const workDir = process.cwd(); + + writeExperimentalVersion(version, outputFormat, stdout, stderr); + + const homeDir = resolvePythinkerHome(); + let firstLaunch = false; + const deviceId = createPythinkerDeviceId(homeDir, { + onFirstLaunch: () => { + firstLaunch = true; + }, + }); + const logging = resolveLoggingConfig({ homeDir, env: process.env }); + const identity = createPythinkerCodeHostIdentity(version); + const hostHeaders = createPythinkerDefaultHeaders({ homeDir, ...identity }); + + const { app } = bootstrap( + { + homeDir, + clientIdentity: identity, + args: { + requestHeaders: hostHeaders, + // `--skillsDir` (v1 print parity): explicit skill dirs replace default + // user / project discovery for this process. + skillDirs: opts.skillsDirs, + // `--agent-file`: explicit agent definition files, registered with the + // highest-precedence source for this process. Passed through unresolved — + // the engine expands `~` and resolves relative paths against the session + // workDir (mirroring `--skills-dir`). + agentFiles: opts.agentFiles, + }, + }, + [...logSeed(logging)], + ); + const auth = app.accessor.get(IOAuthToolkit); + + const configService = app.accessor.get(IConfigService); + await configService.ready; + // Print-mode config defaults (task timeouts / loop step cap / subagent + // timeout → unbounded) before anything resolves a session; only keys the + // user left unset are filled, in the memory layer. + await applyPrintModeConfigDefaults(configService); + const defaultModel = configService.get<string>('defaultModel') ?? undefined; + let telemetryEnabled: boolean; + try { + telemetryEnabled = configService.get('telemetry') !== false; + } catch { + telemetryEnabled = true; + } + for (const diagnostic of configService.diagnostics()) { + if (diagnostic.severity === 'warning') { + stderr.write(`Warning: ${diagnostic.message}\n`); + } + } + + let restorePermission = async (): Promise<void> => {}; + let removeTerminationCleanup: (() => void) | undefined; + let cleanupPromise: Promise<void> | undefined; + let telemetryService: ITelemetryService | undefined; + const cleanup = async (): Promise<void> => { + const pending = (cleanupPromise ??= (async () => { + removeTerminationCleanup?.(); + try { + await restorePermission(); + } finally { + if (telemetryService !== undefined) { + await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + } + app.dispose(); + } + })()); + await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); + }; + removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanup); + + try { + // Install the appender BEFORE resolving the session: `session_started` and + // `session_load_failed` fire inside create()/resume(), so an appender wired + // up only after resolveNativeSession() would drop them to the null appender. + // The model below is the best known up front; a resumed session's real + // model is reconciled via setContext once resolved. + telemetryService = app.accessor.get(ITelemetryService); + if (telemetryEnabled) { + telemetryService.setAppender( + createCloudAppender(app.accessor, { + deviceId, + appName: CLI_USER_AGENT_PRODUCT, + uiMode: PROMPT_UI_MODE, + model: opts.model ?? defaultModel, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + }), + ); + } + + const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr); + restorePermission = resolved.restorePermission; + + telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); + if (firstLaunch) { + telemetryService.track2('first_launch'); + } + + const goalCreate = parseHeadlessGoalCreate(opts.prompt!); + if (goalCreate !== undefined) { + await runNativeGoal( + app, + resolved.session, + resolved.agent, + goalCreate, + resolved.goalModel, + outputFormat, + stdout, + stderr, + ); + } else { + await runNativeTurn( + app, + resolved.session, + resolved.agent, + opts.prompt!, + outputFormat, + stdout, + stderr, + ); + } + writeResumeHint(resolved.session.id, outputFormat, stdout, stderr); + + telemetryService.withContext({ sessionId: resolved.session.id }).track2('exit', { + duration_ms: Date.now() - startedAt, + }); + } finally { + await cleanup(); + } +} + +interface ResolvedNativeSession { + readonly session: ISessionScopeHandle; + readonly agent: IAgentScopeHandle; + readonly restorePermission: () => Promise<void>; + readonly telemetryModel: string | undefined; + readonly goalModel: string | undefined; +} + +async function resolveNativeSession( + app: Scope, + opts: CLIOptions, + workDir: string, + defaultModel: string | undefined, + stderr: PromptOutput, +): Promise<ResolvedNativeSession> { + const sessions = app.accessor.get(ISessionManager); + const index = app.accessor.get(ISessionIndex); + + // `--agent` selects a catalog profile by name; otherwise `--agent-file` + // implicitly selects the profile that file defines. The file + // is parsed here (fatal on error) so a bad file fails before any turn. + let agentProfileName = opts.agent; + const agentFile = opts.agentFiles[0]; + if (agentProfileName === undefined && agentFile !== undefined) { + const agentFilePath = resolveAgentPath( + agentFile, + workDir, + app.accessor.get(IBootstrapService).osHomeDir, + ); + let agentFileText: string; + try { + agentFileText = await readFile(agentFilePath, 'utf8'); + } catch (error) { + throw new Error( + `Failed to read agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + try { + agentProfileName = parseAgentFileText({ + path: agentFilePath, + source: 'explicit', + text: agentFileText, + }).name; + } catch (error) { + throw new Error( + `Invalid agent file "${agentFilePath}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + + // `--agent` / `--agent-file` are creation-only: validateOptions rejects them + // together with --session/--continue, so resume paths only apply an + // explicitly requested model — the bound profile is restored by the engine. + const applyModelOverride = async ( + profile: IAgentProfileService, + model: string | undefined, + ): Promise<void> => { + if (model !== undefined) await profile.setModel(model); + }; + + const resumeById = async (id: string): Promise<ISessionScopeHandle> => { + const session = await resumeSessionById(app.accessor, id); + if (session === undefined) { + throw new Error(`Session "${id}" not found.`); + } + return session; + }; + + const forceAuto = ( + agent: IAgentScopeHandle, + ): { readonly restorePermission: () => Promise<void> } => { + const permissionMode = agent.accessor.get(IAgentPermissionModeService); + const previous = permissionMode.mode; + permissionMode.setMode('auto'); + return { + restorePermission: async () => { + permissionMode.setMode(previous); + }, + }; + }; + + if (opts.session !== undefined) { + const target = await index.get(opts.session); + if (target === undefined) { + throw new Error(`Session "${opts.session}" not found.`); + } + if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) { + stderr.write( + `Session "${opts.session}" was created under a different directory.\n` + + ` cd "${target.cwd}" && pythinker -r ${opts.session}\n\n`, + ); + throw new Error(`Session "${opts.session}" was created under a different directory.`); + } + const session = await resumeById(opts.session); + const agent = await ensureMainAgent(session); + const profile = agent.accessor.get(IAgentProfileService); + await applyModelOverride(profile, opts.model); + const currentModel = profile.getModel(); + const { restorePermission } = forceAuto(agent); + return { + session, + agent, + restorePermission, + telemetryModel: configuredModel(opts.model, currentModel, defaultModel), + goalModel: configuredModel(opts.model, currentModel), + }; + } + + if (opts.continue) { + const page = await index.listRecent({}); + const previous = page.items.find((summary) => summary.cwd === workDir); + if (previous !== undefined) { + const session = await resumeById(previous.id); + const agent = await ensureMainAgent(session); + const profile = agent.accessor.get(IAgentProfileService); + await applyModelOverride(profile, opts.model); + const currentModel = profile.getModel(); + const { restorePermission } = forceAuto(agent); + return { + session, + agent, + restorePermission, + telemetryModel: configuredModel(opts.model, currentModel, defaultModel), + goalModel: configuredModel(opts.model, currentModel), + }; + } + stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`); + } + + const model = requireConfiguredModel(opts.model, defaultModel); + const session = await sessions.create({ + workDir, + additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined, + mainAgentBinding: { + profile: agentProfileName ?? 'agent', + model, + }, + }); + const agent = await ensureMainAgent(session); + agent.accessor.get(IAgentPermissionModeService).setMode('auto'); + return { + session, + agent, + restorePermission: async () => {}, + telemetryModel: model, + goalModel: model, + }; +} + +async function runNativeTurn( + app: Scope, + session: ISessionScopeHandle, + agent: IAgentScopeHandle, + prompt: string, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise<void> { + const writer: PromptTurnWriter = + outputFormat === 'stream-json' + ? new PromptJsonWriter(stdout) + : new PromptTranscriptWriter(stdout, stderr); + + await agent.accessor.get(IAuthSummaryService).ensureReady(); + + const turnEndings = createPrintTurnEndings(); + const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => { + dispatchNativeEvent(writer, event, stderr); + // Arm the turn-endings collector before `turn.result` settles so a + // background-task completion that steers a new turn right after the main + // turn ends cannot have its `turn.ended` slip past the policy loop. + if (event.type === 'turn.ended') turnEndings.push(event as TurnEnded); + }); + try { + const handle = await agent.accessor.get(IAgentPromptService).enqueue({ + message: { + role: 'user', + content: [{ type: 'text', text: prompt }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + const turn = await handle.launched; + if (turn === undefined) { + // A prompt blocked by an onBeforeSubmitPrompt hook never launches a turn. + writer.finish(); + const completion = await handle.completion; + throw new Error( + completion.state === 'blocked' + ? 'Prompt hook blocked the request.' + : 'Prompt turn could not be started', + ); + } + const result = await turn.result; + + // Turn settled, but `-p` is not done until the print-mode background + // policy says so (config-driven: exit / drain / steer). Flush the buffered + // assistant message first so a long drain/steer wait does not withhold the + // final message. + writer.flushAssistant(); + if (result.type === 'completed') { + const configService = app.accessor.get(IConfigService); + const taskConfig = resolveAgentTaskConfig(configService); + const goalService = agent.accessor.get(IAgentGoalService); + const cronService = session.accessor.get(ISessionCronService); + try { + await applyPrintBackgroundPolicy({ + mode: resolvePrintBackgroundMode(configService), + ceilingS: taskConfig?.printWaitCeilingS ?? PRINT_WAIT_CEILING_S_DEFAULT, + maxTurns: taskConfig?.printMaxTurns ?? PRINT_MAX_TURNS_DEFAULT, + countPending: () => countPendingBackgroundTasks(session), + drain: () => drainBackgroundTasks(session, taskConfig?.printWaitCeilingS), + turnEndings, + skipTurnId: turn.id, + warn: (message) => stderr.write(`Warning: ${message}\n`), + now: () => Date.now(), + goalActive: () => goalService.getGoal().goal?.status === 'active', + cronNextFireAt: () => cronService.getNextFireTime(), + }); + } catch (error) { + // A steered turn that fails fails the run (v1 parity). Anything else + // is best-effort: a wedged background task must not fail the (already + // completed) main turn. + if (error instanceof PrintSteeredTurnFailedError) { + writer.finish(); + throw error; + } + stderr.write( + `Warning: print background policy failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } + writer.finish(); + return; + } + writer.finish(); + throw new Error(formatNativeTurnFailure(result)); + } catch (error) { + writer.finish(); + throw error instanceof Error ? error : new Error(String(error)); + } finally { + subscription.dispose(); + } +} + +async function runNativeGoal( + app: Scope, + session: ISessionScopeHandle, + agent: IAgentScopeHandle, + goal: HeadlessGoalCreate, + model: string | undefined, + outputFormat: PromptOutputFormat, + stdout: PromptOutput, + stderr: PromptOutput, +): Promise<void> { + requireConfiguredModel(model); + const goalService = agent.accessor.get(IAgentGoalService); + await goalService.createGoal({ + objective: goal.objective, + replace: goal.replace, + }); + let completedSnapshot: { readonly status: string } | null = null; + const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => { + if (event.type === 'goal.updated') { + const updated = event as unknown as GoalUpdated; + if (updated.change?.kind === 'completion' && updated.snapshot !== null) { + completedSnapshot = updated.snapshot; + } + } + }); + try { + await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr); + } finally { + subscription.dispose(); + const snapshot = completedSnapshot ?? goalService.getGoal().goal; + if (outputFormat === 'stream-json') { + stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`); + } else { + stderr.write(`${formatGoalSummaryText(snapshot)}\n`); + } + if (snapshot !== null && snapshot.status !== 'complete') { + process.exitCode = goalExitCode(snapshot.status); + } + } +} + +function dispatchNativeEvent( + writer: PromptTurnWriter, + event: Event2<any>, + stderr: PromptOutput, +): void { + switch (event.type) { + case 'turn.step.started': + case 'turn.step.interrupted': + writer.flushAssistant(); + return; + case 'turn.step.retrying': + writer.discardAssistant(); + writer.writeRetrying(event as unknown as TurnStepRetrying); + return; + case 'assistant.delta': + writer.writeAssistantDelta((event as unknown as AssistantDelta).delta); + return; + case 'hook.result': + writer.writeHookResult(event as unknown as HookResult); + return; + case 'thinking.delta': + writer.writeThinkingDelta((event as unknown as ThinkingDelta).delta); + return; + case 'tool.call.started': { + const started = event as unknown as ToolCallStarted; + writer.writeToolCall(started.toolCallId, started.name, started.args); + return; + } + case 'tool.call.delta': { + const delta = event as unknown as ToolCallDelta; + writer.writeToolCallDelta(delta.toolCallId, delta.name, delta.argumentsPart); + return; + } + case 'tool.result': { + const result = event as unknown as ToolResultEvent; + writer.writeToolResult(result.toolCallId, result.output); + return; + } + case 'tool.progress': { + const progress = (event as unknown as ToolProgress).update; + if (progress.text !== undefined && progress.text.length > 0) { + stderr.write(progress.text.endsWith('\n') ? progress.text : `${progress.text}\n`); + } + return; + } + } +} + +export type PrintTurnEnding = TurnEnded; + +/** + * Source of `turn.ended` events for the print steer loop. `next` resolves with + * the next ending (skipping `skipTurnId`, the main turn's own buffered + * ending), or `null` when `remainingMs` elapses first. + */ +export interface PrintTurnEndings { + next(remainingMs: number, skipTurnId: number): Promise<PrintTurnEnding | null>; +} + +/** + * Buffered `turn.ended` collector fed from the agent event bus. Events that + * arrive while no one is waiting are queued, so endings that fire between the + * main turn settling and the policy loop starting are not missed. + */ +export function createPrintTurnEndings(): PrintTurnEndings & { + push: (event: PrintTurnEnding) => void; +} { + const buffer: PrintTurnEnding[] = []; + let waiter: ((ending: PrintTurnEnding | null) => void) | undefined; + return { + push: (event) => { + const resolve = waiter; + if (resolve !== undefined) { + waiter = undefined; + resolve(event); + return; + } + buffer.push(event); + }, + next: async (remainingMs, skipTurnId) => { + const deadlineAt = Date.now() + remainingMs; + const waitOnce = (ms: number): Promise<PrintTurnEnding | null> => + new Promise((resolve) => { + let settled = false; + const settle = (value: PrintTurnEnding | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + waiter = undefined; + // oxlint-disable-next-line promise/no-multiple-resolved -- `settled` guards the single resolve; the rule cannot see it + resolve(value); + }; + // A delay beyond the host timer ceiling (an explicit + // `print_wait_ceiling_s` or a far-future cron fire can still reach + // it) is clamped by `setClampedTimeout`, so the timer can expire + // early: the loop below treats that as a chunk boundary and + // re-arms against the real deadline. + const timer = Number.isFinite(ms) + ? setClampedTimeout(() => { + settle(null); + }, ms) + : undefined; + waiter = settle; + }); + for (;;) { + while (buffer.length > 0) { + const ending = buffer.shift()!; + if (ending.turnId !== skipTurnId) return ending; + } + const ms = deadlineAt - Date.now(); + if (ms <= 0) return null; + const ending = await waitOnce(ms); + // Timer-chunk boundary, not the real deadline: keep waiting. + if (ending === null) continue; + if (ending.turnId !== skipTurnId) return ending; + // The skipped turn's own ending: keep waiting within the same budget. + } + }, + }; +} + +/** A background-task completion steered a new main turn that did not complete. */ +export class PrintSteeredTurnFailedError extends Error {} + +export interface PrintBackgroundPolicyInput { + readonly mode: PrintBackgroundMode; + readonly ceilingS: number; + readonly maxTurns: number; + readonly countPending: () => number; + readonly drain: () => Promise<void>; + readonly turnEndings: PrintTurnEndings; + readonly skipTurnId: number; + readonly warn: (message: string) => void; + readonly now: () => number; + /** + * Reports whether an agent goal is still `active`. v2 drives goal + * continuation as new turns (v1 keeps a single turn alive), so a `-p` goal + * run must stay alive until the goal leaves `active`, independent of the + * background policy. + */ + readonly goalActive?: () => boolean; + /** + * Reports the next scheduled cron fire time (epoch ms), or `null` when no + * cron task has a future fire. While it returns non-null the policy keeps + * the process alive — the cron tick timer itself is unref'd — waiting for + * the fire to steer a new turn, then re-evaluating (a fired one-shot task + * disappears; a recurring one reports its advanced next fire). Cron + * liveness is independent of the background mode: it applies under + * `exit`/`drain` too (v1 parity). Omitted = no cron waiting. + */ + readonly cronNextFireAt?: () => number | null; +} + +/** + * Apply the print-mode (`pythinker -p`) background-resource policy after the main + * turn completes. A single loop re-evaluates the Session's live resources in + * order on every round and stays alive while any of them is pending: + * - goal : while a goal is `active`, keep waiting for its continuation + * turns (bounded by `ceilingS` as a safety net), regardless of + * the background mode; the goal summary drives the exit code. + * - cron : while `cronNextFireAt` reports a future fire, keep waiting — + * the cron tick timer is unref'd, so the process must hold the + * event loop itself (v1 parity, independent of the mode). The + * fire steers a new turn; a steered turn that does not complete + * fails the run. Each round re-reads the next fire time, so a + * fired one-shot task ends the wait while a recurring one keeps + * it. A fire time that stays unchanged and in the past across + * two consecutive rounds means the tick is wedged: warn once and + * stop cron waiting instead of spinning. + * - mode : 'exit' → return immediately; + * 'drain' → suppress + drain background tasks, then return; + * 'steer' → while background tasks are still pending, stay alive + * so task completions steer new main turns; return once + * quiescent, or when the wall-clock ceiling (`ceilingS`) or the + * turn cap (`maxTurns`) is reached. A steered turn that does not + * complete fails the run. + * The steer ceiling deadline is set once on entry, so goal/cron waiting + * consumes the same budget. + */ +export async function applyPrintBackgroundPolicy( + input: PrintBackgroundPolicyInput, +): Promise<void> { + const deadline = input.now() + input.ceilingS * 1000; + let turns = 0; + // Cron anti-spin guard: the last fire time seen already in the past. Two + // consecutive rounds with the same past fire time mean the tick never ran. + let lastPastFireAt: number | undefined; + let cronWedged = false; + for (;;) { + // (a) goal: while a goal is `active`, keep waiting for its continuation + // turns. Also wake on a short poll: a goal can leave `active` without any + // further turn.ended (budget block at a turn boundary, or a pause after a + // continuation-launch failure), which would otherwise hang the run until + // the ceiling. A continuation turn that does not complete pauses/blocks + // the goal, so the condition exits on the next check. + while (input.goalActive?.() === true) { + const ended = await input.turnEndings.next( + Math.min(deadline - input.now(), GOAL_WAIT_POLL_MS), + input.skipTurnId, + ); + if (ended === null && input.now() >= deadline) { + input.warn(`print goal wait ceiling reached (${input.ceilingS}s), finishing`); + return; + } + } + + // (b) cron: keep the process alive until the pending fire steered a turn + // (one-shot tasks vanish after firing; recurring ones advance their next + // fire), then re-evaluate from the top. + if (!cronWedged && input.cronNextFireAt !== undefined) { + const fireAt = input.cronNextFireAt(); + if (fireAt !== null) { + if (fireAt <= input.now() && lastPastFireAt === fireAt) { + cronWedged = true; + input.warn( + 'print cron wait: next fire time stuck in the past; cron tick appears wedged, giving up on cron', + ); + } else { + if (fireAt <= input.now()) lastPastFireAt = fireAt; + const ended = await input.turnEndings.next( + Math.max(fireAt - input.now(), 0) + CRON_FIRE_GRACE_MS, + input.skipTurnId, + ); + if (ended !== null && ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + // Fire observed (or its grace elapsed without a turn): re-read the + // next fire time from the top. + continue; + } + } + } + + // (c) background-task mode. + if (input.mode === 'exit') return; + if (input.mode === 'drain') { + await input.drain(); + return; + } + + // 'steer' + turns += 1; + if (input.now() >= deadline) { + input.warn(`print steer ceiling reached (${input.ceilingS}s), finishing`); + return; + } + if (turns > input.maxTurns) { + input.warn(`print steer max turns reached (${input.maxTurns}), finishing`); + return; + } + if (input.countPending() === 0) return; + const ended = await input.turnEndings.next(deadline - input.now(), input.skipTurnId); + if (ended === null) return; + if (ended.reason !== 'completed') { + throw new PrintSteeredTurnFailedError(formatTurnEndingFailure(ended)); + } + } +} + +function formatTurnEndingFailure(ending: PrintTurnEnding): string { + if (ending.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (ending.error !== undefined) return `${ending.error.code}: ${ending.error.message}`; + if (ending.reason === 'blocked') { + return 'Prompt hook blocked the request.'; + } + return `Prompt turn ended with reason: ${ending.reason}`; +} + +function countPendingBackgroundTasks(session: ISessionScopeHandle): number { + let count = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + count += handle.accessor.get(IAgentTaskService).list(true).length; + } + return count; +} + +async function drainBackgroundTasks( + session: ISessionScopeHandle, + ceilingS: number | undefined, +): Promise<void> { + const ceilingMs = + typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0 + ? ceilingS * 1000 + : PRINT_WAIT_CEILING_S_DEFAULT * 1000; + + const deadline = Date.now() + ceilingMs; + const seen = new Set<string>(); + const allWaiters: Promise<unknown>[] = []; + while (Date.now() < deadline) { + const batch: Promise<unknown>[] = []; + const suppressions: Promise<void>[] = []; + let activeCount = 0; + for (const handle of session.accessor.get(IAgentLifecycleService).list()) { + const taskService = handle.accessor.get(IAgentTaskService); + for (const task of taskService.list(true)) { + activeCount++; + if (seen.has(task.taskId)) continue; + seen.add(task.taskId); + suppressions.push(taskService.suppressTerminalNotification(task.taskId)); + const remaining = Math.max(1, deadline - Date.now()); + const waiter = taskService.wait(task.taskId, remaining); + batch.push(waiter); + allWaiters.push(waiter); + } + } + if (suppressions.length > 0) await Promise.all(suppressions); + if (activeCount === 0 || batch.length === 0) break; + await Promise.all(batch); + } + if (allWaiters.length > 0) await Promise.all(allWaiters); +} + +function formatNativeTurnFailure(result: LoopRunResult): string { + if (result.type === 'failed') { + const error = result.error as { readonly code?: string; readonly message?: string } | undefined; + if (error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } + if (error?.code !== undefined) { + return `${error.code}: ${error.message ?? ''}`.trimEnd(); + } + if (result.error instanceof Error) { + return result.error.message; + } + } + return `Prompt turn ended with reason: ${result.type}`; +} diff --git a/apps/pythinker-code/src/cli/v2/validate-config.ts b/apps/pythinker-code/src/cli/v2/validate-config.ts new file mode 100644 index 00000000..a5b8e191 --- /dev/null +++ b/apps/pythinker-code/src/cli/v2/validate-config.ts @@ -0,0 +1,187 @@ +/** + * V2 config.toml validation for `pythinker doctor`. + * + * Loaded lazily (dynamic import) by the doctor command on the default + * agent-core-v2 path, so the v2 module graph stays off the legacy doctor path. + * Validation uses the engine's own section registry instead of the legacy + * whole-document strict schema: + * importing the package root runs every built-in section's side-effect + * registration ("import = register"), and `ConfigRegistry` is then + * constructed directly — no DI container, no `ConfigService`, no file IO. + * + * Semantics deliberately mirror the v2 engine rather than v1: + * - a registered section that fails schema validation is an error (the + * engine would silently ignore that section at runtime; surfacing it is + * doctor's job); + * - a top-level key with no registered section passes through the engine + * untouched, so it is reported as a non-fatal warning — except the known + * schema-less domains the engine consumes directly (`default_model`, …); + * - section-declared key renames (`deprecations`) and renamed env vars + * (`deprecatedEnv` bindings actually supplying a value) surface as + * non-fatal warnings, reusing the engine's own detection + * (`collectKeyDeprecations`) and mirroring `ConfigService`'s env-fallback + * warning rule. + */ + +import { parse as parseToml } from 'smol-toml'; +import { z } from 'zod'; + +import { + ConfigRegistry, + type AnyEnvBindings, + type EnvBinding, +} from '@pymodel/agent-core-v2'; +import { collectKeyDeprecations } from '@pymodel/agent-core-v2/app/config/deprecations'; +import { + camelToSnake, + describeTomlSyntaxError, + isPlainObject, + transformTomlData, +} from '@pymodel/agent-core-v2/app/config/toml'; + +/** + * Top-level domains the v2 engine reads via `IConfigService.get` / `inspect` + * without registering a schema (free-form values, structurally validated + * nowhere): `defaultModel` / `defaultProvider` (`kosongConfig` default + * pointers), `modelOverrides` (`llmRequester` / `profile`), and `telemetry` + * (read by the CLI itself). + */ +const SCHEMALESS_DOMAINS: ReadonlySet<string> = new Set([ + 'defaultModel', + 'defaultProvider', + 'modelOverrides', + 'telemetry', +]); + +interface V2ConfigValidationIssue { + readonly path: readonly (string | number)[]; + readonly message: string; +} + +/** + * Matches the shape `handleDoctor` extracts from `error.details` (the SDK's + * `PythinkerConfigValidationIssue` list), so the doctor formatter renders v2 + * issues exactly like v1 ones. + */ +class V2ConfigValidationError extends Error { + readonly details: { readonly validationIssues: readonly V2ConfigValidationIssue[] }; + + constructor(issues: readonly V2ConfigValidationIssue[]) { + super('v2 config validation failed'); + this.details = { validationIssues: issues }; + } +} + +/** + * Validate `text` as config.toml against the v2 engine's section registry. + * Throws on TOML syntax errors and on any registered section failing its + * schema; returns non-fatal warnings (one per line) for unknown top-level + * keys, deprecated config keys, and deprecated env vars in use. + */ +export function validateConfigTomlV2( + text: string, + filePath: string, + getEnv: (name: string) => string | undefined = (name) => process.env[name], +): string | undefined { + let data: Record<string, unknown> = {}; + if (text.trim().length > 0) { + try { + data = parseToml(text) as Record<string, unknown>; + } catch (error) { + throw new Error(`Invalid TOML in ${filePath}: ${describeTomlSyntaxError(error)}`, { + cause: error, + }); + } + } + + const registry = new ConfigRegistry(); + const transformed = transformTomlData(data, registry); + + const issues: V2ConfigValidationIssue[] = []; + const unknownKeys: string[] = []; + for (const [domain, value] of Object.entries(transformed)) { + if (registry.getSection(domain) === undefined) { + if (!SCHEMALESS_DOMAINS.has(domain)) unknownKeys.push(camelToSnake(domain)); + continue; + } + try { + registry.validate(domain, value); + } catch (error) { + if (!(error instanceof z.ZodError)) throw error; + for (const issue of error.issues) { + issues.push({ + path: [ + domain, + ...issue.path.map((segment) => + typeof segment === 'number' ? segment : String(segment), + ), + ], + message: issue.message, + }); + } + } + } + + if (issues.length > 0) throw new V2ConfigValidationError(issues); + + const warnings: string[] = []; + for (const diagnostic of collectKeyDeprecations(data, registry.listSections())) { + warnings.push(diagnostic.message); + } + warnings.push(...collectEnvDeprecations(registry, getEnv)); + if (unknownKeys.length > 0) { + warnings.push( + `Unknown top-level ${unknownKeys.length === 1 ? 'key' : 'keys'} ignored by the v2 engine: ${unknownKeys.join(', ')}.`, + ); + } + return warnings.length > 0 ? warnings.join('\n') : undefined; +} + +/** + * Warn about renamed env vars that actually supply a value, mirroring + * `ConfigService`'s `resolveBinding`: the deprecated name only resolves (and + * thus only warns) when the primary var is absent or fails to parse. + */ +function collectEnvDeprecations( + registry: ConfigRegistry, + getEnv: (name: string) => string | undefined, +): string[] { + const warnings = new Set<string>(); + for (const section of registry.listSections()) { + if (section.env === undefined) continue; + walkEnvBindings(section.env, (binding) => { + if (typeof binding === 'string' || binding.deprecatedEnv === undefined) return; + const primary = getEnv(binding.env); + if ( + primary !== undefined && + (binding.parse === undefined || binding.parse(primary) !== undefined) + ) { + return; + } + const deprecated = getEnv(binding.deprecatedEnv); + if (deprecated === undefined) return; + if (binding.parse !== undefined && binding.parse(deprecated) === undefined) return; + warnings.add( + `Environment variable ${binding.deprecatedEnv} is deprecated; use ${binding.env} instead.`, + ); + }); + } + return [...warnings]; +} + +function isEnvBinding(value: AnyEnvBindings): value is EnvBinding { + return typeof value === 'string' || (isPlainObject(value) && 'env' in value); +} + +function walkEnvBindings( + bindings: AnyEnvBindings, + visit: (binding: EnvBinding) => void, +): void { + if (isEnvBinding(bindings)) { + visit(bindings); + return; + } + for (const value of Object.values(bindings)) { + if (value !== undefined) walkEnvBindings(value, visit); + } +} diff --git a/apps/pythinker-code/src/cli/version.ts b/apps/pythinker-code/src/cli/version.ts index 0305a3ce..a2dbc765 100644 --- a/apps/pythinker-code/src/cli/version.ts +++ b/apps/pythinker-code/src/cli/version.ts @@ -7,24 +7,15 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; -import { createPythinkerDefaultHeaders, type PythinkerHostIdentity } from '@pymodel/pythinker-code-oauth'; +import { createPythinkerUserAgent, PYTHINKER_CODE_PLATFORM, type PythinkerHostIdentity } from '@pymodel/pythinker-code-oauth'; import { CLI_USER_AGENT_PRODUCT } from '#/constant/app'; -import { getDataDir } from '../utils/paths'; import { PYTHINKER_BUILD_INFO } from './build-info'; const MODULE_DIR = import.meta.dirname; -/** - * Locate the host `package.json`, or `null` when there is none. - * - * A packaged native binary (SEA) ships no `package.json` at all, so every - * caller that only wants the path for diagnostics must take the `null` branch - * instead of crashing the command — `pythinker doctor` did exactly that on - * native installs. - */ -export function findHostPackageJsonPath(): string | null { +export function getHostPackageJsonPath(): string { // Walk upwards from this file's directory until a `package.json` shows up, // so both dev (`tsx src/main.ts` — this file in `src/cli/`, pkg 2 levels // up) and prod (`node dist/main.mjs` — this code bundled into `dist/`, @@ -39,27 +30,13 @@ export function findHostPackageJsonPath(): string | null { if (parent === dir) break; dir = parent; } - return null; -} - -export function getHostPackageJsonPath(): string { - const found = findHostPackageJsonPath(); - if (found === null) { - throw new Error(`Could not locate package.json near ${MODULE_DIR}`); - } - return found; + throw new Error(`Could not locate package.json near ${MODULE_DIR}`); } export function getHostPackageRoot(): string { return dirname(getHostPackageJsonPath()); } -/** The host package root, or `null` on a native binary that has no package. */ -export function findHostPackageRoot(): string | null { - const found = findHostPackageJsonPath(); - return found === null ? null : dirname(found); -} - export function getVersion(): string { if (PYTHINKER_BUILD_INFO.version !== undefined) { return PYTHINKER_BUILD_INFO.version; @@ -72,14 +49,16 @@ export function getVersion(): string { export function createPythinkerCodeHostIdentity(version = getVersion()): PythinkerHostIdentity { return { - userAgentProduct: CLI_USER_AGENT_PRODUCT, + productName: CLI_USER_AGENT_PRODUCT, version, + platform: PYTHINKER_CODE_PLATFORM, }; } -export function buildPythinkerDefaultHeaders(version: string): Record<string, string> { - return createPythinkerDefaultHeaders({ - homeDir: getDataDir(), - ...createPythinkerCodeHostIdentity(version), - }); +/** + * Product User-Agent (`pythinker-code-cli/<version>`) for ad-hoc outbound fetches + * that don't go through the provider pipeline (registry / catalog imports). + */ +export function createPythinkerCodeUserAgent(version = getVersion()): string { + return createPythinkerUserAgent(createPythinkerCodeHostIdentity(version)); } diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index 1a040824..cabb6506 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -1,19 +1,43 @@ import { ErrorCodes } from '@pymodel/pythinker-code-sdk'; -export const PRODUCT_NAME = 'Pythinker'; +export const PRODUCT_NAME = 'Pythinker Code'; export const CLI_COMMAND_NAME = 'pythinker'; export const PROCESS_NAME = 'pythinker-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'pythinker-code-cli'; export const CLI_UI_MODE = 'shell'; -// Telemetry ui_mode for the `pythinker web` / `pythinker server run` host. Same product +// Telemetry ui_mode for the `pythinker web` host. Same product // as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. export const WEB_UI_MODE = 'web'; +// User-Agent suffix for the `pythinker web` host: its requests go out as +// `pythinker-code-cli/<version> (web)` so upstream can tell web-UI traffic +// apart from direct CLI runs without changing the product token or platform. +export const WEB_USER_AGENT_SUFFIX = 'web'; // Give telemetry a short flush window without making CLI exit feel stuck. export const CLI_SHUTDOWN_TIMEOUT_MS = 3000; +// Upper bound on headless (`pythinker -p`) shutdown. A wedged cleanup step (e.g. a +// SessionEnd hook, an MCP shutdown, or a connection blackholed by a restrictive +// firewall) must not keep a completed run alive indefinitely — once this elapses +// we stop waiting on cleanup and let the run return. +export const PROMPT_CLEANUP_TIMEOUT_MS = 8000; + +// Grace after a headless run has fully completed (turn done, cleanup attempted) +// before force-exiting. `pythinker -p` otherwise relies on the event loop draining to +// exit; a stray ref'd handle (socket/timer/child) left over from the run would +// wedge it. The guard timer is unref'd, so a healthy run still exits naturally +// well before this fires. +export const HEADLESS_FORCE_EXIT_GRACE_MS = 2000; + +// Max time to wait for buffered stdout/stderr to flush before arming the +// force-exit fallback. A slow/piped consumer's still-draining stdio is a +// legitimate ref'd handle — flushing first prevents the fallback from +// truncating completed output. Bounded so a permanently-stuck consumer can't +// re-introduce the hang. +export const HEADLESS_STDIO_DRAIN_TIMEOUT_MS = 10000; + // Published npm package name; this can differ from the executable command. export const NPM_PACKAGE_NAME = '@pymodel/pythinker-code'; @@ -27,20 +51,24 @@ export const PYTHINKER_CODE_BIN_DIR_NAME = 'bin'; export const PYTHINKER_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const PYTHINKER_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; -export const PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME = 'install.log'; export const PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; +export const PYTHINKER_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json'; export const PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const PYTHINKER_CODE_BANNER_DIR_NAME = 'banner'; export const PYTHINKER_CODE_BANNER_STATE_FILE_NAME = 'state.json'; +// Managed Pythinker auth provider key shared with OAuth/SDK config. +export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:pythinker-code'; + // SDK/core error code that tells the TUI to show a login-required startup // notice. Derived from sdk's ErrorCodes so a future rename in core // auto-propagates instead of silently breaking the startup recovery path. export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED; export const FEEDBACK_ISSUE_URL = 'https://github.com/PyModel/pythinker-code/issues'; -export const PYTHINKER_CODE_CHANGELOG_URL = - 'https://pymodel.github.io/pythinker-code/release-notes/changelog.html'; +// Sign-up / sign-in page offered to signed-out users so they can create an +// account and submit feedback through the authenticated channel next time. +export const PYTHINKER_CODE_SIGNUP_URL = 'https://www.kimi.com/code'; // Sent in the feedback `version` field so the backend can distinguish this // TypeScript client from clients that send a bare version. @@ -50,23 +78,29 @@ export const FEEDBACK_VERSION_PREFIX = 'pythinker-code-'; export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. -export const PYTHINKER_CODE_CDN_BASE = 'https://code.pythinker.com/pythinker-code'; -// The only update source this client reads. The plain-text `/latest` endpoint -// still exists on the CDN for install.sh and for clients shipped before the -// manifest, but it carries no per-platform artifact data, so reading it here -// would report an unverifiable target as verified. +export const PYTHINKER_CODE_CDN_BASE = 'https://code.kimi.com/pythinker-code'; +export const PYTHINKER_CODE_CDN_LATEST_URL = `${PYTHINKER_CODE_CDN_BASE}/latest`; +// Rollout manifest consumed by update checks; the plain-text `/latest` above +// stays unchanged forever — already-shipped clients hard-fail on non-semver +// bodies, and the CDN install scripts read it for fresh installs. export const PYTHINKER_CODE_CDN_LATEST_JSON_URL = `${PYTHINKER_CODE_CDN_BASE}/latest.json`; -export const PYTHINKER_CODE_TIPS_BANNER_URL = 'https://cdn.pythinker.com/pythinker-code-tips/tips.json'; -export const PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL = `${PYTHINKER_CODE_CDN_BASE}/plugins/marketplace.json`; -export const PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL'; -export const PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS = 'pythinker'; -export const ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS = 'anthropic'; -export const ANTHROPIC_PLUGIN_MARKETPLACE_REPOSITORY = 'anthropics/claude-plugins-official'; -export const CLAUDE_PLUGIN_MARKETPLACE_PATH = '.claude-plugin/marketplace.json'; -export const ANTHROPIC_PLUGIN_MARKETPLACE_URL = - `https://raw.githubusercontent.com/${ANTHROPIC_PLUGIN_MARKETPLACE_REPOSITORY}/HEAD/${CLAUDE_PLUGIN_MARKETPLACE_PATH}`; -export const PYTHINKER_CODE_INSTALL_SH_URL = 'https://code.pythinker.com/pythinker-code/install.sh'; -export const PYTHINKER_CODE_INSTALL_PS1_URL = 'https://code.pythinker.com/pythinker-code/install.ps1'; +export const PYTHINKER_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/pythinker-code-tips/tips.json'; +// The marketplace catalog location constants live in the shared +// agent-core-v2 plugin domain (kap-server consumes them from there). +// Deep-path import: this module is evaluated on every CLI invocation, so it +// must not pull in the engine root. +export { + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, +} from '@pymodel/agent-core-v2/app/plugin/marketplace'; +// Official plugins whose usage bills against the user's plan quota. Installing +// one of these shows a quota note after the install result. +export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['pythinker-datasource']; +export const PYTHINKER_CODE_INSTALL_SH_URL = `${PYTHINKER_CODE_CDN_BASE}/install.sh`; +export const PYTHINKER_CODE_INSTALL_PS1_URL = `${PYTHINKER_CODE_CDN_BASE}/install.ps1`; +// Official download page, referenced by prompt copy that steers users away +// from third-party install sources. +export const PYTHINKER_CODE_OFFICIAL_INSTALL_URL = 'https://www.kimi.com/code'; // Native install commands, split by platform. Use these for prompt copy and spawn calls only; do not assemble the strings elsewhere. export const NATIVE_INSTALL_COMMAND_UNIX = `curl -fsSL ${PYTHINKER_CODE_INSTALL_SH_URL} | bash`; diff --git a/apps/pythinker-code/src/feedback/archive.ts b/apps/pythinker-code/src/feedback/archive.ts new file mode 100644 index 00000000..439f68d1 --- /dev/null +++ b/apps/pythinker-code/src/feedback/archive.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { getCacheDir } from '../utils/paths'; + +const STALE_ARCHIVE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours. + +/** + * A file produced for a feedback attachment upload. Both the session log + * archive and the codebase archive share this shape; the generic uploader + * consumes it without caring how the file was produced. + */ +export interface FeedbackArchive { + readonly path: string; + readonly size: number; + readonly sha256: string; + readonly fingerprint: string; + readonly fileCount: number; + /** Directory created exclusively for this archive and safe to remove after upload. */ + readonly cleanupDir?: string; +} + +export async function createFeedbackArchivePath(filename: string): Promise<{ + readonly archivePath: string; + readonly cleanupDir: string; +}> { + const archivePath = await createArchivePath(filename); + return { archivePath, cleanupDir: archivePathCleanupDir(archivePath) }; +} + +/** + * Remove feedback-upload archive directories older than 24 hours. Packaging + * cleans up its own archive on success and on failure, but a killed process + * or an empty parent dir can still leave leftovers behind; this is a + * best-effort backstop so the cache dir does not grow without bound. + * + * `dir` is injectable for tests; production callers leave it as the default. + */ +export async function removeStaleFeedbackUploads( + options: { readonly now?: number; readonly dir?: string } = {}, +): Promise<void> { + const now = options.now ?? Date.now(); + const dir = options.dir ?? join(getCacheDir(), 'feedback-uploads'); + const entries = await readdir(dir, { withFileTypes: true }).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (entries === null) return; + + const cutoff = now - STALE_ARCHIVE_MAX_AGE_MS; + await Promise.all( + entries.map(async (entry) => { + if (!entry.isDirectory() && !entry.isSymbolicLink()) return; + const target = join(dir, entry.name); + const targetStat = await stat(target).catch(() => null); + if (targetStat === null || targetStat.mtimeMs >= cutoff) return; + await rm(target, { recursive: true, force: true }).catch(() => {}); + }), + ); +} + +async function createArchivePath(filename: string): Promise<string> { + await removeStaleFeedbackUploads(); + const root = join(getCacheDir(), 'feedback-uploads'); + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'upload-')); + return join(dir, filename); +} + +function archivePathCleanupDir(archivePath: string): string { + return dirname(archivePath); +} diff --git a/apps/pythinker-code/src/feedback/codebase/filter.ts b/apps/pythinker-code/src/feedback/codebase/filter.ts new file mode 100644 index 00000000..1df5976e --- /dev/null +++ b/apps/pythinker-code/src/feedback/codebase/filter.ts @@ -0,0 +1,92 @@ +export const DEFAULT_MAX_FILES = 50000; +export const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; +// Upper bound for the compressed codebase archive, aligned with the backend's +// per-upload limit. The scanner uses cumulative raw file size as a conservative +// estimate so the resulting zip stays within this bound. +export const DEFAULT_MAX_ARCHIVE_SIZE = 500 * 1024 * 1024; + +const IGNORED_DIR_NAMES: ReadonlySet<string> = new Set([ + '.git', + '.hg', + '.svn', + 'node_modules', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + '.turbo', + '.cache', + '.parcel-cache', + 'coverage', + '.nyc_output', + 'target', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.venv', + 'venv', + 'env', + '.idea', +]); + +const SENSITIVE_DIR_NAMES: ReadonlySet<string> = new Set([ + '.ssh', + '.gnupg', + '.aws', + '.kube', + '.docker', +]); + +const SENSITIVE_FILE_NAMES: ReadonlySet<string> = new Set([ + '.env', + 'id_rsa', + 'id_dsa', + 'id_ecdsa', + 'id_ed25519', + 'credentials.json', + 'service-account.json', + 'serviceAccount.json', + '.netrc', + '.htpasswd', + '.pypirc', + '.npmrc', + '.envrc', + '.yarnrc', + '.yarnrc.yml', +]); + +const SENSITIVE_FILE_SUFFIXES: readonly string[] = [ + '.pem', + '.key', + '.p12', + '.pfx', + '.jks', + '.keystore', +]; + +const ENV_FILE_ALLOWED_SUFFIXES: ReadonlySet<string> = new Set(['.example', '.sample', '.template']); + +export function isIgnoredDirName(name: string): boolean { + return IGNORED_DIR_NAMES.has(name); +} + +export function isSensitivePath(relativePath: string): boolean { + const segments = relativePath.split('/'); + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (segment !== undefined && SENSITIVE_DIR_NAMES.has(segment)) return true; + } + + const base = segments.at(-1); + if (base === undefined || base.length === 0) return false; + if (SENSITIVE_FILE_NAMES.has(base)) return true; + if (SENSITIVE_FILE_SUFFIXES.some((suffix) => base.endsWith(suffix))) return true; + + if (base.startsWith('.env.')) { + const suffix = base.slice('.env'.length); + return !ENV_FILE_ALLOWED_SUFFIXES.has(suffix); + } + + return false; +} diff --git a/apps/pythinker-code/src/feedback/codebase/index.ts b/apps/pythinker-code/src/feedback/codebase/index.ts new file mode 100644 index 00000000..7ef51870 --- /dev/null +++ b/apps/pythinker-code/src/feedback/codebase/index.ts @@ -0,0 +1,3 @@ +export * from './packager'; +export * from './scanner'; +export * from './types'; diff --git a/apps/pythinker-code/src/feedback/codebase/packager.ts b/apps/pythinker-code/src/feedback/codebase/packager.ts new file mode 100644 index 00000000..cd4bf2c6 --- /dev/null +++ b/apps/pythinker-code/src/feedback/codebase/packager.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir, rm, stat } from 'node:fs/promises'; +import { dirname } from 'node:path'; + +import { ZipFile } from 'yazl'; + +import type { FeedbackArchive } from '../archive'; +import type { FeedbackCodebaseScanResult } from './types'; + +interface PackageEntry { + readonly absolutePath: string; + readonly archivePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +/** + * Pack the scanned codebase into a zip, with files placed at the zip root. + */ +export async function packageCodebase( + scan: FeedbackCodebaseScanResult, + archivePath: string, +): Promise<FeedbackArchive> { + const entries: PackageEntry[] = scan.files.map((file) => ({ + absolutePath: file.absolutePath, + archivePath: file.path, + size: file.size, + mtimeMs: file.mtimeMs, + })); + return packageEntries(entries, archivePath); +} + +async function packageEntries( + entries: readonly PackageEntry[], + archivePath: string, +): Promise<FeedbackArchive> { + if (entries.length === 0) { + throw new Error('Cannot package an empty feedback archive.'); + } + await mkdir(dirname(archivePath), { recursive: true }); + + const zip = new ZipFile(); + const hash = createHash('sha256'); + const output = createWriteStream(archivePath); + + try { + const done = new Promise<void>((resolvePromise, rejectPromise) => { + output.on('finish', resolvePromise); + output.on('error', rejectPromise); + zip.outputStream.on('error', rejectPromise); + }); + + zip.outputStream.on('data', (chunk: Buffer) => { + hash.update(chunk); + }); + zip.outputStream.pipe(output); + + for (const entry of entries) { + zip.addFile(entry.absolutePath, entry.archivePath, { + mtime: new Date(entry.mtimeMs), + mode: 0o100644, + }); + } + zip.end(); + await done; + + const archiveStat = await stat(archivePath); + return { + path: archivePath, + size: archiveStat.size, + sha256: hash.digest('hex'), + fingerprint: fingerprintEntries(entries), + fileCount: entries.length, + }; + } catch (error) { + // A failed zip (e.g. a source file vanished or became unreadable between + // scan and packaging) would otherwise leave a partial archive behind in + // the cache dir. Destroy the stream so the handle is released before we + // remove the file, then best-effort delete it. + output.destroy(); + await rm(archivePath, { force: true }).catch(() => {}); + throw error; + } +} + +function fingerprintEntries(entries: readonly PackageEntry[]): string { + const hash = createHash('sha256'); + for (const entry of entries) { + hash.update(entry.archivePath); + hash.update('\0'); + hash.update(String(entry.size)); + hash.update('\0'); + hash.update(String(Math.trunc(entry.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} diff --git a/apps/pythinker-code/src/feedback/codebase/scanner.ts b/apps/pythinker-code/src/feedback/codebase/scanner.ts new file mode 100644 index 00000000..6df42021 --- /dev/null +++ b/apps/pythinker-code/src/feedback/codebase/scanner.ts @@ -0,0 +1,217 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { + DEFAULT_MAX_ARCHIVE_SIZE, + DEFAULT_MAX_FILES, + DEFAULT_MAX_FILE_SIZE, + isIgnoredDirName, + isSensitivePath, +} from './filter'; +import type { + FeedbackCodebaseFile, + FeedbackCodebaseLimitExceeded, + FeedbackCodebaseScanResult, +} from './types'; + +const execFileAsync = promisify(execFile); + +export interface ScanCodebaseLimits { + readonly maxFiles: number; + readonly maxFileSize: number; + readonly maxArchiveSize: number; +} + +export interface ScanCodebaseOptions { + readonly limits?: { + readonly maxFiles?: number; + readonly maxFileSize?: number; + readonly maxArchiveSize?: number; + }; + readonly signal?: AbortSignal; +} + +interface CollectedFiles { + readonly files: FeedbackCodebaseFile[]; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} + +export async function scanCodebase( + rootInput: string, + options: ScanCodebaseOptions = {}, +): Promise<FeedbackCodebaseScanResult> { + const root = resolve(rootInput); + const limits = resolveLimits(options.limits); + throwIfAborted(options.signal); + const usedGitIgnore = await isInsideGitWorkTree(root); + const collected = usedGitIgnore + ? await scanWithGit(root, limits, options.signal) + : await scanWithoutFilter(root, limits, options.signal); + const sortedFiles = collected.files.toSorted((a, b) => a.path.localeCompare(b.path)); + + return { + root, + files: sortedFiles, + fingerprint: fingerprintFiles(sortedFiles), + usedGitIgnore, + exceedsLimit: collected.exceedsLimit, + }; +} + +function resolveLimits(limits: ScanCodebaseOptions['limits']): ScanCodebaseLimits { + return { + maxFiles: limits?.maxFiles ?? DEFAULT_MAX_FILES, + maxFileSize: limits?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE, + maxArchiveSize: limits?.maxArchiveSize ?? DEFAULT_MAX_ARCHIVE_SIZE, + }; +} + +async function isInsideGitWorkTree(root: string): Promise<boolean> { + try { + const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree']); + return stdout.trim() === 'true'; + } catch { + return false; + } +} + +async function scanWithGit( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise<CollectedFiles> { + const { stdout } = await execFileAsync( + 'git', + ['-C', root, 'ls-files', '-co', '--exclude-standard', '-z'], + { encoding: 'buffer', maxBuffer: 1024 * 1024 * 64, signal }, + ); + + throwIfAborted(signal); + const relativePaths = splitNull(stdout); + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let totalSize = 0; + + for (const relativePath of relativePaths) { + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + break; + } + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + break; + } + files.push(file); + totalSize += file.size; + } + } + + return { files, exceedsLimit }; +} + +async function scanWithoutFilter( + root: string, + limits: ScanCodebaseLimits, + signal?: AbortSignal, +): Promise<CollectedFiles> { + const files: FeedbackCodebaseFile[] = []; + let exceedsLimit: FeedbackCodebaseLimitExceeded | undefined; + let stopped = false; + let totalSize = 0; + + async function walk(dir: string): Promise<void> { + if (stopped) return; + throwIfAborted(signal); + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (stopped) return; + throwIfAborted(signal); + if (files.length >= limits.maxFiles) { + exceedsLimit = { reason: 'file-count', limit: limits.maxFiles }; + stopped = true; + return; + } + if (entry.isSymbolicLink()) continue; + const absolutePath = join(dir, entry.name); + if (entry.isDirectory()) { + if (isIgnoredDirName(entry.name)) continue; + await walk(absolutePath); + if (stopped) return; + continue; + } + if (!entry.isFile()) continue; + const relativePath = toPosixPath(relative(root, absolutePath)); + if (isSensitivePath(relativePath)) continue; + const file = await statFile(root, relativePath); + if (file) { + if (file.size > limits.maxFileSize) continue; + if (totalSize + file.size > limits.maxArchiveSize) { + exceedsLimit = { reason: 'total-size', limit: limits.maxArchiveSize }; + stopped = true; + return; + } + files.push(file); + totalSize += file.size; + } + } + } + + await walk(root); + return { files, exceedsLimit }; +} + +async function statFile(root: string, relativePath: string): Promise<FeedbackCodebaseFile | null> { + const absolutePath = resolve(root, relativePath); + // A tracked file can be deleted from the working tree but still listed by + // `git ls-files`; lstat then throws ENOENT. Treat unreadable/vanished paths + // like any other non-regular entry so one bad path does not abort the scan. + const stat = await lstat(absolutePath).catch(() => null); + if (stat === null || stat.isSymbolicLink() || !stat.isFile()) return null; + + return { + path: toPosixPath(relativePath), + absolutePath, + size: stat.size, + mtimeMs: stat.mtimeMs, + }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + const error = new Error('Codebase scan aborted.'); + error.name = 'AbortError'; + throw error; + } +} + +function fingerprintFiles(files: readonly FeedbackCodebaseFile[]): string { + const hash = createHash('sha256'); + for (const file of files) { + hash.update(file.path); + hash.update('\0'); + hash.update(String(file.size)); + hash.update('\0'); + hash.update(String(Math.trunc(file.mtimeMs))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +function splitNull(buffer: Buffer): string[] { + return buffer + .toString('utf8') + .split('\0') + .filter((item) => item.length > 0); +} + +function toPosixPath(value: string): string { + return value.split('\\').join('/'); +} diff --git a/apps/pythinker-code/src/feedback/codebase/types.ts b/apps/pythinker-code/src/feedback/codebase/types.ts new file mode 100644 index 00000000..a611d93a --- /dev/null +++ b/apps/pythinker-code/src/feedback/codebase/types.ts @@ -0,0 +1,19 @@ +export interface FeedbackCodebaseFile { + readonly path: string; + readonly absolutePath: string; + readonly size: number; + readonly mtimeMs: number; +} + +export interface FeedbackCodebaseLimitExceeded { + readonly reason: 'file-count' | 'total-size'; + readonly limit: number; +} + +export interface FeedbackCodebaseScanResult { + readonly root: string; + readonly files: readonly FeedbackCodebaseFile[]; + readonly fingerprint: string; + readonly usedGitIgnore: boolean; + readonly exceedsLimit?: FeedbackCodebaseLimitExceeded; +} diff --git a/apps/pythinker-code/src/feedback/feedback-attachments.ts b/apps/pythinker-code/src/feedback/feedback-attachments.ts new file mode 100644 index 00000000..df3b918a --- /dev/null +++ b/apps/pythinker-code/src/feedback/feedback-attachments.ts @@ -0,0 +1,190 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile, rm, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { detectInstallSource } from '#/cli/update/source'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { FeedbackAttachmentLevel } from '#/tui/commands/prompts'; +import { getLogDir } from '#/utils/paths'; +import { detectShellEnvironment } from '#/utils/process/shell-env'; + +import { createFeedbackArchivePath, type FeedbackArchive } from './archive'; +import { packageCodebase, scanCodebase, type FeedbackCodebaseScanResult } from './codebase'; +import { uploadArchive, type FeedbackUploadUrlApi } from './upload'; + +export const CODEBASE_ARCHIVE_FILENAME = 'repo.zip'; +export const SESSION_ARCHIVE_FILENAME = 'session.zip'; + +const CODEBASE_SCAN_TIMEOUT_MS = 3000; + +/** + * Stage 3 of the `/feedback` flow: prepare and upload each requested attachment + * independently. Attachment failures are non-fatal because the text feedback + * already exists, but any requested artifact that cannot be prepared/uploaded + * is reported as a partial attachment failure instead of silently downgrading + * the request. + * + * Returns `true` when at least one requested attachment failed so the caller + * can surface a partial-failure status. + */ +export async function submitFeedbackWithAttachments( + host: SlashCommandHost, + feedbackId: number, + level: FeedbackAttachmentLevel, +): Promise<boolean> { + const api = createFeedbackUploadApi(host); + + if (level === 'logs') { + const uploaded = await prepareAndUploadSessionArchive(host, api, feedbackId); + return !uploaded; + } + if (level === 'logs+codebase') { + const [sessionDir, scan] = await Promise.all([ + resolveCurrentSessionDir(host), + scanCodebaseForFeedback(host.state.appState.workDir), + ]); + const [uploadedSession, uploadedCodebase] = await Promise.all([ + prepareAndUploadSessionArchive(host, api, feedbackId, sessionDir), + prepareAndUploadCodebaseArchive(api, feedbackId, scan), + ]); + return !uploadedSession || !uploadedCodebase; + } + return false; +} + +async function prepareAndUploadSessionArchive( + host: SlashCommandHost, + api: FeedbackUploadUrlApi, + feedbackId: number, + knownSessionDir?: string, +): Promise<boolean> { + const sessionDir = knownSessionDir ?? (await resolveCurrentSessionDir(host)); + if (sessionDir === undefined) { + await logFeedbackUploadError(new Error('cannot locate the current session directory')); + return false; + } + return uploadProducedArchive(api, feedbackId, SESSION_ARCHIVE_FILENAME, async (archivePath) => { + const exported = await host.harness.exportSession({ + id: host.state.appState.sessionId, + outputPath: archivePath, + includeGlobalLog: true, + version: host.state.appState.version, + installSource: await detectInstallSource(), + shellEnv: detectShellEnvironment(), + }); + return archiveFromExportedSession(exported.zipPath); + }); +} + +async function prepareAndUploadCodebaseArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + scan: FeedbackCodebaseScanResult | undefined, +): Promise<boolean> { + if (scan === undefined) return false; + return uploadProducedArchive(api, feedbackId, CODEBASE_ARCHIVE_FILENAME, (archivePath) => + packageCodebase(scan, archivePath), + ); +} + +/** + * Shared lifecycle for a single attachment: create a temp archive path, let + * `produce` write the archive to it, upload it, then always remove the temp + * directory — even when `produce` or the upload throws. Both the session log + * archive and the codebase archive flow through here so their cleanup and + * error handling cannot drift apart. Every failure — including archive-path + * creation — is contained here as a non-fatal partial failure. + */ +async function uploadProducedArchive( + api: FeedbackUploadUrlApi, + feedbackId: number, + filename: string, + produce: (archivePath: string) => Promise<FeedbackArchive>, +): Promise<boolean> { + let cleanupDir: string | undefined; + try { + const target = await createFeedbackArchivePath(filename); + cleanupDir = target.cleanupDir; + const archive = await produce(target.archivePath); + await uploadArchive(api, { ...archive, cleanupDir: target.cleanupDir }, feedbackId, { + filename, + }); + return true; + } catch (error) { + await logFeedbackUploadError(error); + return false; + } finally { + if (cleanupDir !== undefined) { + await rm(cleanupDir, { recursive: true, force: true }).catch(() => {}); + } + } +} + +async function archiveFromExportedSession(zipPath: string): Promise<FeedbackArchive> { + const data = await readFile(zipPath); + const archiveStat = await stat(zipPath); + return { + path: zipPath, + size: archiveStat.size, + sha256: createHash('sha256').update(data).digest('hex'), + fingerprint: createHash('sha256').update(data).digest('hex'), + fileCount: 1, + }; +} + +async function resolveCurrentSessionDir(host: SlashCommandHost): Promise<string | undefined> { + try { + const sessions = await host.harness.listSessions({ workDir: host.state.appState.workDir }); + return sessions.find((session) => session.id === host.state.appState.sessionId)?.sessionDir; + } catch { + return undefined; + } +} + +async function scanCodebaseForFeedback( + workDir: string, +): Promise<FeedbackCodebaseScanResult | undefined> { + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, CODEBASE_SCAN_TIMEOUT_MS); + try { + return await scanCodebase(workDir, { signal: controller.signal }); + } catch (error) { + await logFeedbackUploadError(error); + return undefined; + } finally { + clearTimeout(timer); + } +} + +async function logFeedbackUploadError(error: unknown): Promise<void> { + try { + const logDir = getLogDir(); + await mkdir(logDir, { recursive: true }); + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + await appendFile(join(logDir, 'feedback-upload.log'), `${new Date().toISOString()} ${message}\n`); + } catch { + // best-effort logging only + } +} + +function createFeedbackUploadApi(host: SlashCommandHost): FeedbackUploadUrlApi { + return { + async createUploadUrl(input) { + const res = await host.harness.auth.createFeedbackUploadUrl(input); + if (res.kind !== 'ok') throw new Error(res.message); + return { + uploadId: res.uploadId, + parts: res.parts, + }; + }, + async completeUpload(input) { + const res = await host.harness.auth.completeFeedbackUpload({ + uploadId: input.uploadId, + parts: input.parts.map((part) => ({ partNumber: part.partNumber, etag: part.etag })), + }); + if (res.kind !== 'ok') throw new Error(res.message); + }, + }; +} diff --git a/apps/pythinker-code/src/feedback/upload.ts b/apps/pythinker-code/src/feedback/upload.ts new file mode 100644 index 00000000..629fc6a6 --- /dev/null +++ b/apps/pythinker-code/src/feedback/upload.ts @@ -0,0 +1,208 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; + +import type { FeedbackArchive } from './archive'; + +const MAX_ARCHIVE_SIZE = 524_288_000; // 500 MiB, matches the backend limit. +const DEFAULT_CONCURRENCY = 3; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_PART_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY_MS = 1_000; + +export interface FeedbackUploadPart { + readonly partNumber: number; + readonly url: string; + readonly method: string; + readonly size: number; +} + +export interface CreateFeedbackUploadUrlInput { + readonly feedbackId: number; + readonly filename: string; + readonly size: number; + readonly sha256: string; +} + +export interface CreateFeedbackUploadUrlResult { + readonly uploadId: number; + readonly parts: readonly FeedbackUploadPart[]; +} + +export interface CompletedUploadPart { + readonly partNumber: number; + readonly etag: string; +} + +export interface CompleteFeedbackUploadUrlInput { + readonly uploadId: number; + readonly parts: readonly CompletedUploadPart[]; +} + +export interface FeedbackUploadUrlApi { + createUploadUrl(input: CreateFeedbackUploadUrlInput): Promise<CreateFeedbackUploadUrlResult>; + completeUpload(input: CompleteFeedbackUploadUrlInput): Promise<void>; +} + +export interface UploadArchiveOptions { + /** Zip entry name sent to the backend. */ + readonly filename: string; + /** Abort a single part PUT if it does not complete within this many milliseconds. */ + readonly timeoutMs?: number; + /** Number of parts to upload concurrently (defaults to 3). */ + readonly concurrency?: number; + /** Per-part retry attempts after the first failure (defaults to 3). */ + readonly maxRetries?: number; + /** Called after each part finishes with the cumulative uploaded bytes. */ + readonly onProgress?: (uploadedBytes: number, totalBytes: number) => void; +} + +export async function uploadArchive( + api: FeedbackUploadUrlApi, + archive: FeedbackArchive, + feedbackId: number, + options: UploadArchiveOptions, +): Promise<void> { + if (archive.size > MAX_ARCHIVE_SIZE) { + throw new Error( + `Failed to upload archive: size ${archive.size} exceeds maximum allowed size ${MAX_ARCHIVE_SIZE}.`, + ); + } + const created = await api.createUploadUrl({ + feedbackId, + filename: options.filename, + size: archive.size, + sha256: archive.sha256, + }); + const completed = await uploadParts(archive.path, created.parts, archive.size, options); + await api.completeUpload({ uploadId: created.uploadId, parts: completed }); +} + +interface PartLayout { + readonly part: FeedbackUploadPart; + readonly start: number; +} + +function layoutParts(parts: readonly FeedbackUploadPart[]): PartLayout[] { + const sorted = parts.toSorted((a, b) => a.partNumber - b.partNumber); + let offset = 0; + return sorted.map((part) => { + const start = offset; + offset += part.size; + return { part, start }; + }); +} + +async function uploadParts( + filePath: string, + parts: readonly FeedbackUploadPart[], + totalBytes: number, + options: UploadArchiveOptions, +): Promise<CompletedUploadPart[]> { + const layout = layoutParts(parts); + const results: CompletedUploadPart[] = Array.from({ length: layout.length }); + const concurrency = Math.max(1, Math.min(options.concurrency ?? DEFAULT_CONCURRENCY, layout.length)); + let nextIndex = 0; + let uploadedBytes = 0; + + async function worker(): Promise<void> { + while (true) { + const index = nextIndex; + nextIndex += 1; + if (index >= layout.length) return; + const entry = layout[index]; + if (entry === undefined) return; + const completed = await uploadOnePartWithRetry(filePath, entry, options); + results[index] = completed; + uploadedBytes += entry.part.size; + options.onProgress?.(uploadedBytes, totalBytes); + } + } + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results; +} + +async function uploadOnePartWithRetry( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise<CompletedUploadPart> { + const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES); + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await uploadOnePart(filePath, layout, options); + } catch (error) { + lastError = error; + if (attempt === maxRetries || !isRetryable(error)) break; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + } + } + throw lastError; +} + +async function uploadOnePart( + filePath: string, + layout: PartLayout, + options: UploadArchiveOptions, +): Promise<CompletedUploadPart> { + const { part, start } = layout; + const timeoutMs = options.timeoutMs ?? DEFAULT_PART_TIMEOUT_MS; + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(); + }, timeoutMs); + const stream = createReadStream(filePath, { start, end: start + part.size - 1 }); + try { + const res = await fetch(part.url, { + method: part.method, + body: Readable.toWeb(stream), + headers: { 'Content-Length': String(part.size) }, + duplex: 'half', + signal: controller.signal, + } as RequestInit); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new UploadPartHttpError(part.partNumber, res.status, text); + } + const etag = res.headers.get('etag'); + if (etag === null || etag.length === 0) { + throw new Error(`Failed to upload part ${part.partNumber}: missing ETag in response.`); + } + return { partNumber: part.partNumber, etag }; + } catch (error) { + stream.destroy(); + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Failed to upload part ${part.partNumber}: upload timed out.`, { cause: error }); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +class UploadPartHttpError extends Error { + constructor( + readonly partNumber: number, + readonly status: number, + readonly responseBody: string, + ) { + super( + `Failed to upload part ${partNumber}: HTTP ${String(status)}${responseBody.length > 0 ? ` ${responseBody}` : ''}`, + ); + } +} + +function isRetryable(error: unknown): boolean { + if (error instanceof UploadPartHttpError) { + return error.status >= 500 || error.status === 408 || error.status === 429; + } + // Network errors and timeouts are retryable. + return true; +} + +function sleep(ms: number): Promise<void> { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/apps/pythinker-code/src/generated/dashboard-web-asset.d.ts b/apps/pythinker-code/src/generated/dashboard-web-asset.d.ts deleted file mode 100644 index c49133cc..00000000 --- a/apps/pythinker-code/src/generated/dashboard-web-asset.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const DASHBOARD_WEB_GZIP_B64: string; diff --git a/apps/pythinker-code/src/generated/dashboard-web-asset.ts b/apps/pythinker-code/src/generated/dashboard-web-asset.ts deleted file mode 100644 index 90b5aa45..00000000 --- a/apps/pythinker-code/src/generated/dashboard-web-asset.ts +++ /dev/null @@ -1,2 +0,0 @@ -// GENERATED by scripts/build-dashboard-asset.mjs — do not edit. -export const DASHBOARD_WEB_GZIP_B64 = "H4sIAAAAAAACE9y9e1/byLIo+v/+FEYn12Mt2o4NecooLMJjwgRCAmSSDMNhCbttC2TJkWQeAa/Pfqqq37JhZtZa+95z9/wmWP2urq7urqqurl5b6me98nbCa6NynLz5rzX8qSVROgw9nnpv/qtWWxvxqI8f8DnmZVTrjaK84GXofT7eab7yak/txDQa89C7ivn1JMtLr9bL0pKnkPk67pejsM+v4h5vUoDV4jQu4yhpFr0o4WGH1VS55iAuw152xfOF1feyJMuh1IiPudVEEg9HZa0f5ZcLS5WYvUllrUL/q33e7ndWTIkyLhP+ZnJbjuL0kudQXzE6z6K8v/ZUJIlsS81m7WPOm5MoTsuglvMiS654rYiueL9GTdUaWV4rbouSj2uTnA94ztMe92tR2q8B/qga+q8flVFTFDnngyzntUMe9cra6LafRyUvWK3MatFVFvdrUW2QADy1bIBt1K7zLB2aiiaAxrLkrdp7PilrZZzeYsk0K2vnSda7BCDTPs9btWZTdqLo5fGkfCNraAymaa+Ms7TW8Gt3utoyv7VCtdpVlNeuamENqoySozLLoyFvDXm5Cx1t/KTR1aIe/eR3K0XLWmjF1LCqMKz9RIP3U+3+XkXgOP7k5KzV1mtXlZigdh2n/ey6NY7K3mif9+Oo8VNDoLto2oQS1KgJ/ydf5OVFpSqsXoIxlxJIeOzOwMyZjoGGWupjO+EUhsHdKMs8Pp+WHBGiRvcnGEgLHbNaD+GoNc58B7//Sr0COrty+TXzGzJ27ak92msJUDcQRBJ6gCyYDSnvwXQdAeJCb1SWkyJ4+nQAk6RoDbNsmPBoEhetXjY2E+Uv1FCUURn3RPFenhVFlsfDOHWqkgD/MQRPe0Wxsj6IxnFyG/7M46IMrmHQ/v6s3e4+h38v2u26TP2Fl29zmKDF8n6WZvPZ+nExSaLbsLiOJp4EgHpUlLcJL0aclyL6qTNharhiht44608T7vTojZ5DDf8OcFKUtTjUA9rLOcxnOZwND3vt+S1ocA860Y0Hjbhej1vFdIJrYGF/N2RjgOkki/qe7+e8nOZpF5aLhmhngIuCbur7lOe3RzyBMcnyjSRp/IStnVDn3KpOf/L9XmPgd1N+Xduf4khl6cF5wXNYfhuD8M2daaOPbQx8gBSmN+IghMV4FCd97IDnm4xjAqYV9fu8/yHr88Ift8po+AEXYiizt/vhvVevj7HvGHYhqtd7jbE/81uZgKKhesXudGPBUpsV0/My5xw+Z35Xr11T6ItEfT+8m3UFomqDFqzTfJjH5W29DuDrUGil+GzQoqUj5/nHLIl7Iq8bFVbzYCmigQOiAezQtICdJud9ABo2uMJb77esYOjFaS+Z9rkXzJWM0iy9HWfT+TLZOC69oBJZAEabgvY81p9pLOCI3sE4DVp8omkFvsOldlchBzHVHXBYgyAbzjvW92e0YOA6Pe2Fd/yGqC+4m83YToTYfPq3v/1X7W+1v0O/eVrInQpjcvxoXhQ3zXwKwI15a5LDqBI0rQtYa6ncZja5zWmbbvT82j5uzh+TqATCGcM2t5v2WrQ9RoNBnMS497VkweNRXNSKbJr3OOzdfV6DoIShX5vitkYb4v7usYquDTKIBx4DE7CKvd3N7Q9H2zWomsvoWp7B5tiPc5omt2JbNQ0hdSEATxEfOyNDY79dNQi7OyOJ29pO1N0ZhR3CXBoe3Y7Ps6SF88EjxLTKPEqLGAtHSYuLFcDzWbwg6wA2VJFsE3WP4fDcYf3jMJ0mCa4X/aUwJL6gDXQ6Dj1vuY/EeMlv5xIo1mce/AXqg0l8R7MD28U6JzWKnEA5yoJ0fzI5DQfwx5/xBPDZDwd6MoU0Kdjdkye4DGSDIGX4EfQYFA7GDBKDgYZhHdIBYgYUAat6fzbTSGvtyM6GMRAY0MlNOJUfBX3NELgnFup/QdTL8k9G9/eNJ4B2Nu21JK2GODa+FUE1ZCGWY3mFpoHr/EOa/p9Gx18sZO5IOv6i6Tjh3S//ITpGNEcJJE4fJXLWW5BcAJvTK89gX+CQY7Co9jxDBOSQ3F+QjGsc7BmYPF6cXPIbbH2yCLgsvwYO9gyoGHJcLYJvWkxwcCD5ZkHymI8zSLpdkJREP24h6WhBEvyLr2AXguRjlRyXHGSALDdj9rmxrSfANuwYOLPu78U0rG0vwT5xfoGc2DomBI3t8Lhe3z45Pr2/3z7x/v53VaF3ylQZmPKqdm99m6aqT7PmOryLi30gv5L3A4uxEa0vdWaMp8BoTPlOBmT2eQIsKbfz6fRDDmxWjx+VD2U44uV84ox9Cg+oM62oKOJhyt7RkqUw8aOxzb6zr/4d0nqLFpdwm1FAjm/4XQRhIIvwnfieEph5+PX+/nr2A4uVGWKiFRc08Tez8SRLcUkC8Oz0QsIYahiheZo+88iv1604jVwYiCUaL78c5dl1bTvPcfTL6JIXsF7URGGcvcgwcxSX4ugcGFEU4wTcNZApQQhUKLgexSBBiPF4vIoWbCd2/1sVzDcwkSFCPdVRz3cRMDCjbOFAor9SrUURqmbPKg9Vm3H8GQf7Z9NQaDVqcn3790eblr/wm9US8rw/d7/QclHmU1w9w2/sU+OL3XGffQHi+DjNeYVAgJfCKn8NN/I8uoU89GtAzjn2DLO8D+/eiT1wQ/wci58j+pkxwKikdIPuUVQcXKcfoaM8L61aj7nCBLEl4VfssNyZ5zbkbdqQv9OGvGM25B17Q/46M3zjPhdULRcYbIs4fWhwWyDdN5k3uLUWWauJngRA7kTv8NVSkOGSZao4oyqwI9/DOy/0Ai9sewx+4GPFU9y798Rb3oZu0irSeHoSBqdPh0zT4FcNxPeTr6czsXht8fDp70+Xnw4tts3t3B9BTGwUBtYBSg8hQBYq+N4qsyPYn9JhY/WFhY1fsCfFdYz89HYLp9+0AEkkgl1iME1gs0p43wvUyt26ipIp71JyzrF1TJXrAnQ1KrK02+eDaJqUgaxVASwrD1FORThg1UZtS9rIOQy0H+jmQTTnaR9zMJlD4wzQYOox+YDvM4UN2EzCG36HNeEv1aH7xlSvsA7g0P4zKJrNZNgiKTE92A5LuSCtHg8V6rqNHkmgyGoN4hQqvL8XMedZlvAo9XwEX6zSNLtvebjUQWZbbbY+xrS7xBPLgelx2YtzFMRASqOAHBwRSKfjc2BHAlH4HKC/FD2TpBdoylETRVRZS6l8LZ4rWbtVmIKU7dYZalPZWeMWp+zZJLpFWdo3mJjNoA+3XDF6KS5+QLEMCu9g/711r+Ut0xRp+8EO+7UBhdYbXyEF8izJWQHhW65n4hZn3pP6U89f9uAPNJ5yahCKaCJJIj3hkggGPkhNZbCAQCPwAdDs4zf7ugx/cZ4pxmZbzkREPiWse16As1GE/AdgWYaeQudbk2kxwjZ81ulCT8WCHZVWl4OdZVhscIB/BXT4SiD6hJk/8bXtVsLTYTmC7+VlfyfcPvnETxmQTFQisnbYJ444XA7P4Bv73uOIbUEeUCnUgxybYrYgaDEE1Np2+Im3elGSYDZqdqkB7bRS2NJAlGn1YbPpQss7Yj64bQNMj7UuSFuSmMOr0HLgwCIH6ayBC5mhm66YYN9DueZBmuRbxI4FbEfOSckM4KGKGiKkApu0NbUGCTBBzVtufEdoTiSPIoqfwiDICJgAoxqMaVG785bldohBaLJ1kcVpw2M1HN6ZF3yHH79V2x3UbrNpbQzztkQeSSi4of1elqDqC5cD6CzBAWmsNgW0AJcU4T4NslNR8qgPnNFMTySzjFyqXVZMfZr5amXqip335BQQBOOlMbcNKAPat8kflwa1OYlRloMEIzfz2Y5pkNNWiK21zvSi2uyo3RFic17AbtD9Hn5vIG27CzpA6pRsw9xxK6J1WcV0mK4w/Gov6n+xnhW3HlzX7cxO3raVFzaBSlc7Gr0qU0vuf1215st42t8v9KIOKwCImkSUjihjxQc2v2pmgTg7COfZdnmoQGW3r5DXs2eKHBFkHucyNjyO3x67O5+eI+ONmspelPZ4gnw4hsa8KKIhDx7nP/REldmt3V7NRJXkB2ZuUuvB9szHFW1Jgod6bjxrEBB+V9rjmVomZFvA3/WgxgX4kCktPo4rqLBTGrCt9qLpcFRu3/T4hHKwbV/Ojxky2bDHtghEgHXGDnh4N44mwSWDhXA76o0CW7bCyYez0JIJv7eiySS5FSJFlA9JLQwsKUMS7qGk6oy0GCc9QauVLS8DvX4H7jsjxt0puqiIYR2/z/z7+5PTGcvS5LZKXEvEF7syHi2IrU25ELWwWI3fTIinEStXj8dXsDjVChjIRB0BSg2LWMFQhFNTZKYV24DQDak4CI8YhFQj4QGnoBZXfmBQK9ymGPootSjhgEIg4pjs3zDqiBQx+1mfhz0KS8VHeIWhs7PNvd3tD8dnux+Otw8/bOwdnW0dnH04OD77fLR9dnB49mXj8AN+Hx6dHb/b/na2ufGBUj/+fLixtR2+l5Uc7H/c3ds+PDv8/OF4d387vDs7Izno7EzIKb1FQ/O+9a4F6/k+H2ebQDqIc1LpwSoLoXBBiTkVBswuQU/UikVPpp6jeJhGSThfVAhumC0BfMlDnbBKv9bm4dDD8QjGWrZXG0+LsnbO9b4pR53VzqclbXCTqED9IIggyx5Sgdh+PjXuZloqw52IGCWc99+lmoEOZEhJ+L2qFIbswER9F1rh7/5SKXmQ77A7+YonRk2w+j47K3gysIOkXNQRqCyr17+jMBqqdu7vGzsnPX4afse/Am5kXxSiJW/VXOkqXqXj77TUdh1+1XxMZw33UcWbASNHExY3VwbMXLsblZAB/sKmestPovLUtAGh5ZXTrlUt7PJz4i2wnju+GE46pNuUKoUFVATkqaXsMTvrTXOotPwVeTMQt53wCkbAuAOXsUmLU5vBjLuKgUkRhL0p1ZRSDbDdUsnhNgRUqtVgHxoQoMEaz7YtiB+iQKIVpA4gFkCxOjqwSWRnIYX0XAoxBLIDHMCOOSigT0kdJiSIgwjtZAcJYOdUy1OLx/9WjD/w9QsJ4NYiABhzQQAQyRSnfssFjx6VyKJbBACxQAB2vVFJzAeKFpLF+EiTSCODJCo7CZhsX/TEmUMYccupbxZJSS7cGpxDmBRzS8idJBUx+JRbap6d7JryDBVMmOB0gQaoWFz8iry3IoENWvhR0/xoLbdMiYnBnWTDgmaHSSYL6YtkyoBz0QpqtSvKz2qVN0rr1INtBOSC4LvGl9BHfxdVQWt5eazPEsL5Tft965h9RXUvfAAdlPmtVHdtN2ite9866qIkKbklkGVBXtFi1o7Nw+xUeaqdOeEHMmkNygWMHFlmEO9+wfFnNohhF0hu777rur7SwlHocOO7iAhlgs8QctnfKYgayP6dwYZFmxWMMKB5tGBfkduanashSAliw4f3QWKoRK4NyjKvrGZfq4WsnDKDqmMTunse9S4XDrgBUmSiNF3y4aVTFRM5LIi3+Pl0SEtm6BwPqFQ6bu9XMiwAyMnpQLU9GAA1PFpYZFlQSkgAD/fHymX1abf/8Oju9s2Y7o4neCQD3N+7KO0nfzRk1eyVcdsF5izH0n+iw5W8Ts/3ImA8yj9RiZ3RqWH/oeXC5tycEgcgMozjoox7j5Yz2ZzSh7w/7SEr+yj6ZK4K1hYvuKbMwBrX6ryq5pazyeS/TXvbNyXPU2Eu90cDPJe/AuuiZbNah8kj6eyK5wWW8DqvWyutVx7EkRS9a50GH/ZMPbt4tL6LR+u5OVrfoaP13D1a/xBiOdarHK0/QXMRllViB71HD9zRUg8tgfL/aYfuHy00P7EsGD4imj8imo0iJwU+R+WNG2fsUmyJnIdnShd5JvSal36XB8izdNtrnHdFvgsect7svHnzpoPy9dnJBT9F9qq9NmiARHjp+xQVXrKzEw6/EAdVX3DBapF6ucatE6Fp40yDqwBAtZDYz89O2qe21dEZiTx2Pt+Sl4gBvAyxEKP+TLJJgzQVHLdy6CkmAfyyX7I/beqIrJNtA8jYu+4FX9vuKn5h5W+NC77c8YGB+QoNfD9lO+H35Q6yCmfAnxEG3gyASeDc93fWDni9jmFUOfvrDYESCGBeaB+YgHDHD2T8V4YVyujvRrvqVIP1LqymglilWDBYG1QGuYDpsgtUedO8NN+KvyREtdc5D85acR9yxH3kZVPDYqTZtWS5FDcEGwZOElRCLdLtmNRWStqwiqarH1pZupWW5pegPtYCyw5pl6TB0hZMRzYJxyLpj+uQGZuTGa0xV6hqvcE/tzBVjkJxhBquss/hUodd459P+Ocd/vmh9IIFL4/jMYfdyVELmmghg/2s8vcSHuWLStgJosw3q43d8RjNjku+5k2pdh1Bec3E/4KzQ5H1ZTht3PjdS8k/dmneXJKIRWyXVBr0MJOit0vJNwMka+EZpTGLRMJLXJjjnCw4MROLG1fs0rcIsCuatab3r3LKEv4QQrZ0jQae08aVL2HzAcFtkDhgqcrxDIqdcdgHunp4K12p139p/MosWJtnvhhGLN1h70OYo7CDPmfHHD679tmzJoB360vtYKlC1kAQx3yt5O4RNIFPI5/L876zsFquC02dydVnqU3SBA/uBOEA606d/7nx3ifgfCQrcVIE8/GYcpcBjRwh6ChE5HSPdH+XGkcVzL85q9f3CUt6UT7SY9s1GtcL9zjozmRSNH4EW2GcoXHqHr/iYgU9wPV6rk0kCax6vvdqHTh4uDXYBahvl/qMsVbOjoAKsatokHvlUwahMu5R/ykNFx+FCf9SHYxSp7cFXWw7dLHt0AW215nN1OKopCw9w2EJxSk+u5Qr2kxnucRDeT8gkpoJ8jrjFma/OV09c+SKb40NoKFuRfu9L5Tpm6MoTXmCk1mM3ZYwE3GT2W883OJk47bSlR+dVpYqZT2I4G6bv2GeopS1NISRlcCmm/EHAMfatn3ML2J/eB/+sK3Lz+YmB3B6l/7Mit0F8eCjpB6YbnaKWqJ0csdO3suudcIzO+ED7gKJTlu104QuOU6HYvCslM8gZrzFGyiQqMuu2DnEAcm8zAlr05k7JWZ2MbIo2smjMT90eHIo1n5zdn/fWXm+drbunjt4bqGatL6qwejEKFUBd4fK2PKa87TWJo4SqmE1LAbw1wZYskYXc2oj4EaJeYxSzFQbTArkLfEwVFru877nBwBaG+DYj8pRa5BkAESHrz4984PnTm+GvNwUGqGP9pRfsD0eO+VSR9o+00Yox9KCoCMsCFbEz2oglsFVaUqgbEwuw+OZXvKOw0ta9vTJptGA4KScOe3n/PuUF+VHvAllAwtrctvNOE2/xOVIk4CBGelbQn32ANT080z8PA9c2M/CVRv2Mxv2y0dhVxLHAtJjl8jTqeV7bkdxbXK4w1lxpZhaB8YW2mz1eRLdIstrZ5cmIcBBAve+DtwrMHUX3A+IHWcGD3LJh63SMv5YCSBq5bljEPIc4zrtl6svn3VerazaSc8oiT+rDDpEPueriic9QFiXYSs4C+/ifnC7vMzU3AsumbMPBWdMr+MAMHO3IqiXabYkaKKhKH9zwYHPtrgVjvzJDfST4T4Sqk3ijDadGzxl/rTeMJty8AlZD9xCQLwBNPnInlvVHQh2B6q7vr//DLzKQq4F5NczlwBG2TTpf4t50g/3uZ1ynUeThSuSmD/H3flTpQfnzwPHli5d4t2LQQ8gHPQENY8t3tESGvkY+sTHsGRnRjZ/QrJ55srm/Yr4TUatf3SHo5+N/6eJ36WFyV1p816OtWRadsuxtnlHbUbXEsCvxLDemEtpwlq7z6+e0o5SPPWWr7p0RlE92PDvbpZDbx2ii5PT0FvmKfb/8+GuPmVtmGOKzqmvr2PA9ti9nauuCzPSxxrrf67G21Nl2eLtx2k8iAHf8oQRAa/9LwB82evWrmLY+mre8s2yh9scYXcAk7EmuRk0OkbLGYxPs7Q5VpUBDmo8vYpzZHxg28TCVFAgRtBDvy/vC9RGPJlAcu06ylPYSouWZ6sZpKkskGwfmO1gynLbGtw+PY0bz1dWkN3Zglyb8G8P/o3h31f4dwT/9oPpjE2CNoMJ1t862McraPKIbfDITQUz7H1YSW7YrRj6o3B1bijqdYPm1VNjWGvHCvGvekQyIIvcI7k/kBLF85aPmDqcCq4YnvDBZsrz3XSQBTcsHk/EyQ4tsMGtYHfHsCf9mwfwpsMT7DDNiys8zMV7l8oizPMstvrGmKGo6XOz8MLbDfRqpicYAArD8K+aCfSYvjz5kYbKLMcEtZwxjw7SysJBWpGDhFYbN/f3N60UKOUYugqZO/W6E35dCXc6vkuUK69f+/ocUFAQccK3Pq68wPlNixEqdy3o1doybh2z27DXmtCuAdBAhGCjIQ5Y5SuF7CuLm8E8N5ThFv72WwNUumBL5j5uBVFyEK/MIMIue7PeuAlv7HuI7CacH+71h8c5EHj1gxsFc7+1SS36Ch66Zrj14cjp+yJwsOwWypWyIJ5AVnphqNEpeaM1WjetyDKOVvSBsSC7Txq3zOkt3rbRBc21UKvjJlb2FIRSXYJ6pllau5STIkt20ZBVXC/21rGvR9AnXRcOHOK2x52KTLRq/86CPzhiBr5j5rb6eeYHokm6uSwR/BUa/fNVMFjze9pcDedAFUCRQYrohghx7Pbpau+fGUHfXWU0M32jtAtYTAytMA0W37prapwnlKkyxl3s9n6127dWt/8qCfxlpJCsf6PYXAmPpnM8hv9zdP4Yjv7VKUDo2YNmbx+mi/9WBAm7AZ1ZXC638lKyzOqS5784EZl7h9tUU7nsbddTKSIrisfAIh3lvSNeWjgykS6WrMxO+fgHL6rFMW5BacoqC5Max5SjoFNEZJibmkhuf21q3vzx/Brj/Iqsbtg0iLtnhOYSar6uY1j14z80K2dKQSmgEbNL6id2QFA55Oi5xt6DMGeucmrZ75w8hPTFZbWigiK9Fd/IUgVedxtXzoUlA6kPNN45+WRytfy0WHgYQmXfZYVlMiNyN0QNc6e7qbCRTiy5Z2ss5J5Eyz19LSx2E5R/dNZU5FxSlHB2dri9sXl8trX96/HBAXBvP+8dvN3YO3t3cPD+7OyNN/X0BdTHs7YAqb3LrU3nVqTvI8/zJws2UmWcEwsvD0axF/v6cA06wEzvQhT5rPDsjwXgZi+Jefo/7u53atHDRykHp5oenkTd1MjBqHFgsTjcn4ZIPV3rnFcqxspHpWL+kFRc/mtScQJScTIvFScgFZd/XipO/lgq5rZUXP5/KBUPGvr6yFJjid/f84qEwisSCq9IKDNLpNVjxlkS0tjwVpSQoUnJyfaxi4sl+Qrxy1B/04EOD8tuP6PCDZRlomFRf9Z+/QoPCdEWMtH5fcZN2WuQajm0rGZmiW5ggGlbXU+EQK7hGyN8BJPI0VnV4JLlIex6fVo6SRrV6sIGD61eMGMR2Jgr6PusXHJv8iDBSjdb/Zlt2G0Jxi5Yq53/K8C6kmDhqEJh7oiivUbn1SvfGvsba+wNVCT1lmLLD7EipuBfVJsSbBFWLvQWfGZmJmcRUIg87izCRBEAVF6oWoXumU6Cw8KFIw8tJj8KC1mcRZr5T8IIPdeUcTrl4sAQjx4LYdQLpXPxJU7YsX4KdnNxso71J0aILoBIVbORE112c6iqiM/xJGu2CKu4h0rwALhIUX0SFoCCXMwWhZcRHiwPNTBDAcyQgLkboVZaFdM9GhJIIhEQARlU4jAcasBw6Eair0PV9cXV51jLQ9Xn2P6D1Vd7/9oXt0gTM3aIgWq+122FJZgykGH1MXJK6BowRw1dS9pDI/jrIAwY+r216RcqVXPtOfAd8LPyQv6+FL8v1Ihy2jpwKgoEccfaogxvieirk6+LBRQm7NknlIGuS4rjBfpE4xfl8593oXL9mAuVT4+7UHn3hy5UfjzuQuXnx12ofHvUhcqXP3Sh8uvjLlRy/kj6WUK+xdj7h/2slPxhRyvH/HFPK/v8gXrP6PrPWYGarpTjIGzwh72ynHGzU9d41S0Ln3PLghYCsFufbPBT2NQXO2ZxrDXWueWYZWsR0JJl1Z4mbX32b1xtYy6tK+Xnohb1JLJdN2yppb8lfeehSzdiS+h3QX0VbTFXB6fyynztk7zF7qmbaZ44sfyh4tUdNRn/TsWbq2ky5VedokhLxOe8mkDu6kTisU5Ut+i8mdsBdXtawV29oH+tARWzVdT7LdDoczDlSeN3me1nmQ2qVRd7HsjvL3v6QpAs/CVQa6K4DqJtBEOnUYZnoMiTORWXcsg8D/g1XBjxOvyOvn/SIC7Y9wIrzsNdk1p+r3pXhpX+kQJYLanrZYCUR9obH5pCi28JewkLPJRVN1AQBuHCwD43xcKN0pci391sbjX+xXW9ws7C+N89E2GX4fTfOq7AY/476QsjgO0fHXUKq8ExL0dZX3xHNMnkodQFR9vGA9ccbtusKPqmkG2Y+x3T228OaHT13nnBTw74KRM/Qh1/wJtNixn82uAMuL4DvryssunizFQkNAh434YWDLpfKD975vNWfxrAo1K0ICfM10YPg+wrWcni7w4ThUstrMip9FqYd3Q6AQ8FxbouUH3JWCPlFpOohyKev36Di0nQrpg20GJXKq+TDOUZp1gZQrkShZVb+GXKaYK7OnnF1dADYGzTC28clSOMXKm0yMP2bPa9sSO6aFspfkIrRUr5js4o4G/PTk4ibK8iFpiLRo1bxFtXzPUdPVSJBFzMri7OOXLQYaE5sYWAaYytpDabxfHakgYLR3MuEW/Y/Rq1nGuNIediH7os2Pue5cgpkbvMZaGvfZFyx+b/lP4mURwdcYC9S+CL4nFDOudtPP09bdT+1ojKmr/uP/W7UGNZr5cnnVNasd73YKqsqZIxGoAcDBr/ICetUfkPf92rNda0K803sIwtyO793YOMf5+ml2l2nQbtoO2ZQ8t//Nc/li8LWAPfC2OMrwW6gTFms4WgcWSUYQJ+LcxhKeYU1qNJSF1GfStehjvC1o9zoL/uA/ESZ/q2WxTebXHgC8bAgWhVhjBdy7LSOR4Xx4alQOrb8IGTc3+Gu7PkYIUHHOVgqvHW+J5iHl0q9thdwcvgwbpm2rIUNgf0t2EfScgo41pLADkX3XjLTk4VUewL+DfC/dl8Rg4Z2Vuh4aW63oobqVZhLCjvqVrdUbboD5GiLNh4G6KJkD5Dweoh2b0nKCNtS0xUq+uKAL379fqGrmRfEN0cI3QiE9iG+D2VdHcijCfhz+ls1o1aj4y/vfGG3iMZva4QyuW4D3lpORbb4kIfD/h4tDXm4fIJTGVRr5PLtUE8nOaoLa/XFxPUn6mO3ZFTm+BR8Ge+VBo8WmPDZ6Mwx6slIBHDQoGUPqrXh4KkzsNRC7AVl7BK/MNnhyjs6iCJiUUY4U3ytXNtPbB0fhKdtqSv3qLxKIx+14+Wl6mibrF2aOo4PCn+Qh0F1EEakDBUcNzf42qqaiQ9XaQTmx1W6DTgHjprYYQGhSGMEkIPmwICABU3mwI4k6MbNZsM4vF0x8orVDCoPoSW8cfvZ2gmLXOz9pvi/t7JTyJJiCsmIUx5ZPJgKa6h6bTH6NO4r3B4xnp9y8KPtWqTK66tcMtUaCcypxLYvLZmQtloOuhLzYaxGqdVnD20+iZyDjaSkK8vlHBgf/DXYZtLfNwp9G7wYeJwPKSjkCzNygtpx/pSma5KHOBmKbduwfS8sFK8PZJeZcqqkSVIjwGIx81QMdmYX0k2tR1psej5gR2v63ptt+LIQzJHW/JgGlDc6oSThKWOqqUzlyhFECvPgvJtmbbasaHQgpevWSm1mVrs7m906KB2xhLdkiVCmYNq6eWQxgCYSlRFchKJHAW0Vl/KpVp7JfvHfxE51IY8pXu16bBGC3JQ+8dypDzsLCNxR2KlFtZX74s/8tjIfivCRyyN2S9OsmsBzzYn4WIjVXbspNi212xcVsyU2YGT+QEDc/ZLL3zsbgDbcdIXWfOzPA4fvCbADh0orMsF7InbsnVfgW1goSQbsjMXFbzcigv8JHQQP8rSSEg6kzJ0r1z1FUcqd2Oo1NnNz1C9D8XMZbhS+HnVXm9shQgSXzVDI40YNwIqksawDMnUv5f8WF1ZN5/B9YTtT0QaduyH/N77sGIgvp7YiqQ3b96EbcbpviVUsNppNvYxw9Mfk/u2f98WFpNxuPL8Bcvg58VK59kzNojDZ53Xz1bbz0y9l4mlQ60/I48buIK0jdpTrV71Jte25zKxo4zOZXhFWZXL8DMRfqXCr6prWueFnPm6ilVZxwtdyQtZS2dF1wOfsuXnch193pE3AjrtFXkbYKX97JW8HtB+LbO96rxW+V6svpIZV1devpA5Xzx/viqzdlY77ZcaLF5fedF59Uo1SwiVLa88W3n1SjX+7NXzl2ppb79+2Xlu1bD6enWl86ItcSRGQoK1+urVi7aq5MXLly8howRu9fnzZ89WrWperLzuPHuu6nnxstOGwgZbMqz2iGcrHajNYE5FqI68erb6HGrTI6gi5NWB1RevXrZfd3TzOkLWr24V6PbblcXauTTcj3FDZFLGi2A5lgqRvSjlheJwNO21JYPaZjmq3mlX6vM+ZQamDkpDURkm7fx1lI9FiCYcbPiqvy/VQj+ke7LQzrD+z5zOkdrrRQjTIPID4AvDIRuZuJEfJPf3sO9DZthF5JkmJSV4aQCKYCNQ0dAUGvrBoiqixVUwcqa33g5KkYQ/BZ4H1nO8/ICZc4ho4olPWW+WLH8TQoV4QrW6ggesdaQkoDs6cfXXy6Aw+I4iwYBoZaKN7vo/G1WkAog2Tv16STCYCrcqHM3CSzmKqIPKRC6X8WKKWgHUvNdz+/+lCf0vT+By+Tlf/bcmb7NTmbOVOVqZkpUZuHjCQZ3uhGt2rAn3paeun4SDWN9MidfWyLVArNcSRWpmi/CZ5fRyp2hYbpZK1EomIBStdt4kwsxCOB6w2SlzVULSoEt892FJ6mDdW1ThVagRtzibHCmspzgQu2nk80QsLCxiBctFl0fV5cUNhgn7qw1CiC40qdlCVaD9Pbrmc+KQdTzk9FTZreRL7PRixJNEcNnCAxjPpQ/aYWhqJLsUdh7yyo3eAsRU4Crjfp+n0hSNZLgkHNX/mXTba0lXCV/AGQxLWGjY27CztrbVHZ5snUI/zvGnKUx6NkBa2yKpeEOdeGJlGCm4py0AbWttQ5nVbMF4U+374QYW3Dc2DPutBPpYD5uKaFf9WVIP//l2FonFbReYdRiits9yEVEIuuPySLi9gAruwxxWKuhZaWstqSbaSebpam5VC/9ZCge/Eh+l362OmyjnYB5k1nA+6t7MwPsEmYLXq9b6+LEniF0aP8w3ok+enXq7asQiM2IFjljULerlPYd26yXiBgEIS5BmoE+FNc95ZreL+4Q+SA9xi1ih3WG9EzwpsG6Imscz4HdJbEWWQckTmvmV5X6lovqGpZ6Hz+yIVcrywrmZ+H/hyv6vLebQM+Cf7K7pJRuSNIM1fwqgOSKzPhc2Ww8zh7OVNb7+Cv4Bw62ZFzEw0LRp6VWwYqopM73MX7YmWp0iXILgqbJ0+Mqv6DDHOF+DKn8eKYWDffTg0NOlvICirs62JiBMl9aVE4xJhJQzSoT0kkfQIiruba/4rQItGBsrPtvl6A6QTqh34nOeP/GWRwmbljqW3OuJ2EGqYzfVFSiRslvoFHKzJQu8m+hoVGGgBC9T9kyKcJMl49NMxx9yYSIpU4pIp+xH+aVs2bKXpBHs84SXvMZPdvkp04FpaQV2CyvwbmIF9iaWr5x+aglkWB1JZEYas0yrWqinSku0kelq25XkZJCe3t8nWFQY9oalZXFWalWRsERI9OqdOCm+WKeOaVN37WMSAZd+Gqgrs2lbvhmHTc6Fb7G12ijV5gdUJZo8APSP2fRIk57Oqvhd7Sw29VnVdgWLW55G/6rpkLFL6rrmS6ur9gQaO8OYZqeaMwJhQEaFd6MsFvqLI3pYMCCHEtGEmXjSwesEPFmx1may3uAnBWxUS0LmjzJySnHESxZnzqs4XxMxoycp/TL8XfY2owlAxT2IsWwbRRbi9+LshJ/i3ghMAF8rFRNADjUzfMivUUIGcez3dhIe8uH2zaTh/e+TYKP521nU/PH779N2e7PdpN+tF+LnlQjuiOCOCK7s7ODP6kuRefXllvjZwWBnh1JXoK6m+N2iH5F5pfOKUjfbIrizjcHVdruDwa2XVHbntUjd2dqk4NaOCO7sbJ3+/wvc339vttrN1wTN25fUbFtC8UI0u7ojmn3WPv3bEw+YCSQGNnVJ4rutU3pfiPOxaca4j152VEQhIjrB20kLOM0SyqxPiSogE6SKL1TwWhNby/noUnaCthq0cMi1BbWwY+CNzbOlpfQb5HpPSORpu3mVI5CPgkjVm3xQg2yevGBhtVLfK57hkG95BILVKmFv2suueb4JiQ21ObXZc/KXgyyrR6+pevU6BfDNpKaHHOeD7cxm3H2PtWSet5zYnpXGNm7+u1HidvsxwP8Q7t9KJWJJY9wHIU8WQh79ZyFPHoD8w1EDgQToIxv649LiY5UV16LXWXSTzvMs8w+3WN3QJmzOwy069pHDizxztiF82EqpakJhsk+HWCQg2aSK1xvjdDLFA3Xatjy6CHOe3Xhiv/LyqB9nnm1rMnFVb3906Mvtx68sE4BSvCBQPdVolOaAPEInSSaEbbjqdZ1S8AUvKBQhFWF4rgsZ1Ma5+DAZ+3RnnzrjSwpD1zpBViDXM7wF48+YY8EwQjtyIJkRy00mNiJThkfa5Sla3VGrUcsEoNAdgCBcf8/DkVDjlVQNASSW2QQPHvEgxS7OW2d0KE5pPBfCuWYiy1P0FGIEwqlkrZYqxRS5EemtC7pBIvYok9ettiLpBkDDNuwp1ct0E756+q+raNmpRN4okJmUEQwRn/DI6zPgsj0ttwBRRxJA3pIQrntAixzgHESwtuCKQC2goVZE9yiS9QadwYgaIWGp7QdLHYutiQ23iUyYIEJlSkZe+lRA3VzXJrd4mc0xjrXEIS7shpWndTwTPs/6t/LsR2eiOOKTvk3Cpye/p97vv5/az5AdlLYsqE+3v03MSxNalQtlvWXk5aN8E1aJjbLRtiStzgt/2at5M2v6l1NbQcZGbIgElZKJiIcqb+ea8Ghp0XzFWLk8O3FqyVwXS1g4WrBce5jikVU9HU6jD1C1uq7TEkZqIPGaGBqdIhoptBQK+z6ZBoR4jBocP9DpOm5hthHdLZ2e4zPJ9TqFcrzuSS3MgSlmgYEzQbyNmKwrmYtNIDZS2HukvkKJWbl+5YorT/KbgsLDpaUcMlrpkvTxPED5lVw4MIUZGB9PIpyxHC4sMlwwlkN3LCVxIB6H/qIhFYY/hsaybJ7G8DJNBaB8IUD5AoByCyBCCJFXrkeHxFd9PWgJW7KGOreHWpbwgeqnRk89S0I1pKKjZMCBRqFObAkjz4ZiZ5UERsa1gtTQWFQNpbCDLGdRGK2v4w0etdUt7HNk93lpKWJmyIdm5QtEyhyxRP+haUuYpcEeQU8QO5YeaKq4hjK0XZPhWtrKrlOeb8klE3kSsodxMEFzMyFkOfGCudTNDFOHt0TmJ6N3ggqUQ0v7ReoCBNFiLVGCaIGnDyf4EGRyUpyiJCJV4e1ussbtq6AFbDgVngWL8ZPkVG8l+F3whJ7fkedwTlxYwAQFaUDFyz4dqWTYcmb6olcSKppiwv6ACdi5DTt1F03B1MqXkERvNYkXsqi9YmF7ipJLrdIROeWhg9Q2FLBzl9ZlQ7t6e08fZJZwUi6Zy4lqItChjaR8a7lFlXSoZ5gz1sIqqUoAtuDhJCyYj5ZcmTl0UtqXA62VIFladF3x9YpPvOsvjcinbJ21SFnPVXKuQk6YwujEOgmjWaIRh2OKw5lAnMBHFXyGp9p4M4MuaKQlQ0EpkRKklCT1Ai+QF81Nu6tUmxFbhwmDOC9KesYJu4EqO1qPkkjGog5PWc6TDuxOhCXGLRHVgk8a9P88UYqjhhel8ZgOm3bp7hF80BlVLSrwYapDjKmdZ3mf57voeOJgWsIaa8ccoSBtR3yJ++UIIm52En6jfn/Os+kEAwd5H/XIItzLkulYNii+i9oASw1EkWv6+Ki8cmLgaARczyV9fuDDSMcfYPu1YR73N3Ie0cchlJe/22lffR5NolR/o/tCCmxS69anKiFCupAMUjl0kPWFkxeABI05k2g8oa93IjKbRL24vK0RauDvZBRB/wqQOdDj6Dn68Khdx/3suqj9IP+FtR9ZNsZ7/cmBLIqeQvsqgLKC+c6zS74VFSPxjqEJZ4MBjpCI2McbZUkMe6SMcMuLodrPfmw8QAWQ9FYOpPkUYwfhPd3rcfFQDeOCSo+L37BvIkQjKz71GIqgGEXxrYddBOXAj4ufzXDZARoiEXFI1f9sjfcXfn4Zlw/BKFJVRyH0XodEX3UGh3pF7KZFw3ZMIUOqTvlJfTdB3UkTJTsqIjSKPWmqDKy+b92zGWeupF+amw3Npkcn/d2kchMxsV66Ba5KvIIarZNqxYj4IAX6ATECQIRRiUxir4CRgG/IH6CAiL/z5WAZVw0taS5CtNO+v/95ghszrHTrD9SdiKobxDXICyEiKln2JjfWBjFZuH/Jtktz3bKy4L+QWwPd8EVVPNOMpX5bCv1qJP5SUmUiIl9zl9DSotRGND8CFRRFhNroEdRGiFrj86IgNx8+jC7s7Wyu2QJkIWSI8B52vU4UAdyo9IKj6shFHXOFc18WyVl5kp9a+1I61W4PTI+oQ82O0QFU7kpFaZoJF4XNm7HS7MHSnuVNedVYaQJh8WwOQOathptF3puLm+bxXBy9RlDORZOgIiLHcYEvKTaHye1kpJR0S1UbmqW2kNQvxa64H00aJyde1MPXKzdheUWZgslwsycjTtmJNyrHyQ4+84nupGVMOdn+Po2vIA6/m5wCmGQ5N4JECmUihKlYe1qKfUM2Bv0ZiTBlSOIheRJ5Cx3DPQZzqbjmuYqkrHl0HvfQmxDmoQDhihJVzqNRPMCWVLhZUARBGk00IPBtQ9FL4slHvPnG6LM5wW+VcDhNuErI8ZsScOh30eZlkiWReA1U0kPsxC7OvBPjYWaxuExzIFN1WXlNWGdXJKczHJKBOj2GLrPkOgYz9bMxrO8OllWUi2SeIsf9NupdDnP0zwMZRVTz3MRhRms3R0KBUFPyBTpZIo7SNOJo599EEDEJA00C2CRatVKyUy1Mh51oHCeULOYGhVQiMh8qqcBvO2GjfzEtSju5GYkonavM0Z+aziKDJvk2MdVTQCX9igcrqa78SgZV8hdFeZR6bUiP5vAHui4kvsVE10kwtZRz1HcwsX6g7UCi82YmtTkyyYtK/4rPYfUeKHulEmm2Y0Ub/auvONvxG7B01bwxaWK6m2Qx4WUO8uNm0yNFVOgx4SVQ+BGOLOUR4WYhIygLoggCilZU2CKXMdkybBOViu8ml+QpgvuxlTSO7SRidU0iOfymZDR3w+kgOBia1yqqOVFxdsbjUdy7THlR2DlLHYlZJ3iFgXhAyEOBJjHPMjHNCt7sUJL8pPiMVgVhE4KJItzkIgKzaKTukhgEmXQMLSmSAIGZnjhDQhGVIUEuXOEavy08Wwy6SrQnJR4pXXJkRqbDkYU2J97FnZNkI9At42KxIhuI3BDT7OsoN5sQGdx8Ms5kRE4UtgOTK5ERbhZ8pN3NQzEmkxFJTLaxiTMZbUxSrgoulfxiMlxTEJNR5t1IeyMaJQw0IxFSiVu8l+VqP6IMfROjMtmkQHlcSiAnLriz6m1dxzStvZ28ulVmio5zh1tH20Nt8rrDPE1j9HD2Nu7HlItCzXMMWsmHUTrkVnpOYZmhLD7CxBmL5LJoTnDaCEbhaiMBefWcw3IHyVfNyAQp+R3UIzBz1RzJb0rY7fNsmEeTkSwYW2HKgBZjHOUwscheNcd2BGUhN3biOUHMQcEmF2GRIS9h4f1GiXlJ6+6tTjHLLiU6q65J/1ZJF+WvYb0xyy2GnMX2Oo9xbSWHI0yFmuRfB5OB5U2LrzBWl5BKgeCGQpSouaobzVOd+uzrJHz6v0/QugPNOuCns1M7/dvFye/57+nv5enfIv11tSCu0F89/ZXrr1h/TfRXqb+Cp7ERJIexdSD1VVp/4EGgv+5dRFeROCsOhCiFfLIQp34SXvNAqKid430w3gfhycpf+3y4V4vwbZWC96Z4qauGnoyjKflT/Mn3AstA8pdSOYCPppVLXPHU8a2DZlz5kNP5G8gL+jROmD6iaj3Lc15MMjIW/lyo8zrpO+KBVFTw2Tq1dduqLRCPJd6kQr17m1YgHFrH+2TrRlZm1GBp7Mh8reFDg0HpLIcHSoqys4KgV2q3GNICAD1a0OleItSJ8GtrJOeD8ljBJGzqCDrhT+gwgLSc9CXjjVUBXVIx0jE6DUxsW0Q/CZ2wyJS0vk95fit011m+kSSNn6gHJ+Kg6aflA6Ku0l/+yTs9oSZlg6c/ITTtbmn0/qWyQo/C5KQ8VQY7vF6P0PnRmDSj+KFtCizk4mFRUdX5tn2/C3iMWCHxWLiImwsqPBZVPBYCj4XA44y84S0AX0DOKvDW672sAZK6fPtK2JTgTgP4jLyAFPSLR1pea1ZlhIbfC3AYRXatpaDjlqWlpDWGsvEkQZUJ2m8JobeYOi4trmyVSjHVhpCk1uliZtsxhWVSVIu0fTCVxCvWjZtUn1Pcqk88t02Lhs9uUjpruEnR+0qK80lNLZhJOBV93zfI5DYycapV7BPyyLXFN4abtuFV9RlKHBWHUqJFOROgJ6A6PUtLOSWzdDOBDVkqG2RIGVmqyK1sClKhm9GKq2bfz/AF4+w6nY9ZmHUfOOn5mIVZP0+q4YXZtpF59gJAxVKkT5Z88h4lr3BDSoNWiPNpWWaoQ+TGRkkGJEHKkCZpH004liJNvMZgfqmDLrd41SmZUegljr9gdz6vrHaAarXVnHEsKPwXlaF2Yyw2CMddsTSXV+Ygj6XJByjkdkF2ImxKMwhfAi19NTV6+PBtd7ExUS9C+aUo4iv0fFGxXsLK2miIJBuO+n2Sa5Rle8PDfdljUEkv0rnEYf0jGaV5CoEq3g0R1+ZZLvZZdhVXdrObTHgGvoq1Z8xYvIYIi3M+xfuJ6i1WWERDafoQp1Dz+liuQQF82Idj6JBTzmJhcI5WxjjAaFUahgX8kK1xV1zoSppiN4nCTjdaC0eYMWlGImsOH90IM2vwoHZh1MlZZy1a7zSVy3PbCWps2+Bd8ls0qJFVeMrABruBVzLIZaSIEtevAQIyR6dNvbOKZimhuGHRaas4trqyRmfjlHOdB9aFodtYG4Yt2dGZibZMmHIyETJGPQ1ta4E+eOKiJS4qkOeXhIkYwRXtpkUZFiKKttZIfKd01CJeMs9lMhUIRyIkHTwdi0hBEVJ/PET9MffnTAGHPp2U8pPhKdUBv2GynjRyP8jh21wSxPrjYktM+I85See8HzZyta/pOHkoPJ8Q5NKjg7I3gH1s/TYObjMmq0ewomEknKNnkwk0IBPV1YCjhuWtgd1NRN0SLNfNUVzMQWC8OlUR2qUT45Zb33o1omHORuyuwNImPVB5VIvdS+gjewh5t7EvTAitfgeVt8geBLVSbn0uxgZWuKZ4Oz2H3aAKrZ2GJgaPDQYCPEFP9YWD7BmLURQtaPUqg9uYLj8g+L8m4R119yMQHswmdk7tFPAl2iWLzDYr4zG6cRlPTLW2gZ1Ovr/Hd4jl88RsjsLaAMhxPi3oe8bO4xCm4a+Jz7IoPGoAnL8m7O4q5teQs8/LKE4gm89+nWC+DJbk3pRlUzaI2FEsCmQRuwNhiPP0K5QRX98QenLV+VV/YRygi3+Vv5SnzJP3/BbLoXpefEaJ/BhD++ILZiuIpOh5PCe/cUF/ysTmTAjDD0RYDtiCjonpvRhPThZzdQzY1Twby32P2FcjdaElYCa/AydjUKlvxnCfGlO351v3dKJYgHnL5G6gZckATWPwD9cyCroBos3PW2/0pgiWwHRzEKlPGA4d/83Ef/MDSIAybRiskOPA+Qa+b4/B960K3zeoCjft8wyp4Cj22fuJGPyjmN3htQJ6s2HAcyKW34hY3k989svE0Ig7OJhvMMV8v0C+nYkhPm2wgQs/DCoUmxRQLqYQfE77ejSwlifU2g7U8sWqBU9pzrMo72+hZ8oFnXUyqA67pST34UQiGnapxS/Q4kerRXKBiQAdEZY+QjIfhnfbRS/w4E80AXYINS/8PMoDr+axPT4oA28DWLxr/PTY54kMAifLDlGBIsOHQq+CTLKMIQ6abfEEnYqh0bTHvsSQeHDksX2eTgPlUhUDHtuYTIpK1FEvz/BZNvG7lwH7zvazHx/zOKXLWjjxvM9pTK9goct/b8ZK6M+rwMNTIXIx6bHXgXccnXusswLV4wvi8LkK/SUem3VeQP3iIK7zUrQPjUEAKtlIMBbKf4yAxD220g7wAlchIFl5aZC2ukLoWl3FvENk89nqM/Et0LD6HFvswwe09y7DI5TVlw5mV19ZmF197aL1WdtB6jOoDRgMjqcCz14Y/Hawjzsd/ABIdlbwA8DYWcUPKLPzDD+gwM5z/AAAdl7gBzS98xI/oNmdV4gqaG/nNX50sMI2flHVWPcK1t3Byp9B5R+mY4GPDkJlD9XKCiTjuxwwLAkMC6Az8MTK6TGJ6MCT6yvSBBCnJxdUGHwclMBTi65n3aNKh4aDnNtYtev+6oK8Ph+F/kqBZUyGeKVufWkJuWDHhL0/NdcI0qFQiw3NeoEvCNrzlg7qIVKzt1AxhvGiJf4qZz8u0eq7pjOzVcq1FcoA81IUHrLCxDgzydRK+g2EBTqt+JuSUYZcaD5s1dFHqkEYrLjpxFsvJXxYCh16OmChISBDfTUsaUnWE4zNn98Pcz4BOU2WJfZg0Q6pmPsHOIY5NBAOkCuQUP9huYe6rvuNlV2P4t7or4HwlxuBVTce4qobDX1WDM3eJM/KdpHxodMT+BW6adxLsM1pTtwVnhrQc38frcg4If4Ff5FbKa+Rq4NyolLUohI/BWvmOMpvafG/oMW/ADCmFi2X2bQ3Ip5OyCPHOtzDswXY3XTEgtFeSBULBhyazwkLU2i+NzRb00QKM39yR82okh5UMrBQ2edJGS3ka0SK2kNlPu96xHmyZSU1ecuKQ9KgrN8erPKbU+U3u8pvC6p0MixI1y3+RrwtfOwL6vFZn3o8gB6PLLSl/Fqgts2ypC8/IfeYco8g92QYnryGHQ/2K9imTtloGu6U9bpHfi/FuRctnAiNVN33hRu3LuVTepd9KZWrMPCBkE9rZexsQnswHIqGjmE/r7RQry/1I7adUYbG0mh6f98HnvLVGv7tdN6EfeDiP2Qh7qmbmaMdPc4WOUSSE07dAZwMtblUqaYgunSQ/oD0lNWehlQmdJCz8lpnEjNeGjIhm9s3WsEBdLjIpvri4VLVEdeS7RfoIKucnQjBhc37t0eUW+ye9lpOm8956qqKhwuR0TNDizYGqpcHqNOt9k1jgNZAfCVkRT6NgIhvwyj4RiW+Kw5AdDdKAo+2pA9Zvb6ZyUcJqi7JCH5LAzTU1tbnqW891lCF/P4eaKNel2NOe2DW8FFTlk9DpT8jnDBYkolmK6iYRCBGejYcVQSIB9ZacgFDf/xiecMvub6hwaHKgCaHIoMweST1FBDtmvhS9u0arRhJ275AsEpYtG2rLLM5aB8Y0O0MoRH7K3IVl5l81kKMy+JRoMcDgRsjkwm8vIn+jNQviuj47alAk2r3MI6PUdKGjzGwbiP8EHal+IXKVDyoxW863caPAnjtHmUseSJ+bkr6la1Mc4q+5vwSfi0G79C+pqse0xI+as23e0NX831GDQ4M3S0yODjHToUZrVaCG2LcsK443KTrt/i/8K4V+cFtGp5Ep8FNiuq7MCvQJDdLN2lD9HzW1g4aSAGHR7LnccPkYF5PfhClYhvo8AorFwqVACKVw5SgnPnCr8NIutEcRxWN8Dkxvn0gb2ZrVC8sjSo52kCC6+Fs9+c94JxlevKJKSc7o5lQcjWVGYU6VTye2qHJFJarVGDZ2hLonG8yFXl+ZOFilX3D68dXnt/9kbk34HWNaBlEPgQAw9CSXCIhu8xg32CejafhZCrMbMd0CgCgj6e4qyzcmu7vX68t3rMMhq5RFzxCRccoonW6N6L9CyFUbIoa163Mx2GSI2ZVspVpgcBmbRDjQk9fr8OwjSNfDdzJaRdIsYTaGGd01A4LXdY4H6KrEFPv0VD74RIPhMMuFKcgHyDUDODAGsIEvlpR+Uego/JcVZLR5XasxSKsoXmuRp0nQbysQR4sia3XBGiPVOQk+mhq3B5WqI8O5azctl+UD5XMzulWhW4rRTeHtiNG2lzIDmCJDOA7T0mKelrCys7FKTaKZYL2r0pFcvLwKC6cB4B0bLBpXSaeRBrWK/Huhrl+bb1jYyziRTecSwGlnVq6x6Dyqnei/AgAotEREoucCOEsQJ1607NozoaEzAMd56CDdX04Hqmz/SJMYLmjaay8kZSsACQtYZ/I8B1vtunKZvMHKZ8z5bmwS6u1dYfK5+6dqvm16Z3jBYuq6uIRlb4ToNwuVe9d0bExX3bOutTZGF/DoY/ehOpC/B2WhZVXmNcFZZPPoJVoxgMBt2kDKjsS74+htwAnQh2fzlzbCwjKF3AJfsehx17m0iQQ3ToRJnp/4fabkWT4stQJ0B+yGynqsBr0A68nfHQVWkMow3iNFkNjzK2ujSoDONgcGw8m4tXuzgu8ze9oQ97S2NIrRer+tXMl9YFobTQR82t5rvRIDqnS7Brnl3T3Va3VfresxeiEOu3RhGq9O97f2yXf/XJ/6Rrv5ImayfKZtLT8IvSlSp/RGuV8YN6EkGe0iTwJT3xkcZ2S4nEa4WmuCtls3hfncPqvMzLK04j2O2LUC/QQla1vEKyWEwU8lxMGZssJK5bN86UHE80bYS7Z6e1+TJpWSkd3EIJBOZai3cOyIQlxi/ZZdiQtTK7ksfdQMjs3rhXM98qlqqQl6IIeJUx0lYGzErxeTyDCIa3uDQiX0KZcaY/QBgaGTVwTCo9SZrY1YWYN3YCtHwYu8tej8I6MrYOo5eZiwIbbkdtpf4bmGo3IbR2tox6kdGUq56Ne8khVRb457oSpLNm8wUauA0x8HoiVS6WIIKNtXBbR3yJWF7BCMx9wX6/DxjWMGOEDhgK+kM29miKfK2AiPjcyfG5p8bkyh0Ki5HNx2Cp8bmnxuREeLKpT76PUt924vU/sHeBupq1ITirT5LTqTYolJ564r+ct89PQu5bfJSbsZz9E7Bg/IIrI+CINzVEOjuh7fAVBRUCv9Deq7mFcVDBWdxcfLqGvN9rlBDE9WEYQICBHv0yZT0UT5r1xyG8Ch9PUyW7qf6DAfAviAPexMpuUwymkcPVACcLWjN1O0QfaJ/KBRoqeT38gEsirgBbaXbURkKh0v3ORtuyBMwG2IIMercezEfJM0Hd6VAXElHewYoXsVx/V6we30xPjx5ECxGtdpFY0ly59KJIJ449EXBwkNULF9gON+Sj5U+ZUHJYnyalhscgNThYCJJ6NOHxytBIbW4R7WUkrJPkcDCneoVOIPqxGq/wb1YSeoqmvWSVFAPVrpu4BsnOQNaPzLC9r0fSG7PVq5xyQwo+z4TDhNVEV/nxMolv1eyxuZdRIvoC/WYFvveuzRfieQN5pWevn0ZD+4GVz8QsIEF83sUjf49EVp6+DK5kmbp/3YRRq/am8x0wCSY2PJyW+Ps7TXn47Kemrj3/F6yYZMFmkmpeGfzXa3uEvSIXANwIXj0d8+Etafvz4PKnh04z0h9MJr/zEE7O+CgqIoKPVBsbKeFF8oW2i+DqAdsUH9mos7BFrpDGDv/ikOj67Q3/wNZaJqpbwLUNUr/ymmuU31q0+sXb5jfXn2ZB6hq9qS5yR0xj8i9fxC87RhB1/5CMwSYJhcjNTkz6Ua6hDEr64a3RyIcGibxxK+hA4ucK74Kqpgg4oIZmIh3IR3NcR3SOokS7evujdPZ+KncwTRWlxM3P7nXxk8VfSZghXn19RbNJeOrdjxYXmHB8SJoNJR6K04qvHiY4NpK0kdo0jqawwKptz7yb267mMDY8o0mN3yppnyTHnQUWfeHQnWKSkNu8iqlT1Ro9hp9eleyyd5AcqBvgD6i6fCfd2ysgzLibIggsItQwt9DsGHUBDPahxAT5kSosDrbiosFMaHsRHsD6U2zd4pZg2MOOkCB3xZYn0Ko9+QtghueDfTsM2O5qiQKpVFGhOqEQVHm6nDFiaaYg50WK6qziZwxLNl/Hv8rJw8y6Nnx9IKB5KyGUCIMNOY8bNSaGfahc2nJHy194dSZXCekGSbFgEDfk1oh82kvHAsqhCYTGTruPfgzhLzv4tkXYzNspTAGc7RXg405+l+UzMZwRIvEcvcuS+vqDPkFtOk7nts0XlsfVSU9Os3OIsSNhx7Dh3+SVx5G7KqV/3w/layf8+M67uZeNqtCwgu5ZjmUjnsxwG4KlEbh7AyrVPZ/lMvPRQjw5scqvvTrVORp/l6DkZvSSvkKlrbl1UMcok9AUYF/F5DCvYbb0DTBM+hElW3zkAlGuA9BE31bm63shto330u2R5MCqMo3xs2HkMAV3woHJIWe6vYygEKgWBg5ZOdGNEKAqTe/2+BnRHnNlYTkOVx8Dn7bW36j37txHMun3pftG8Xf/c9y0dgUZzqdGMdsFWwlx3rb6aA7YPqeOx92xoCE3a6g7RFTt+ws6sbH7lI/XCGIXGTHyKNsW3biw0NsH0JZ+eJxFWUCSZbuL5JXRc1jNAy6V0OlH1DqyscqaSA3fsszCapZcG0l7MC1HGedRWRE1p+D5N+ZS7eURVpgW8XKcMmGEfLnPOd5JoKGse0GdbNQyMMbJ+VnFDxaJAIl/3oICm/NClhZtybo7jPmaNiKVVrt5MM9bFS/Qu7BJvxcWhcGgvn3u0ij8p3YsrZo7rNx0kYTeSEOFCMmD4fgXQAOOEHpA/nZHkdkjd6OLqwpchBnvGJRYyYEZbocQP0LDZHehqpWocEneM2nRtyR4VX2fm4rf+4nm703nZWWGJPVjcCkBKIiMTGRaELjNB2KUe7oatdEGBlWeWId0mR26HaBrbFE09sglcjQ+dft4RgEEpARUaZ8H7Q6QdnCGu5Nzl6gvixOSTPldYQvMNF5KB+FZzkVsBZpHub5l6gUdgN5Tofdb9ExRmI51ewBFopyfBCeE0r3h1jPmiyVvBsYq0ES2jHHSqFjSREtEELmg2pWgoJZFoWGWmReBWFooq/BVymutKhZwqvXKIST7NHcrrlng/zyEm/h8iJucFp4N40QNJbXHBjdt2H4ZHpWUMjx3DjnwF3To5sl4uHoWfr/BWItOPkPvrKy/EWR46xFFHZCMe9dU3esP11ldeBs9Fzea+q3xj5pgbuw5Y41Y7DBV5BSry7HXtmOvBzpm4+Vj7pEruJA054gAU3ZORBie1d8EofMWAi1lxnq354bSJ6w+2eb9SbfXHfKO/ukVXF4P763zB3O1o5/XikrnbUes5+8fEIV8/Pfvkicgn0fsNENBpOzdGaz9D3Gs36gtm67hx7zHumRtXcox8waSFgDqMGoUr0J1Qc0mrbc0Yrnv419PCHDrA8mV5fXKBCBkpfJTuhibvQiMfoPFiHXXsJHP7NeH3JcMXqOhed6KeAbOOeLW/U7vMC8Y1c76w2C+WgQiO4SuLoW+bIxR7n7Vh3ay2SrU8U8sWEK9609fEBCAAig1fc7MIlN3GXU+9lrObDrJAn8SJMJPb96aqkKCNxxOBZKFPhj3HiZjJKzg7QiH2BYZ5P5oYBnWjNI4rHyNLydvsZHjWYL0bh8/RyPslSdAoQ/kMOHD/9CZPAIRAj/D+1sPjdbZjaTn0gdfjZQj8zRTl52OUnw9jef6DbP0ZydX7JXxOpDnXtzLssJ/RCZvp5RfZy8305JgkyKuI6e/DGOvkWKH9VosW4s7Kk32Sk7+VTH//bH1PEmycSxHvW9nlkCyl8NUVFHoiv9npRvXwn43O2hrMjGQ57EhpXGQo/WV6TXO1vabX+qJZ/D/Pu3nYiOpYbAR12I8zwRoXvXkTjljRhD/Y67U1U9l9Ag3dR4iIfJkL/YfIk1tJ1ow4JhZYSTtGbiPUdXxG+OjA5LD43oOpOavHp83jrg+YBMQ2m8fpqcDwqR6tRfFdXXgCEhdgEZDabO6XpwK7sjBAujD+2+J467UuyUj9mSGEyspW3MfGyhb6Nhok2TUNLFHgR8nq/JC/Y7zVx4aS6H6UGDqc6pXzeee17eLxynq1V2V5BqtOZy3Kh3SgUegDMh1z0jnVs8uNXhcnuIGHh9cercTycaObqAFTusSb9wCO9fye83CFJTKYC+EVDkpZQpb40BMuf3jLHhZv9aBJP46SbOgFA97wpDoeZzUFUV2OIWur9uJBbrz6qTc3KMDH53hdAEuiIrpa8Aq9z8is0RTdSwTGY/SnyHYZDVV8ik6S00oNYl0RLUilZQW2sXolhBx4yW/y+uIWYg8BKcxxC5FdKIfnGhHGr5hBauupxgyNtiLp8SFyfVZE2mdFVPVZEclhEx4/0K+03WHpSqLSlp3F8lBRAWj0BwCJ3cdHR8uGV7Mu+Esu07n0r/2QW3HyFRe0jbXdGysP5BGw/BOysX132xcnJF+iPEVRKwyX2vf3Q6BN52564q83otYkm+Ds1a6wsXviwMcMC3NHCdWVWSquHqlymEMo7KmAybCd9ufz4KmTykZnTMYTN8TQIVL4C+55qEkLaLEo7+9xVcChs56j1NZPH7lRPX3kXcUXfiTdgeQJn8t3DjvqVcWA1iH7CaUacOwiE6W1VZpiR6GZj6od29xcX0eCFeij9VwI6trH2sMDgUtLIb4n1bWeZ2Pk+QHfskOTcKEJXHmJwgnG4btt8s65VD6ES+gEHE0J8zGQCX1L1xT+/f37XBqvVMU8H92YL6FCol7/wet1WmeZAEvc89f+6SviXsgNi9bnIyIx3hc81RKv+KZY7byEFRaW/k16N0+LVvSg3X9bC+I9O3xs4wdn+4l69RHN2L/k7EuuNyTuBz/QQzv+/cjXt1DDpJd4xwpN2LjPW909SRr+HZW2djfLxnmqn6scJvZrlZLI+1rmhS8e9MXzwq1oMkluIRF2I7VTOjLuDT3qN9S6C/jCC3TDRL1OTDvvGeyqDVH2i9xud8uqq4tEcWtfG2dTYKrPpGArHO1UI0KLR/hYCsbHzXA2VaIx+w41WlN0X/P+gncxTy3Oq/hxCthKj3qJt0hKV0mDL9k+qLQvxXMlixJlZQuKMPEugVjtzTpize8f1gGIYFQlRMSQFZbaXumggeUv3K6iKxBb8dEVT4c4Z0dSd9vFYwNb19HltIdbhxp34jHlHLIWWi9/Dnv8uXnE8By2eGhk2JIn8Wj7eHJ+ClKYPj9hQ+cwZGh6MjRnLAzGUB1jAA7QCvb+vjESkGtZOA+HNHdmer4XUvffeUVzHrsnK1EnY9VZ/awDs3pkoMvDkQVdbqDLXehGAiwBkmjfIBObVngeqfEputpVNOTsjpwXQBE8IPKRJeyjfWIB0CilpTn100M+Mr0rZKGRjpxBYYugDq0HQbjrdQR1jzluS4UD01JOPw2p/6+Lt3/p4Vw/x51KYV3nEC8FixymD2pM2obmnAn40NC8eilciGOXXFXhyCHiYSgOW7pXZcM9K5Es0ki+RoLulcyKTysYPsMTogcVi4YAnFuu9W6KjgxRPArvyN1k3BA+gPJYurRftwH8NSIIf40AxEKT88ws7bBQwPItjkVLMQ73oRgIa3uIFddiLLTVPHfHHM3A1ex1V1xrZxdrtjF/p6f1cCLO+9fZJbkKtgWu9gTGq7p+5zDYBq565YI773kNGl9wDloXX2LnGPiL2rbqdYKCQQnuPMsqI5Qlsdtf8X7tXU+pgpnT+yBh2GNxrohdsx5O4Q8QSPsVEAhkLasKaamCbleUzzPG1YCKuSdoFGrYLcU5fun64NofKkOUDbSnkjfwE57TU2yVuKoLG46KGnndvoiHaZSEd2SWhe8ldVjVSZYpLpYUdaNqNuuKUz8sGtquflqyNuRFyTPfdgT8sqnGPOfX8GdQD/sxDNPWNBXv9p4VIGn1pwnfjJIE3X+zayf5AzqqTz7mcYb+L9klD++Ugjb4hs4ICnxMUDB3sDigzCpDzqgviFqRcTCaPOrTGxSBfZft2rgQIGqR6CXztiET1zulqRueJsnylmeqSCl2RGKzyUxAeMT6MWxcD5nzcqFpSeCVcEZbsjS33kKvKxuokDuretHcsq5namd8chZAxMlpVxXeyxs+lkcr6XJaBJ5cXj0mVIJC98HKEbdcIkX4TI4mB6V63pouL6OGGbI2ygErB87Ly+VAuEJrNqFp6vN5pFf5M0vddZa2BCx4rpKgO3d0sCC8pgHwXdV9t+fajGDOzWEDiBHdHDZsq5fPQ3thOEHDh4cwIDy88ajIpL7XxUWhcVHg1DA2CogGa0CjBb1SMn5YPvZWF3QA36XwSX1rt0v3gHS1Ob+g56mwVgFuWLDHqlNu3YD3IrpKBuFZ66gL/8ysJiR96odjID+lYSgdo62qEZkgAPfxUkmOLBmYp9PlUk3j+jFR8oUh4c9G4PmYqM26Kvis8+AQFiZ8haW/CYuNJdnsy71Cy0VfGx9hYdBV+QFFlFTafq9joNtFELrW9TPrSFHc3gkuK0ILw8oCLjTm+6lRNL5A6513Rjn57OUziPgRG23lsxUfPRGGdy55+XczayWKBhUrCTH4TNwB1HQlDww1RZjeFQMlOkltBvqcS07xJTLjpEoa/ODTjSTcyDn9S8mEAgVfZ5MtS02kadrcRifKFpouDYh0fIx3cgSJsnxAG/eCwznVhHVq6gAiHkym3fjQtjfrtNuwABQjniRHwtKU1lmeV3brZ6/wBRtyPajnkJ761fkrH0/TIJmsSnoru8WiGV6oB8tm1tz9a7WZiV2oiY0OomZKO/wfHoqZSChTwMy+LbFy0kipi1n0Ym2cxsZdD/B2Z5PoFnWz0lUmbvmitqS6TCQLLD+TuaVjvQFgJAAGEKOYVGV1q5vKfQUSFjJlz57jMYCY0KW2IZPV2KoQ6r7i7vZTMYd+xHNUsyqveP+QVRxFtjXplnWR+yhSzR1Fy2GH/Ug1v9r4gcdpPoP5+CNlLtN6EamVi3wsTshYTHAMpMuT87SsWN59VgueGD59eg1tHq/rZWbluU8Ok+SVU21lpY+15J1Rnxm9mFheTsRwySu1p966HL8akOEIjdyL2p237F5lbeGrBQ2P1Tx/2Zt5AXduCvUGFdecm+xCjIDA4EG4aWxMugfavsaKDU8uTtmmYqLxwuOBWL4u7N0+0TUvua5xSQNyoQUkAQG7CC+UUN61HS+YZbixaUyGL9Q9h+6mrmgTEaD2qAs6cKUotukHKigskzZ9tgndrDR3Ya3YAnZl+Ro+kUBuavtCXVrQ46ZF0piVHZjSsswB4+sNxK6ReQ8UtBB/IGE7WLtAZEvkvnjZab969eIFNB4cAA0tSrCjO+1nr56/xFjrMUpEnLk8awFgZsZcvb7dpaHoEtvT9Vxos9kLVERAP16sNy7CD9PGAaAGDfsgM7tQKhVEXgDpRQMx48abZs5NMzjGu4AT66l5CIfhp/UtkUlOUn0QswdR6EEsuDBM7YVjiBGGu/rG+K69EO7qErv2/EXFAay+u/hq2IVSQcsuiNahI9GCDmFHD+KGAF6ApUoISwsLQdGjCDn8E3h/hp9G6+2YTkDygRvjZnZtJii3G4Ud3vwzY6oH4v4eV9gH+rOl+sN2H+nRS2x1J7Fahex/lZbeqlloeJuL0Dqku1gSj3dbafq5PytOHtZpbwlI4Z63fKFAqwDQdVvTJHZhtIyCf7io2jl91t6GkHYEvUHVSDsXC2jngGjnANekA9P+gTj7ug40uDB4D8KqDKJ0ZqB2qE9hbkZP0F749/dnHH4MCmBoTJ1MnFA8hIQ55kJVg63Ato3wkzrywp563+xcZ7FYeyHfZ/E5W7g/bFRXjwuzF6DDQeWWXTmpsKnhwKWGgwXUcDBPDWrhEOKJWCSBOBCESkOaEA6qhHDwMCGQpTysWutmXQy0byMzyCbf4aJ8ZoAPcICBCAyixBAfiCE+8Bf3y0xaISxaPXtwdEUTML7QnEDGwcLxFflohEXOz+Jz8Qjv28uHAWPPHsk9dyT3Fozk3vxIIjeABl6ICnG7HkkcBxPb6rpt6cHcqw7m3oODqRrYk4NF6D0IKKjaPKc2qUF3hP9M4cNKYTPsezjssHDb2BMDvycGfs9/EA1bqta5sd97cOxVOzD60KrA3t7C0Vc5YfwvmMj7WXwuHv/fzAxXfOCudIYgz2+fhBcs5uFFiK4gBVv4RO/uMV87UDqZGJ313z0RPNebGDgzyP8EyguTbQxo9pCc2/AQafUJOziJ+amk6Sutkr57YlipJ+FIPXEyAxbiSb1+xRewXMhRPkG2FzoCKz2A7WM35Ojuhlc8GGrbeghhIvzF2nH0Ysx6UHGblog60U3GF6wf69xFSJ9oQIn5nsPEkxBXWtU39sTioQC+JwvAe2JD9wSTnmi1jds+PSvzJIwakGGuYUD0PtSPWU3zI/vool4fWejTCU+EOMJh3OxZEfNARAjMjhZhduRgdkSYHRnTThyzeT36O8voFXoGwVkV0dbZuiFUXPoWisid5x3rJtYf0jEDYjug44mGb9H00hU6Ukk5YdPO81eI+10iiftKvvQu6ftd8ufo+13yOH2/SxaMwrvEHoV31Pd3iaZv2a+/StkP4wO+kcatLsK3S+d/YR7+CVp/FBRN9A/Bw91lY57ur6p0f2XR/b/Sk8V0//7KoXsIPkL3m9wh/McYIPhSLnU+4bfpC3QepWFXvtN68L/MTUnvVEqgRG/SF87h7IXinyikhE5G0qbMII7aX/p3pNNQKASGxZYGLVj3DDe8Ge5pywp9GP4fF0wfBYxEhT1isBbDJTQ1akqTCZaIsXQyMzNc6409Es+qIriW1oQkXmkNZLa9vyAWL4BXcwWo0FAskhzePxpaaxifgTj2kMAczgnMbmZXOg7nBeYHR8KVjx8YCFKu/6nR2KuK5W6N85iaFwOsyarkALXaGkYLl1ghG4jJQd9qLu4+8qxV53nbvGV1EO4KDSeU/ei0+oeShITyj0UJmfFhWeLfl/tQTUfyndExKaJ6sd5YPPALpoEYYMjw4bEhBDYIBs8PEkfk1euyXmfxaIC04WKFs0ZVYV/pzHfl+cATGssnUuH+xFa4PxEeqOky0cpr4AisianrG6qrRXuwgxiYh1w/oycf6EvSsDdo4Ks6MFT41fHZbeK4K9uT1y/sS4h3+Fq5cMldNS4lSw584FzcoBdHw0k0H4ePBfN+cCePeFRGYQ8iLuEri4dCWpv03LClSprKa6nV27UO1OQNs1E+3BP9PdcLaZ5jYqpdQoQ76bJ7eOKGH1XYbZ1qYg4vySAmAMCjIT3UQ6dGAjWqAhEyNjiWA9XEdSzn4OLB9wcpPlJgNm54fUUYsqnXLrWTDVmqUPyKssUJGvJLuNxg0vNGafvaQIcF5AOBkQ8KMdhoCGG7roiEf7WK04rtyHo1Eg9/nBE2/BgmqV4k9Wed18/az6TVnsKIvNyGF564Ak3eAb+3bv8l7GMPb6HaZyXfp+5NehuIOdNa2y4WEGjnRYZducaVF4SFubR4SjKZJzTNQfUzaUcoqETcSCZSSciwXZELeu2krwdppptrfylhHo4CNIIV3lKYcgY6u4btkDd0626R0hQpxQ5Isd3EnlDRIxOqqE6gXM2YaH7GRC39Pateh1aebTjd0HYwpxnrOaSG+HiUgJ5Vi0lfuZ9cp40fInGc+mmqrCDO0q68hWCJjtz2IBMZw1NRm7JntqelWG2V1XJ18NEAs9IrtPoUGNITEy2PLSNZN9U42DkPh+wwPKeOd8WPIEDtPScPDwPpKecQYs+p2JZD3VuGtLfCLYe0hxCuAgu50bPiULWwNTcUh8FQtVgtHp77xIK4tttvAQGasLojEMC3QuiWFM/DvCvnyUYoDKvrTeWYZZXthxtQl4hHtO2vN/q8voF8+kbQiOSXf7chHANtYAhfksXxg02y0nkCW0zGNs3DoTMPh384D/0uMMcI628hZx/DYXcjFNczN5Gy1cUa615NBw1Bfgs/6polC/Sb6xHqbfib4OY2OXvLNozlOCS4t61Xg9+kwwz5W2++eP589eV9Z+WVyNGuNrkRLmp03W0x+A3yiUFT7b0Vb3vY8MiLQAFOgvZshiOmUMU2HONYefT5jO3b4Ved1ys+DGphFggIaVdQOjI82TgN9sUJ+IYvL7Hsy6H7P9y9e3fTSNYv/P/7KYgXy0c6iLSdhJvcGq9coKE7adIBmtuwOHKsWAbFDpIcoBPPZ3/3pS67SnII9MycZ51Z00SWSqW67trX396+3tSJpgkH2D1jfT9K9pKDaJTshLFZGwdRfpls0/5ULvvRRDrMNveyfc4xEgcJ3jrgdw/EnvXpAnyrdecvFSHHOLw9K79jOyOxj5JRVDW3ZvMre1GZiBAM9UEFewNHN5yjuQlszxvYFjuCPp6cNAK8r5JWHmAmDNgw2hFDpLI6cY9mM+10pGraQU7gYkkol2zKEqsVnqjvhGYViE9ZszvL55mJ8Xk8hcue8MXLTzTbWRbRq+DxFCMjXgWfZ+jUV0JTLmsaYWIxbHtfowsfFS8LVf7zzPjf2XK/kH9tkTyeioifz7MQ/n085dZ9rU3rPvuRRx8KGVhrKfir4HUWvTaO/t0+tuErhUJ9rhPBTmmrr22cSbhdN/z7dfZtqEIybx9JevA+2f5B783TE5Y7NC7YMLh+LWH8sJBo//hrRSO+1i0jPyJ/+0/wXFWecc080p+gIh7+15m3IvZNvIEad4Haxb5tKiZk0zrfOyMplyyFIRYiQi8qzJw8KoMiBNEQ/4Q2w4WJ/9PfeUASjxNFso7pSNPiaTnOSs6RUH5Oy3HVkdPaWhI3zjWLGvf0Ipukx19vf8er9XyS1TkI+KzRCFRwRxdOJBWD0+wt6W4Ml6B+G5Ay9O5XEUqoOZrOFqTApolVdJZU07VRvbpzpuqxi9/ccN2m6kQ/WpqqTCN0yBKJKUpHJG1oxFUWwM2kypzwUP19pf7uTDE4d49yAs3o309TKP4c0SZeKAXC44l0utvJAu33ZrzWNvrS1ezVwtLgRlYGvYw5nt3mgymsI3nBcW+cRAGj2+Efm0ShJZzzz4ULHqQ120VSYsdhqtqAneomsFNtsNberz9OJEag+746nl+exFWphq1MiiBFGBj+uYcMXplMYAi4XWEYnZHPZ2nbTTcu8FPbqYomfmhV/g9ZmtBbHeUCnEmYOZjE1Jk/Z7rqRqRMj3xvdWf+zDB0a3XYEIU3Qev/pMzIYl4nJ1b2gO9nA+1DD7wxtI66rBoRcpN4XW3c+TmpGo3qczxahc6a3Kcm3Naa4NBLT8YpiYt4eHKSHSvupVwn8HQtAGNu6DIzv3AGyW19zQYhmnvavy9c4mw8OYHppOTloWJ09mZGDSbmb39i3NfhNYJdWyjWJwjf9t7ZJEsr/fWHT1P0/q4pjst5OQrMWhg+9BYg8YkhZ2IJ0sx6AW5sOXEgv1m//k9TJHO6RbTJhT/uGxPlW1+FK6cB2m5v9O5sao6sm/xLGOt/XSjP3p2pjZFzTyMvhhiH7RNNqdS7OPxmGJmgOFrwy5adwGsNlh31Ta1D265pbYbiojmWkVUwmJ/UZ/75yV5KfQeP5StDD1KfTsCjLH6Ff5nJzqDNghXLWPx/mDlRQ9glwVk5gfXNlvN5hSPBIjiP6Oo2xaotOrcqffcVEsmHuKWtn7+jgkibrgxDG29xD3kj48GM1OYhITL6I+0vYzHqD6Wi1I6+us3L75O+RRdSYPqeKViaabMz8cc0EMpSTVV4wpmmKPUyUZRYYQYo2uFrTJ+mlkV+brzSn5NX+gvhlf6CvdKzpDqB64hBomDuo8B2p26fvDBxFJTWsxipV52sYKidY8tBJng9FfgXLky1NUw6QtVKw81TnVAtazXXUNDp0nPz37wfGaBreeA8EulgWLmLW8PTPxdSWVtYkk6IGHZXpe6uukKZ6j0yFeID8yHODZ5S4sD10/QskLFiWqe9XhXT4wxjCSM6XzDVKCuf6sSv6u07WyaysbbArOPijFJPQxlGoquocFTJJd/WfJTJKKOQ8YO8Asl2WaZfOScWprfKKK8VJrRKDjILCUeFb92SeJ21CxAtItXssYbTHIuT6BcRqvGLSNtzEwOuH2ZO5PHNhW9y+NQwNvhwIshNpMQRHCFPU2bjo2y8OAbmvzAaUktJSmGAcFSByJA0MB2IXCr7A+unI63ZXta21gQR/lJXT4KVJ5K0aTtH6NOpkkmvtnewQS7JVc5O/nMEE72Hml2ljNxJjnxlJHZnB3rAD0gRuYPUYicG7lFdhkqTeURCUlnvK70lKiZ74WhN6HNGrjrSlqd0xRUmTGBqmNLUxUfrfBHlafUwnehExkfrzu8ok4/sD6nEjHa0mnQP2VALjwDd0ArVI+gCTce2W9bIY/TSTlvzZd//sz2BydPavEkySnaiPCnD2AztDm5tBYaxTaqu7QHOq/r6DHpVBGW0A4JD4n18KL8Ycynu8rbq8s43u3y0rq7+0/3e9vq9Lfq9Q/3eGej5VEz3kVmJeMW5+kylUFusqppEKCqWvl7QyDDRHtHR9zNr9goVASkGjX0Yie1Kyka7wUeRS2LUG0vPhmkR7lF7+dZnelKTtuGdgO5buPSRCLohe8VVZK9oJXsan7EwX4sQJVhrhMs2HZElfUXT0AOUUNOm8RylMRjxXC0WhG5hE8+Apw4xRapwQNPifQiBQ1i0bIjlWNaOtpAF7ISUYVRcMQdvgQKLUT0/cU+SFAkwDW+ZnFKXGZVFnJUea9JDGJIiQcUf7SyYliBU44GrDpj3y8sq9MG5GWqFtNkeDnOk+o6DyXxsVgajk/URHLQBE6CoQv0jJkSJKsp1NkvPqnxOKTcvL/PLy1eWTXvlrS5U0XUZoys1+CW9rfvR41nwADO+V3U5/6qQBpbRV//DhCHLItfRKuSNLQzyLFGJUHT7G/dCThL6Bb5IY700KBoWr+rEpogw4YOb97dQRBAdBDGYYQCAetQeqxeZ2HJgmdpYopripEnix6GLiePhG4afGsoiscHdErzHV6HjULG8hForWon5OE4w5WP3w4njUzA68WByC4lJ4Lwj3np2IjXq4jsEBMPRxCJBo2GdSD2Gw81JGI1WTMS2ipopqcdGKETsnHAz4Z6AlxFECOXlgQuT6yXKQQxYkhRhc8A5FV6MCzIIYWNJUa68gvB2X0BX+Ltebm/UMXxiH5pWFx5Ny6wHkEf34tq9rbxwllIr8vCkCb0s2lBExIA+NKjjqcPZpvAJGXA2sbpH2nZ/IqfbCGS+w9ou7K06X12s4RI6rMyElT6KxWkaTSubXY2ShV8TP8OmEUQ8GgftICcgMf2c90KOsBrv159rjQPNaFyuy8+jmiUNSj6i9HliAfm1B4c5Qcrod9SClhiwqh6yN0hkyxSKu5bOMb97eEJq3NDhRttRKw3uyinjZSv1sEJn4HC6oF4ludlJ8MmgQpSnEeFwWKZ3FOTRJIx2eY1MdHD9UXhxyGLJkV3ZFtAsJ+/WSgKc0Y1EPQgj/HypXIGxEaVugPpS2fol4fhhiMuPhvcXTZiF51xpGrp4CRrucqGfhvFz/XkBoXxidYYtYAyWej5FyqcTJCA1tX5S0tUK55iWRUFJfjOHBYmDwq4aXkMRLZDC0RgcNkRHIea5NUYWEQfFfM3Y1C1QEBq1wfSlVk1V3A52AkYpU0vU0zw+PVFpgO1muyaEjg0IPnElbhH9aO3dp5nJnkWAUKcN2x+0Ijac0EC9An/+QqgJ6y9fIQBnmfxFQq/KFgs13LdAdpWE0a8ZW2+vVtmxNPimlXX98kvCUkxrIJkJWecerXUuL/mqM1TOjFgpgXQ2K07V6+pV62h/XiBIUooa4pT1QW97iEZnsobgqdZIrVII4l8jONEPHj1HJy1HDyVN4SMthZX9q8NwZUgArDxAya3gEEaH2TKZlm5RJLtcE44A9aRiICXEkHebZ04Q2ZWlfj9Bjmhn4laP6ScK5Dhla/wkNNFbPOrW+oLHfn/Srtk5OGlqdg5OHK9Lpeg5OiF7R5b8Mg3gbMXrH0Q/CjV5TxOyqyiSmmuDZU7Oz8O/pnHOlBgOk4FqtsKCUUw5rVA1EBL8v25gDFrzyxV89ic52BWOM4uh0dsUhj0Tw/lpopPhKGIiNvtfJw0h8aG7weXYI5zfgIrVfrPhRdNnFBGpz83+ts1+6s7+41lLGosL9H3KIs5ZGxfRODur4jTCPOSwUoWKYCWTT5u3lctntsMq6SVvLwyCWtuPWabVuVFoG4x6xq659g1HI/75xFgEyE7jDoVwRZn6yLLEONtFARvLG0YYsv5l3VwjBSWEU4AzdObJnF2vGh+iGQRK7r9k/D5dwRBHfyDMy1bN/WoBkptvm0HrcBUOWxpfI6GAZsTBNXrJRSUWhDqwYODubz7o3b1zN7rvYfKAQExFoMu0pfznf6Cd4MJ+fEsZvZq2AeN+sFpkVCZjzC1MCljNbfL9gVhdtqSVHCXKnbc5PfWKWkvQdlhWJ2obPDk9QwSzUIIRoo7T+st7csSWiK6pKQZJoU4r6ECTNUG27LHLPcCwbkUb3pjut5TZ8srsNHzrvJyhQCIUUUFB1UaOSNxKy7IKzjQI45o9zpZLtktKakbVRsalChabA4Spb3tmuE+WYU4UzD2wwMeYTLkOSEPAjjDc0x15LpP/l2R16xKx4ewCdCQT2oc1md3kPqwNdW1dCLXcfkBW3/bfhcMUzj7MXOdupbfwsXfOOfr67zRAGH7lx/VoYxtU2q2mXJ+1yPUrTgs42aDVqQCGKn31iGmw0iX17m3e2+rf32DQYwRah31wt/9gs4cK/55v/8YgkUaeM2QjxrzVlXY5I+1y5kzoL00VwDk6fNThsIgbrokIQU+tJ1zgc86uY9WYWUjGla0NauU1+6JU0432E2DYih7IzfjxROodmOR/XD8bwH+JykB7/x/lsIzvK20lCsITLQhPoin2B/MyoLJSs02jpIIPH5FIjBlzrQI+mEQjEJZ9FmzUInqOmmzZBbv8v5gEIxjBwREHMexFD2vSvhErpm6m+iazbjvhhXrQxGo0ShArqSnVx84SawntWqVRMUjX3e7El9eVnJ5MpLwuEb9fTxwSMCvtAiLRkjGM7vgE+97dULOWr/CAUDp9mr0KCU1mGZjXE9k9tSz/JKcxkmskN+AoDVfub+PVCOTB88XIpPdFlrV7vPx7lXDZUnrPMO26WNrjjE2NbjOlZ07xX2hkIdvY2J31VYmXvaKOI9afYr5o7gaqu4LdFRV7FpOItwC9oeSTC7PCDU5eaRdNViPUufADuyY3++aa5X6ZeB7JzXTCFwYx0waabGzp5Cw8+diDQZaMiqDQssizggHYQ+mhkdfKtBA9TNUVCjVQKbrfIGI0TpVSChoY76XwnBV0c+JqiKgNhbKXpt+2cBeeYRRIqLCFei5iESmDh49IHCOThEjFHRVOD4uItAE3T/AYQp5ZpuQ+aWn0UdqS2lfcVGLCf6ZnA6Ppps5VoeWeSz9wUUdu8DlYGvek0t4P6UHdZsk0aSNCI93nXkmWaScI/KJMb5VrrsaTthJmajj/4BSfRLmFQGLxqop6wvDV7WK+ejgllY3FBjqTzVDNZSXnUtfXMqHRWq8F0n/qHiSpmq8NOV+EG942Zen3L0Zm3xvH1IPQZOGhbiFfsRFGV5qL/py2x4IIFOc0u7wUehu6I/y7FBu7N0vQrbNn8v1qpW2xMirYlelJvWyigoUnkdQ3+fG7Pxy+S94zMFEMYM/pBbI6WlRZ/HqKf3SIebyT0U9Vhn8p30L+AQJghsm+zrPH6WxcZOr2fvp1vqjdkrMKlgN0yrl7APRZXepjTf86UVe8Fvh6LxstJozMr2+cZMDpjuU9YWhSNXydHT/8QrNbPEPzqW7SWF08nle1fesZc2X86JHWQ6vf28e6iL7z9Kyenk6renos+sSOlWr48Br6U2ZVDreWg+103YwjZVFIdtjb4eXJdSbFxVtXixW1Ni1ily9SgRi2lFPKX1BTsnfSOqXye98URz+COAqLdLN3f5VQ2lgfrf1x6iHJoW0Rua9+FIoBs7jcIrxBScW1WuDM2HL890VHuaz9UeRPaSu1zcuqjz2M5mtpBQf5+ZZpFWxu4LV9vbcIOk8QAP5HWc6sheOsrFo+42wuWv/+0dPQo1NI6qdde7fU292OkGPDt1pZJdSSNdxX7S4tqZD1ZAn5BAwcP/0WM0YttFLCftDwfSq4uYIO1WWTDq1cc3q9lHS4qmUq6FUjCQsbU+wYfPTarXtECf3CqIUKZNFbPFW53U1K2L4syc9Ir01lZLu2jxEXRvPO1R44qFFoc79puBwphbDKO4yIa8Ljxshfyh0JxOS9E98hqTQOST/gVFRKp6JiqU6OxlSRQatG2+V0DKt0ejLNysMyO5l+GTjGzV/QRPcaUSGCtEsZhm3S4dDNGHwLbc2d951b9a3OEfxBB/JP01u3ot7PJHLdSjqP4a7zDrThFr6j3b0eTzDRiq6lfO+XvwVPbCISTwZcrjwd09I9HbdP/NOR74jTccX+vtKdRkvZP0ivGg9E3Ind8LxucKwbJsteRJ7qhiTUESkzl+45/2jROOebipCWvfmLSyCZHAi+YMWQwSq4mLLmfbDCJ6n4IY188Q2N/DKqyutwKH+crOQxsrKVx/h00na8Pz5pMAv7J+Zkf30iz9dfpvoc+XzSchZYtQDZaH+UiksD8S8oGDVCgq5F1oWheLWt5a1wXRth1pp01hlmMQWo6DXYJOjnJ4pA/XZy7c37vrF536/evO2D8ZAHw2W+Vu6ONyfLQVX6XPCLEwYDO/mfusZeLq63xl4u/l1r7KGF69E8Q/w31h037H/Muvurse7++u51Z2NMxQpEG5R0ygSi7TsJvLvmMn1y0rJMbUIZof1oaLIRNgqZGiTYhVYExIT3QsrAFtuPo2lyjL3SwZNxFkqgxdmMevUMOCKP91Xuyph4BzbTcf1kxrNVDawKDpi8EWrmB5VVPkYmpXZgsWIoi1NCUGqkLaodzV/NiqKHqbpCbSa36yg7K9Lj7G+2DVEb+tF/qo2wHI813pa72lY1kBW/wMWx4jelBm5E9ZpI+6sbVZtGpQTt1mhUYRpVhKFM33V4IkEBoryRyougPSMT0Ajs8KIAonl6Np/BMlXQNNJMvaJMkFL1sUjw0+3KbD/T6nBRwlzCQJhXh2tnKQWYX17iFcIWxBLSIBvLBLPslGgaW68f63peToviKDvOgEwT7oWbC+6KggHnONUVvvj92fajh++vXe+3yqvqud0cqF6W6y3LOqh1qcjLT5o6RIsCljsgD3SmwK6hFuViadwo0xt0E00GVAS9STGQsX6LmV5VvnKV9YwTqV+Qkyl5nRJBKQQod4W1ZSEm7jMyG9UIvzPM5tcS4VCPkcd86IIMFnTzeD6r4BRYz4hllM9nbS/9plNLGZ//bH0+ezE7TheTvCa+c1AEyrs2ujAzAKMJxzqN5fHHpbafwvKpsvr59DSD81mGI6i47qV0FEnHxqPX+NHhx3fFp2HVrvh0wZ+OqJ8788VsnJZfYwMaM6ztrtNwldzG6uo2Vk4bj5teBERGIoIrTDYjA1QI1J0RepX6u7BURXxDDfdSBrhOx07mvxHnu1X1C/11NfaNLQWZjzGKYy8rYS/wyfSonJ/y6FnXmappIMeoVI73sMasJnNUBSWZu9o6AxOoDEJLtpMUdswHuW+8zy112JuOd0lAc7Z58I2vaPJRrbmvPTV6e7jChF1wvgZvMZHtuzB+CgLSGGqAX8o0PklSXjqc67bRqCBdseI0PuFwEnc6S9dA7odlFFpxsblx7+59EeQtgjx8T4a0xZOBvMoE8L04ko5mNC5VhE4hRWJBoATcps4EKayim322ivY3NVSzgZ4aplUQxm0w+58yxeTAxSYufoPMcedeZPqKqHt3o0JxRRU5GH6eDgsnCCumDrWGWhUuRI6eSSCqsK9xFmEN7JV0OCPAzVpfYe9txEYidlryvd/H6PjacMEaeCEtPyJINyyZ2bFGY4DvlV+Va4BoJu56132U48Jscek/au+6fS1UX73ONmAM7jARCs0BYYpHOJH4CquyDC6MWCPW2ciAU9GghWqibTryO3dFcnIe2NpOMQZ/L3Dv2vynGxsh7J0UpUAg+NGXNNiuVegEenHqN2rxxmbLG2R3ZygW1WjpCuE2CJdiFRkPJlx622jFRw/zBGm4YMFogAgKF/8it7DFC3uD1xTTRptVsSfbRkGcWDd5r+9rgIAhXL0t38X7Kbtqln7NkQ9N1RvYJiK7MZ63btYVKztLqi50uLAuWxl2s3C6mVE30S/bbBXC3qzVuYEgUuIFTbMIpEyvAs2wrTpiJLW6vCx9Mld+k+Y/tXhwa0Cr8xQPHAsHV7TNc2HnuUrg9IR5hLOxipTHHHW6ovWPUTzKJ6OBBaxt4zjf81Lk8+3DOkB3OQGdW9cyTpNg2Axe13COLgmsu0Q/5QLpcsaFPHeJxVgeE9w41HSqNYck4UQynqlGhRDM5+QGPZgI5jN/O3mXpPCPMqrnicGBeUL5PtPEYJblID3A2EwSBGsSHkVrf2bDQOEywfOiUFewYzEPx6Tbfb6gWCRDTCM1KLSf1KCIvpZj70h0YYdKtQgHLqxH6S6PtYcLWA7AzDvstGCTC1pgaZlZGs4C6B1MV46pJMromFtSMiWlDKnTQG0BZTQgzDbcBFWos7GazKwaf09Pe6hhNniG176UREr0VJUuMqCGxFbNjMgcq467+Ay2f5DDEul2+asJf9esfj0LNpjVjH6G6UJLXO/farDgZhtz4kWFZs3Gg7hYtrQQHXJwj9ioSp6aFAZcj4gGue32N/u9exvaNZX9WBliw/SLDxRFwaNGx09K0XLboXmDJU5tppDyaiirgViTKGMaSiihAOzNi/fn02o6mhbT+mvcj96rXh8Qe6DYgvd0pFuYpui9z05gRnZaaRhMxhkCOisgIi9omWmSOiwtDOtlERcioJdyxSdmutW6RKS81PjHwVl9qVAiLtUQsdcJ4earLE0pnCr/KnWoUs+sIKpCT4TeTETZ2M9Fw8Fok4WnKLswDY97ETnRHWIqdeXzaCjQwRRjPWx33ZKhfZRzfG+MGLTRKboXOWvJ+Ngkpl2RbvWqwaTOcCWGL+L22GaEkfpwhFCsrSCPSFzc/nDTqaX4lrVb18qZqzC0U8S4pY5zha3SIski2pZZmwbS9j+zYC3Wp8U9HjfdwV8srJ63TAzUPvlqXCDtA9H4Y7b+XjFzpFGPznB+y6aHgFgzhVgzZWPBiAE+5cBfKSDx+cTgH3bRJ5wKSDiiTt2A1+TRNLjAXRqrY0HTlLg25IXcdTFhCcXrnpBT6ok+94n8KkqcSc/c8djXJ0huQaeUyJI306D2SGtomd6NaFTzYdwCNCro/Z+e96lbIZCIBtXhyHYt3fU3HkSZBPtWzgVNEma0F9Rw0sX5exBWNbePmOiPxE0AAf4LEx5nye85NPWvmvh9sd513G0XbRr8ukRt9BaNhRnG/CNlZgxOZ+bQvZiO49d1ND/PypNi/jn+pV4KkY78H22TuTa0PiFzqFO4FMmvc/KSb561RXSIbtt/KXIQxnpOXM+Kc3IX8s4++1n6jE6SxOO59M9n43xfegBipQBbtgNdhWJiUaSrHRG+TszCHFhE4lZ8bP+csSzJxv1I4Xeza6cjrt65cz+0dSMo7JGO5URXGFiKRTcTe5RWY/Qnwd2wp+qRg5WXJ9mcpaec2Qr8U66bWdSYGOJWkkeES5LDOyh50RXqGCtyBTEjoDEuy3WxhCIODc/d0HCa7VNy+J0UPNV/1cgV2VX6ch6QL10ttoaOJOw9uGtWwdJsIeDr9OAyEUo9ImR5nKXL/Zm5kXygoAe/Tl2MCyVuGLwnufPgmdxpusn9B1v3+3dDg9ypI+xcnZy4q+Ec3eWwcX8LfYok5jF/kADYG5+TkPuSE7xwxJwiEdDMyiEClSHfKeukq2Wdol3WycetiNDcqhbg54KRmlOuj4Gar2xlaVpZXquVZVsrT33uX7fP53z1if77LMpJdKEdgLgMA63LlerLXOCQJBnskRDxBFClhfbUsgV4y9hp+DX7W/l3qhuKMKvxA56idBpbwiPP4AfLvUxEGShxxmJJpOpsxKYjgTipQMKO9mk4vR4jB8i/kqv6PqR+xzBiq76D9bZoUfgkFlVL5ciiDFhmB6qxol6jFV9du6ujMZoZ4Ze3k4EMkTVtkKJ0mwXuYL7AoMWWjV+uX10QWNVcL4Poinfc8WgrEYTRt9voV7OyYMDHiWrZSiNiqWdDsZ+7Ke1m0qxglqVvTparF2tpo0sCN3v3kUCs9Uy6ALtVnfWuDBxedoJolKSzoIgmwHOo/cBpkY4Ss7qjPW+n57j9VXP35JLfcw7hOthTdpWdKxb4YE/vnp0fX43RxGNgMQ1StPeNRSrNxNdZq6vKw5KdANOCyaBQGTIOWKOU62SDDHLqwxzqhbDdXCNHDRIB9WPGoKPLy6+oyGoZMEsTdpAmHDUXWDBKvsJxesiQHCP44nZ0hJFHw2DvP7md/0dt4r+zz+B4/Rub1Nt5FNHjHn5HYcuhdCSOmRxdVf5eKxCSSHl3u8ehSjIZ5Q0SscckIret24t2fIFxW9CLI++cHAl6cSTpxZFE+AV6cRSi+vmKwzA40sRi8uPEIvzvkQU8MXZ4644kaRgp0hBttzAdq8gC0ZGDBh0R3zhA8gAMs5SSRWoNJ7GG8yQ0NGXSTlMmSFMOWmjKnqQpe0RTDqB7P9QIaMXRN+aGZ/M6s9IsuZIUNZdIgxYZByvsXHSNJl6HJDmVriBMbbWJbS3eusbKl69Sio5VtKRt8BCyyjMCcApBT/NAusbrt+/vf0QlG/kmeT1oI68HgrzCkZjs/T80JEzrjd41jZSQDw8a2r2ovLxMh4F7JhRJKnAcVxl6ZXNZp1sq42XgiKAC8WkYGLVYQ8lpxWh+qMynKIO7onar5Bhl2tyBGjYjv0tVx9m4Ablys5BN3bhzNxJmXaOFR/o7Lh1dIgdECnWipzDstSsKRZK9UqRHtprtTFpDTgKp5Tj1/c6cvCxWTdb9V4F5O9EB4zL5UDtDMBl/Q/tLeaSaiyRHnRDKZyGJaZLGtyb4WOvHgcjFp6xAEeFRo2bM0Scj72E+ucllRYnNzVaFczX8gLoKSol3PaXx2v/bSuObuKKGWnW8uRGv0CJrhzetMDQhp3DvROcCrYYBDSziEZKmcWINIFrDb7WPkyUr1R5RBjLYPCwD2yxxUWovJyYdXWr6O4mMtbIB+ZhjMsBIWigT2go5G8rcwuPSGBVSPOVojURnyMygVxRDDLWozEXOiUkykskJTQpitr6GQmM+VNX7ivOJ3qnopNamMVeDew29OXSBik6cydGTkopJIctekXVadcLs3wHTM3Gmx9iOnOkRk6ZnamLamkat5ql/3/TViZhAYx3ggYYlPuF5yFXiWaV2Rzwl8ZO2e5XVILuwTiNfH0+qepCDVJVat57+gxBbRp6fSadDOKgIcgHy1pc0UNGqsKDni/I40/mXdL5jtILamV6uNGPkq80YuerKUSaAoRD2bE4wImTb6DFO4qhpxhC38IinCAUyY+QcKTEvB49wvOB8Z2uG11h6OBS8w4ON5oo0NmxoQdP0Mfkbpg/ak3aJejaQsGEE0RSpfSeMTJMxFbhatjDXN+tgFK0iW47RBNbBYoRdfER5o0fOz+7dO71+/x6Q0CPj8Yt1HwFViYMrdtYqIti+tezKFxtqorvmq4YS0xLaY3FQUaJpxT5ElSE2o6RhZMfkD2yAx8U11Mb4EVvfR8u4CuMqQQ4EIdssfzKRXhKCV6nYi8XZzJPvI9p6AgXdLsysIkayntUCR76IrkP3nMPV1JD4DADqPsZZkZGPg80bH4i7CSVPtm7DYZyb5A3i2G41v8uYkrNylV9BS09q16NA+Q4YhwKBY+N6K2TJlzrYQCANlY/TxKU5ORcn5fU8D2iruqyi2LfCBeFbzgfnY5uFQ/lrGsiOFanRTLkwOlgEmUk36wzqeeka29js7R/zZl59pvViWu3oRL5xHbEMY4LVzU8oXFKQCLDXGJIOR0OdTtFXCP8c4BxWxC0+mpcfd1ErF5fLOMjXRfWwDPN1UyMPkLhhPoE8NyFMAVXI17H+pFAX+KGkwh/yU5i5wGY7+Rabn8rsxJSMDGtGLF/LFvIgWi4eCEGQK1aecr8PYd/k3f7lhsO4wMboYkJVTEudh9YjlJiE02x4nsLwrcGJ7UDXeY5uWcyJO3V+48xJN53ZdNNZK3/V7dJKw5VvOAjz0oOw7aGbajlzUy0zLEoz1TKy43WowN852XLWmmw585Mtixu2gszA2C0zP9GyWfkUPKicr5R7eMWu4Tbldazz7Wm/P2pJYQYxc+JH7KhRnm/r61hRlj9LeQdFIjLZBJX5gPQKpHNIEGuH6IbROdISYAnwsEQnUobOJ1d0kYib4lGuyLatOsgsWWszBpW7ZOC8+1aPrR93xa1a4lu6I+YqQWM9bqJsyZ3pqXO/0R+T8jvWvWZANPxHgzPoF5Qzc9xGQq2rr+cVqNQchZc5k84tq1/1Ei2HhOWqhMMImFPpB0de/qHjBowwrJKhbX1DpvyWnpfGq9aGAZitpnaU5wbSv7PJmWr89OcORWAmILOga8ofzp7E9uC3e1JsgEywE3aNttYpanJX88p5+eIc8hp+sFsTcRuu9eJm9uu1tcBLgC3hF38zXnstoJabcVrbIFZU4JFmM53OMEjpZB5GXxDT8mPmx6szswhiFfDVYt3e2LjH8WB34gJzGMhHW9/6lizc78X0ZXa79zW1HFLnvLDJwSjtXmYNf3cQktmTzab2GOg4Nn3uNQT91KsuFbK+YZgdsZ6/pEibWfu+QDc0qjXNvBotZGFdeIZm2bEH84CKOm1/ELPXeuNQZEhsf/eRMJleXrp7tL1cqJx8Ut15wycMZF85m4pvnxJQxZXPwVTMoahrLyt6yLyAZSSgeXzkDSSR8OL2asOwGod+z/d1oBFdr17dSzdwQrKNX8ct5JOIn7tSKeuG822KVzAObxzvId3UTMCUHmkOjDDbeAC/20IhmHZyYfTosvX1tu7fuXdXxUvcRCn6HNVnlBw3HBiqoAdN0of+3dhk3XH7wfEOGXD96ypSGe22oY6TsdgEjhn1IQKyDTE18Yx1Diq+BoT6UwsVSCwfGlXoIbQoOSm9hzbX95pwMsPzSiduJiRVGMeXeDbTR/ArC+8rJv2OZufwld/MK1vwSrnqFRVCWSdvsgAzQ2aRwTjr3YXinU5oYQJrXnO92A1BMfTN4bKLUAfYiSgEKljhyKUNX27ljEcaVPXuJs4c7thvEPhVMG33EditMes6tMwljlVS6lVgnAKUKVqJggMNfdukqknO280cNSmrr1SwRLf7F8L8v/0IQnRBzvfk74Kv6U9iirc9Q4pDinQx8fNpJB8iyiwjL6uvLl2PSpmtszXFp/V5vqiTs7GRUBoLKaUsohcVxmXa0NQtFPkpJrUiTdaKCvRZnSUrZw4zwaj8VmqzPoiRNRjNx189xhBvY9nf01Nys3/8/GAfEUHmn6GuvfkxYXzRizFI3NBSVtEBkZmWVb1LnNYqDR1wsIkNWUxFBEoCYsMgVCGXiQ4Hvb15iUo6yToZ4EAyqaGDfoWDa6iuGRkhEBI+NrNO7gbbuKv3jLZc2mC+InlP7I8J1fO2Hevqhw08tfgULaWF3oOZLxSnyUkVHNtDap2TBz3kFUg67LdPsneou3u7qN9hOgeK1UVt5s0sSF2v2bSh+hfN9o4XvwcN8HVxPt4z0b9pIIal26XTIpUrLXlqPuh9QPRS2ZloDVDWtYNCvQQU/mUJcg4vo1QuozCGm5WR7VdohnAV+dPXdE3aMkttoHhOAd4suhZgHOFflFI5TT6cE/ls6xua/tI1q8ATU2Jsan5/lJYaLZOo9aTgg8tLMq4B908DXel1U/rrJm8LKaRHCigbaaAekt9KAtoZppxS0Gr96UHuOLpvbjQ0pYIF037t+xMhVMKI/5m6ml6cJz0LUhfjDPrdVYMO+0YNepE8PA+KttEuWkfbtWBGFN8ZZzS2mRlbu7At9ITho7XAoU/PbxyBDS1XZmEVfK8CHIF45Wj0zYmtgrOvPt/NlmzdDo3a719Z3Nk9bqs2fvTFnmRAnLZJ6UxBnNhVsrrGB7HJN84vw5JTdiH/E7YSjiyo0N2PbMV8eLaEjjfab5ZAeZ3p6BsKcnyt4qYzX9yFt2lWgQmZ82WUb0omXsQ9dP1jJs+yiiIko0qq3I6Qxnym2+tojMnGFItJoQ1ldkLK3lu3olKKY6Icm2YSQrloibPQhp5UMVCVjnHQfBuFvBiNRaEDtB2G0CE4yMjRvPr+jE0J0lqcUuy735a0GfNZmaBBDUHiQ822s31VKBhRRBjRjKjPn1Yr+NPwOqebs0w3HsRKlHBeWPrarbtKNpMoXAUhXZuDUUiopRefE9QoN6JJSHvK0JlBdLVWqmZjo7p3795G/24UVN3NzTt3traUUFqxiCsp6elZkQGnYd6933+wYZTjv41hhlseqe7Oks/T6PFChchZxx7+/D2RnXxss7fRnkSHnKr+WmRVnmV1xwYrryPyFJr1t5Q5oFGr0Nz73V17kePhcv2G22QNGvisYZkg9z0jryNmkEqYw0mXNjaGL4+DUHj8WPtW9McMrVciBWlqhmHtNAu1kGBsO0qZr6zVMX9lWhi0D+bbRaIbAYLj5DLmtJtWd2+4FKOsiV19ptRdH8+LIj2rsnEnLvwWpL49oWhrQepbDwwyTo32D2pES5P42mlXnLpqV5EkM2tNACIcv2xGI2YCjNoZmtYDmszhzaHNPKztPNZ+UFwmlZrOysFJwPvtXgL06MS9V1lrUmXtCrwi8fN/69Pii6s+ZCDC5XuYYEQ6p6AJWwTBXxksrrVOT1XMoaNx0mexUphoxkpxTIoT0oyN4iq84x4mt3Z4xFUPLCSS48WaNuz8abJCA1633cZzyoG9wsQt0SEf4n9Q9ls9HsotBved8Sr2HzZKK+2oE/GqFyk9Qiwk4FnD4Yw8HWU5L7OqUI4IpSEqOFRCQMdDONoG9gJOOH8cQezWOYiNrNOaOtJyMNQyC6IRcJUjinfAg5d/f0XzV2V5b3hSDsu1hiuzrq+9lrpxCoRsSHGlaTVvsib1faV2dOVp2P6LKTubWLnYynvScCXQNTg2uqEm1t+1quFUnXeNgFqPL7h71yKB2HlZZskj0yZeC0/muBYCEu8rzS7b2rOIGmDU3v4c37Ed/p/SR1L1ed0MqZs2pVfuaWc07Sk1xYlLVEFKlc3vz4JOXtdn8U8/ff78ef3z5vq8nPy00ev1fqrOJx3MGCZtXtetoP/gwf2fDtI6p38O9kVFWlXnmeXxa3+zeZ1T+Fbn39BEbtBxOT2rW6oLOuPpeQfDJaYzEKxRxZh0fubi//j5nz+pqw5QBhRGTufnGWlQglKqU5xPZQWGfMGnDKLltCK2D61HnWGjAeqF6GJaxVh2iY5W7WXI4wzGewrM67A0l8lajxiGv1CDUdJFwr/9ebpuo6qrWoNpFUqlGSxJM5gO2H0lN+4rueOLkCtPlDuXl/ryblhi0gY4Hng8c7uXrJNKrrhN4D315cY9vHbt5Lnru5JHeZK3+K7kTd+VvNV3Jfd9V/I23xX8iPJdyX3fFf2EWqL1xU7YPoyYhjpEBDOC/9bbZ7So6/lMuYRMZ2eLWl3rtcW+FnCggjiZduI0WVtL19NFPX80P15UWvHM63F6OqESPXNbLwYMmllq2rZ0yefX0upJMgfF6Buq3EKS3rtIerMfJLJ6pa6ZZdoGUNZKccnIJ842JrDsFeNG/fgRoKn2sDm09l8tsFQyraZxGEhF1neWfzO1N2BfrgVswmDFZJLAWrKufuh/e3ZWZlX1WEdLvEzLGa/GNeBhJrl8nQ5ypUlEgV0jtMAxkWnd/XNYE9gzVM7rZjhn5apDkv0QioYGYyUHJp1l0oQGWEDuOsH5fGB6drr+fT1PzU8K9bIfgLSWhT6kDhr8VF95SMge07BLt+hSmBu16vkBjwrqbHUqom0XjmSV852finbdi39JyKt0jUSvtcxz7ODACgVmBYwOX7G3RCsoXguo0Kp57W/GNEHfO6+NrjadRjgmSU29XNXtJd31UK1aDy1uGInx666a66FZEa6H6t+5HiqzHqp/23qoqFpeD9X114OeZr7VWBnDwKbOZNhlPS9ecFhbq6NC2dOkCyUwHE0dQ7oqa/K3S1hvfWnZuUZx0vgSpA63atV3v/k1dP39xjdQS0qe2FIMZvIGhxmb9JjjEIq2MLrJcHVCPysFTt+6Q+K0tT69LoMrTD2NbW0sHCCcK+tls9AD3PufgtcZ24rcbZW6WMySctA2bImbJSdq5QYVaWxDVDtW4QEmica0f/r0JuTnngyOb/o8X+XxXCbkqGozEZM3pOOgpr8ZYUisg2XugE1lPDGk83ACXnrosBYZp2Gh3XszZ8RoqctTw+R5dqEzeEiOSy9riu5xXNRtQK30YWbXczMup3UQ/qOunIh16B1FjZou6n2tTcjG+WCNdds4WmUYOcdyszpsxXUGij/bQ3FDKC1xexl3fAt72O2ulXb/wi8Lti7FeqSfG/8bO3s7bXH/pxHAQTEa5e8bkEEqgw6GJAYxZ+/7SpekS0jJaa/FTTEpY1My4lIYaWCcJ8XcDakevBGJLiWZHjbrcdvW4wTHIsrcEJ1Chh/QWquGBS6yGP69aqFlRmkkiM2GUjRuGH0hHxy/LIIWkmCOihWAweq2ryQk4hc3b0XpsAGL2+IvCLe0ssvVLDcqDGMuWLhGMJHTndZvIXIKYGFPJXpdfqfl6CiSbx1PSnxY5cRQf/sbjVlpfgMBAptaWvuVT8GhI4VZs2xjMFZ1abV+uFipH27oVo1ZWogZPXnvOpbCXydsO1qtebeeHKpdUcZZHEykJ5zat/HOvUsKM+a0z54iXSq5bU2Bqkov3sws2+tUvqGMAlZY1F4W0zbpq14tYWmkWz8pSSNJLWbGRH53+XcGhYUG/maLfObA3bSx+v+1dhpPBmZy7BOXy/oG3+Q4Y6+ml/4G+6Em272oFtzK/WJUdmK/2G3xbPyNbbEZixXtqH0bi5JWoxvy4L8EC3QVTaMR8mIRmvcexDRFXqyEnQSnhXYWVg+/88KW6qwYoL9SP6WXe2ikIgWB8N83oPipgyiMIgoGfmM8GGYpIch3HO0uxVJl4UWqYDsHOq8DK2QI8XI6q+pBmiBITL6uMjcnKeXlwEpFVg5UrXKCLpjHZ5yoTWkSJ7J3vxfNrGFu/yoj+g3Ttv5VXtKDivsHrdT9S53+sUkipb5Ek8T0g+IiRSZ420GNPasCXkfAax8lE8oIfxToPGR71M0qGkV7mKw3VeOshoT8qc2Q7PlDsieH5MNYGsbFUJC52+ltIbMRUoPGhOolErjBhyx+MCX3sl96aIIoCg2yhBFPbY6uaDBl9CU/NBe/WnjIW7NTRu7zmkHQ18IDIG0mq9MYxn7Oq0yqLBsbX8ch27GQW74Xtz2zymPxbKmRk7zcjei9hblYF2eYVRSTOinWFla/TUSnOlnJTn6sJUgzw8qniazR6a9QFzuBGzhGaRC2fetkOkuL4uuF00qtKnEjJ4386pW0GBht/aePFwEXbGkAaxjNiLhg/L87q5mWVtGw+6aNdWyMCvUPmhIKa0ZAce8EL4KwxaIA67o8BtIC/yZ0zXeeZTVpTuCyyupE3wv96TZby5n1UdmWDNF2cYJe0Ho4CN6LzE/1tSrfdTIMWouUvty0lxt3xfW9bvegUPs7tPeFz9ozQrZiE9hgwJqK64ZFY6vUvVDGlMrQaNKTXCc8eqDds+6oNBlwedde9u+70eTNrin9o++XIXodaosaRm+vjBlHc9uaUfhsmJ5JmiFId+k63dDHOMiGZigle6GXQxaESxNJAs8fwDFOoSBFS8xI0RYzUoR4mGWlAqQLOAkzrtm/Vy2y6cKwqdRIjNF9NJ/Xu1q1p1MCYxqT+ey4mB5/lElM1K3k11qgAqUqn1ramD4lzIkRUvrszEKKGGAnXFFm1N0VZJRgrc/tpL2c/sikFS1jXnjj9QOdXd1L08wVvWx9bnv53KHD0nLoZ4lCimXdBTMd9waLoa7LKdDhrBpU60U2m9Q5ejCyM8G2fkhWu+pt7104KOpAe4WTGQPaR2SuUGSu9MlcqXKPpgS0+hvFUj4s8d+nY23wfwkEHAixc0CrezH8F71k84eF63s0MU6bNLZOENerMvmC8QY7BOc2wfBIKqmOFc6NXtYdSurLZ/hFhXdiGESnSAQTL28+nI2XvACy+KJIaJqdXRZivjPFhfw5zT5fXn6ezsbzzwpVhcAbn+nasKz8HZCNKcWjrUxnk2yXQZwx0VSBVojZcT4vDYZ4ZW49PTlBcC3UgNORSEVS/YufKpZOE4+oNJc8bxeFcDm9kalssT1gpm/3oxH+cwS/9uC/HZjybeXz6hwqBIs72FkjQ3LFOokd85k1OsMCYM5vAeOJpUo2N7eVGkGpFEpJYreJwEC3kh1rc1brNYyCA7gtPF7M/tlOdqC9BwPdTBjeHZIYuJuUQhohPIGBunXrKGHLCjQxjPBuiXf3EtYojhL9IYmxpXczs587yTa8uKP86ukcga+DKIWH0+3+5eWI/qqES7zkJrTGRktt0yVOq8AhVAV6VKDnFKAO/VkmFzTD2RidX2LY0HolHeHyiYtl9IUgJGH71IOXlqxQhsmXAqMTjXWOfrPfU2YQiwsTioQlUGFmPWVl1QYQAetHh0XBqxqVhFQF9FCbE2TKtZzSlLqyomNxz85hYCsXMZJJaZH0BsXPek0Milu3wirJ3hbvyP/2ZH16elYoOfIJXDqyft+4x8q7qmGE4cpts+aeTAuQILljZIfH+5bijjZoFR43jNvlDUF6K2oMRwvs2RVYtMEbTNORrr9302m0Ig2rLR0cEim20BvRIZBiKUT5PWw6oXiUtUhsgCwhzzwIfy3loYj3+taHXnMlit9//HB7TzH1xKPw5c7TvdedmOvxYmzXkfcn91yQQTodp/XKl9kXHNXPLTWl92K3Sr/HvoPOZqjSwxtPfWG2sjtAjyhtBAanwU2meWJ7RB950E8qZHfQkknW88qG1VgVhGMQpV1gNP5KgzuRq1qiJSoKsbGtcNgsreJE+auJU2+kz9xfK7my4qmjxixTzycpHNQNr+Tmt9QarjDe/zpL+KpWLdPuXRiOD2OKAe7eQVvc51QUcnWD7hDhmzREDoVxIEVrZety3fjMfLFQ0eZrhcw5P21VWfTbCiyVfocyzl/RZxcNprY2T1wZz3EoXP2pXTte2d+vN2z9DV2Bp2h1luTBuFGkv+mt2nFj6Fcp7BUIj0F6dQxB6fn6CDipwIS5net81Z5ylnj8olUdDPxGEaF3NCzZVVYvU/Q30lIWSmP6GzBU0Igo+A1ThqNRebigbmJOZufkvH9PYYeEdiAQnwpeLJeuBsvzUubSgnZsj1ujaQZOPJQ1ZtDQwDsYyZsJ6K0oE7CJ6oaLvKjVDSqISpAP86XDStVr3fXUu4okmi8JoCt1zwWE925KZEI3T61uqv9BP1cX8aov1OPj2skKPTWqaOYRioY3xXtNoB30BJvoU2LSnNUGhv0MReDd+enptH40HWWlUo82VGwrygWz1Gh0L5YthwFs5N+yy8uPiKxZh5HpR+Qt7WHhm0Kx+tu3Y3EK0AaSp0IhOFTHb7wIfeAp0Qglw7zAjXFcD0DeLYy8+8I9do45AtK2+pdUZj4PoxcY/w+lqoFzosuvWR9d881Iz7Kt2FQEV0LfelzT6EO7XH3JC8byiF+0gXe8aEXveJGF7hiJfjgyb0G535hNx4+/yL7rPUlE78cvBPU7rtGXA8bgg+apTH9Ud64PRRI50/DrDGXjGOp94T7xQcbkDBQr2cNjwq1omRxZm8/mbBl25/ci2GDNKa4D+LUVuYvfPU6xUKA3SOqyPTqOoM2E4WbieDimCUjDFd/ZACar9f4GNCCBtfkbKifbDxwxFFA29bhb81CQ/AMZk9sG5a8EpDbsxKBhxxGcDYos8nQlJo2mX63FwrdfFY5Z6a//TMNWn/p4tF+rjTZceILHZYttaVMvtc1YYro5R53BtxbIKIlz+GSfbzwdAz/low54SkT4JVI2R1594pGo0YBNOsLI1uYdPppD2csnToJQ6jOixc3Lh1BpoIsFHAO2VqznaQU/0BqYjsdwpXRHU8lPYfj9IF2v82wGPDraJWQC99rNSGp4CcfGZZWLvUH6c6HF8BTEcCVCFG/Td5jwHTNboY1WReUMJg3dwcRlr+EzcNZM+KwJgawmE/+oMWor5zS5qqSWDhSBc4u2kTXziUmirRwoKL5YGeqBTiTAPZRRjmj/Dn8SlQ78qcCOKCU7FdrQYR2Y56tpNu/fv0tjb6CbRCD657EGd7f5wXEu9mtPm/p57NozbdNQV+wIrNkVAqul5POav3xSU95UFAQCoOebkdULhxEItJtYCB7ckQ88Mu/XRZJLgMS/MAYgOgWMIKOljVSTlzZhT4UfHyvo+kpmLibtfQ4s0nid3Uh3TalVDxITPJ/GFHMMFAv3Xei7t/Bm2CfV4491bUt7RQgfEYe6WjeRBt0thF90mkiExWaG2Sy+SFebdzGU3WEwLi+rgYm1TJXGp57WRYbhhhUqtlSMXrXz9Xk6QW4lUAXCt713UbBWXl6Wb6v0Hf55kuGf9RmUqs7S4+zF0RNy610doonFgdoZo0XQmdbZKfpAdMKQfOsrL1YQo9rW8ywdu3afEu5+WmTlV9bMw+7qYKEb/7ihWovu9hgXR2aQUplBbmbo44wuKo49Gnbdx07M6vTPecC/o05eZicYB4rDEqS3gDvEO5eXiG+IU5dbnfoESOrk51yT1AmQVHJBz99OgKBiBaLLVC96xaga9TLSv5KOyg3FN0LMiebWUGaFrgAuZXAd3Wh5Q40Kv0M/3LfoVst7x+W8qubldDKd6bfp1lO65dYhHoTox1OdFdPjLJhEfW3/r5fL1hm2M6XmWtrzSjeqN6vTTkzBmDhV9DPqHLP+0Z0tdZMnjOfqu+ZJV6r7rXScos+dzi1zv2XwcGPot2fE+MvhwjstL+FmgHX+Vb+of7sv67stFeD2u519WmBYslpkcOch3nDrMLfb5h0oaJXZvsNPMv458803/yNzvYLdunsfpYBlYz8vJXFMWb7bw2B/41XlBwfLF/5iWADf9YoLloy9pKNVhoUMRbWn0XVl97jU4r8JoBmuamm8omFhnCbWhbbZHPKC8Q+DwqvE01f+yDlnvc6JfbjWZ11lwg8esJnNfHZRNXyYzpFDhj2vJJI3vrX7DSmot7yxM7Fkfg+oW1XUcpIzZGFFJz6aK+B1tJr3nE7ebevklnKj+WYw8gaB0TYGtdHnQkQJp1d13Ne7n1Y6YHm/joDrHFcYm+YGj+nW76MOR/CLdv6LK9BVtIjoV3tFKx+Waij70R4qWBuqDtPWbHXImzvmUDz1tOdOAU+H3sYdpx6bmkodr6/xjEgITD3mEsRbt+ZMxgF2MUplVYDJWhKsHO81i36DWYMod/O/rc0gRjeg6rT8ovxyv9Gu6ChJi2gPNfTI0xaYm5u07XuXlyMzT/gb9fBHZtApaIdFUE/nvf6eMv9MiykcjNXQ+d29jXn8xI3LflQRjdbkZIRWBjQfQDNIu6aDfOArygWhtu59tfHpoyeGPb+AjifstEE8xEg0sApzyrL5tTCKLjgjs/rQHObCi8Z5gngicJimX4Grmc1nGfyZnp7NyzpFTiTO19XjhJ9yuOREflylkR95qQO4MdvJjiZ1O8iJP/08a345HO7oz7CcMuG3zbe39UiqvsHvzggBFVPoTqcTB8AYbYfrwE2cBsZzkzf6KBpJcmQdYA2shz/IPMSig4LUVfg1r6ff9bn+/RXf4wTSclir4cM8OMCoxhgu5Gxj6tjrfJS/SuB3aNRS15u4xNrUdXCfHGFkAhAn6YpIgmlUCxKvpBZ+NAqvpG51Kq19p1Jxw1aAg0R4XTrO3RrTG498Z1OjDpSKjm+SqIIChnUsXiSj3MR9Qbsa1sb+g/8UKTfGQa2CdhlX56tCN0i/jcGQ9TY4A90N9lk2kIXKWx0HzWT34tnaHbO2UB9oFPRg1V3FFcquNivWPaXvkBxsmZBL8oCcG8vIZ9tYVJbeMoXhyGAIgQHLkQGLCpGRlrWaE1nxJMo9RwB1wKtjZaW270jVQh6mR9Hoaqmhf7cfejEggt3YC5cWK3NzWXcRpl0Emye34cY9oWwljkSxb54/1paKSM+aEelqxgd7YxNoxeZcm64CGUjp11NmcC4Erl+pbQbZorW2v2neXqVwZGcXoT50MS9F/XgyXnyzPy3Jda60Hm1F1jYAIuDMj/OKP9bCesCLxsEoHJgk29cyHDmWiOb3YPX/kkrghIaThtuelho24lXWlkZZoBnynnErwHvL9mle+NZxCp9td2eI2ibcBHfZKQd2Dv3+IuRRHM1xeaWrEzSlAmJQkFp4Kyqbfk62BMjklDOvYe+rmg5ODYN8dYUT1BHt39QkBo2OyBlMf8+JYHNitiayNeaIHyHaZ6u2WKY4pt20qqCWn3qD6ueR1ixVt26FJ1DB2+odBt6tbvsSJjTX3lJlGH1OA4atbvj7wFJ9ThoSb4HaQUdB3Cy+XLkSXVknSEHmdU8CcqrNlStR5c/6Zku5v5rl0BS3YpPY91c3s+GIY15aStolop9KaS3hcJj/TgB/9rcD+LNvBvDrMHqjtcgkzHmh735NUeEk4pfUoJgmtiAQO/bhFYH97e0jRyTOvyQak4nGZLIxO7WG6W47wuAwvLPaaPZibN5tP7tEgQsNz+oQuvpKQifaBpIjYhSQa+eDyD+rREGXj2mrIfi/OO7+pkeHS2pWeCGbmrkHraKRZQPkDg+OKaZZLtfns8N5VbP3lD6aJ+4pPAFusBnRP+zQgd2JO0yzO+hbllbV9DzjSOO9BQNeRbd7moCOfOeCUaikq1UzsXKO+purnmDUuEu5HMi/3DlKVySrLx2FxMZQfCre4/Db+KoygfMQ8wTvzwysfftmYbQkEEXtigMqmHtrdmOrdW3uloHLE3ocinhHUFnRpor5k4qiM77RwNVsinU9KKJRkkaYhd7Zt/mV+xbag6b8STRCcz7s2PuegNGYWvzoXiKgQwd5+4TueZPlfAqnFH7BtO21TJsth+PdPRKTI4Y896fJ7Yx4cXfFi3qunBdXnI976Srh4WrKq90PEPuH+C1ndlIpVm7gqDiLDHqcSr8Fv8d++d0V5XVHVXm3i+RomnLmAnvYzGTWQrfDL9KVQtvj8ZWhfebximB581Vjr3iRruY+HmOuoP06arhhXWlAMd9YKUejsnzwTWW5bauvKIeJzFZ5k6WuI8oqFD2DdpUmL9IIZscknrCfhbtA98zvBgGaNR3x9lf4Xgu4BnWy+nGdF07a4/Gc/cx0LInneZ3UAwaTMO+LNjxOZRscvysLV3fXRu7UvjMWx0TVMiZKS2xvi3eDl+ie+WmMUevhkjq8bNU8qD3btop3KIS4dQXTo4truA3diamjZiGrDV0ATyS8gtw1SG94fEfjnqLBrp9ftpJ5doiwQlRoTRKuA9Y3QwTVka8ltzejQ0oW7LRGLzS6JyaYiv53J3ilzsXE7WXR1QwsTMx9R89yOG1qLdzMD0VjbIu2YasbO5NurlBefFJOa40IRB6Yl9n1YqyoO4XPySgqx/EkxYolU3xD3rIxX1eJXKiA1ZTNMtlL9/hCcas9na6Kd2EqZHCBtBYAeB1cBDpmmdU8mTNcKQ6VDijWdKo0Wl8KkUWlMLkUQJ90LLb2h3TgeozDYonfNWDfGBxXLvkAfTlJLiZZTT6wj+YlurLHxm3VbgfGhyKH07SmsEjcTwYqTmP6sNwcoNaTyqFWk8GoiyXnsXo2nczSwn7ComzQJ+iwKudFkSEoIxZdLqMnExmZfpCeNSLT4V4M/0VfsqQXHSnpa67+jvHmCP95pqLJdin4/dMM/31OtucS8918wjLP8Woxg3+elvDPhxr++QN/7ivAvrGq5IhepHzCf4xRI1cl/Z96UaG0RE+VofFXapL68VpF5hzjR7ax/vcKpfa1ig7aSeHmQem5hD6szUAFX7KuBkgkR5DecJx1b4+z+P36c3387pdBGNfzQEjhv4wDolofap3EPoD3LOQip0I5VVOeJfPpYD79+eekHwXzaXfzweYG2hUUCuN8mmzc3ehvbYU4QCojLFyZ6mwulK+1wajMWjJHYR7HDyKpTl5rTotQoI9Q3B3h1cblJf19QOAjx+nsOCsOdcYYFEmFZfwXZCF6YbSNGlWgeB/qiKSlacohYAEPYmJQZ48obMZ80D6GH4sZJYz7hE+3ul2/zle1q+34KEOUsKK7baGtmxsIPc0EaY0Uvd0+3NJg08A6Zl/OpmWm8pNzS1Nm5ZEbH9bnLFp+ZuQNBGAFKqHwuCghXHjxadbtrqWqyXXUI9BdtiNpSFjUcqnpkS7Q8N7hBFVKF1Wiv9DHL6z1HbMjfmhDYfAiP5th946y4/l5Vn7dm1bpqFBd6JahAjFg6pcnJr6Mn9/Wa2cTEyHQus7j3C7QobmKCQo753O4Jtd1pfbFNM37qXJVmJiOrXZYIc0vLxi0UBmsSuCxoBXQc7hLPcevcUef4+CMwovJFZ29BIKLywYV1VuG6pbJuEbqUUkX87EOSRjCVRmP6/WzRZWjp1zxFR5ihp9lleScBgbTsFJDzBzoSeiHF86ip9numdmONUORkj0AdbbuCRz7oIt3QgNCTebsLoLnohZpzeS10E5P8MEUPogbYrdw8Z9ubMSKXLYILXfiq0x5mxsPOPgbvn1340F/6w6Tn5o9t6pbm73ebYLF7fd+rjhY2G1JNJ7CTxoHWiumYYgSAALs9DSbL+rH6WxcZMkoD16NRRAG5i6G4S8qoPNc5WIG5wAcHVEZdZ5DU+saprsT3e7hoFc2hfOrcXDly1Q9vaVctBSAHU4XERNLS16NrdYl0pL+UbQX7UTb0YHGZ3L6cbsf7SSuoB/tkHvN5WWwA/zrvft3tnqES2quL3YQcUVnGlR4DuS6GPei6elk117ufK2zCi6rRQW7t8q+PjlNJ3Dn7bvoczqFNTkBJkLdW+uJewi/8rxMZ9UUe4Y5yhczrmQc/1ovo8eUziraYaL4JglKZ9bLYVbRbMfwQC9Fuv/HmO8TUXiT7J8HO9GbMHpjmCCY7jJqPTGSN8GTsRN4g03g0eaxxpHmQF4YbrW1cB7WjjTzs1w+4UlyXhRzeDjRxlVtRx3Y6B0Fg0ROKT12g+z31d87obAYq6SKq+GMlV/qvMys/2IYXj8QqJJQFwMMJ6CEt9rnae28DhCTstLwXGt9FS5rfi5VUhCNoO3KrdwB07JCOrQUNo+L9UL5cScW1aLeQHitXM9LZalfFVHfhbUv1N3kX09hQ8Mf2M8omdP6NUQfT8AzqEcfecm/gM4Qs/M5LU91Icp1y8c7Kb0R37sa2BSL9QDomdGSbvZvTygncJ70f/65HKRvy3e4ybH2fFkwH/jkOGCQO5E1tXI4xrsKiPhTGgC1ghMtlp38q2TecO4CCWfERih20HioqFQy8yx6UicvNZeLsE7RX4q9fZYSYP5ceSJZ8QahaGVQIisrVMWKXxdCJR1pIhLKIXUUNX67T+PboIFfzwOSOpLWjS/3TTtlYGADdK4kVh0HCOWKDIWKIrlZBxZ5nd2gxpjPu13I0GwbSxogSwCjDLIFyBgga5DMAWekli5YqoAz774BQYc1g+7PA40WBh9NZxPNa2gA2p7RCunnFNwDC5BWVDrQG55XVIrcHKyoagD1Z2i7hoL/KjXGcoln5O9TzOJop+NPJeOnSrB6v/442U4pqPJgxi5sf02HsIkXJ/AiDMZmSOAYjxfy5lYYw794f14O78cC+4FlPHQSmI8+IOKkvUfRkI7Ydzfu41DXOCNmMmE0+9Fv6HC0TU5ZJiOe5NAx8a0Scqxw0kjeuNaLUUASZ804G37W3Bo9kwfUGMOfHYGKWFis7nMNx51IG2o/DwOov6vG0nx+O40zgWwu39m272wnLyeRKJfirodB2ILVx+0RbBtCD9gOW/hAlIOBKATPi25/c2ujf++elUQWM+/e5eWRdVoE4ngkhCGBO+uhNH7JBl8y4KuVZgOHAJYf9moANaBV8/KShNma4PqUCK22P1qRQO5g8eFTBuw+qnH5XBplvEsE5WJRAMjTKHlWa63TSIEe3bgf017Ok7utsaduttW7sR0vGTmMrBp+5igZkQ7a2fcfZ+TvBowa+Ul8moUXKPW4MbGa58UaolWvL5fZObX1k0Y1Mo5ttBX3QsE66ohpinXIs6J4xqcTMW9ZeeuWJNdfUBWJS66KcBGVziY6knoTpAHQBDu12CTW8s0tXX80hrNDzL+SThXhdmY/5dmvePbdyR+aqa/Zt/3WnV7PLIPY0tJvrYEazx4+QGENmDhIswr6cXPMkYXre1kv1WJAEgvyFwgZzms3x0bVuqwTodKiFm10u/T3AW4Z7iUulHtKZbCMSg7xro22065FLOfe28J7d9x792Jq1TBotgpTQbX28F7Y6uBJ06hGae4Zt3I8/F1MKQfyi71KccuRXD58gahJ8aQthbozfkZO10ib0g0LFuTIJjg9MlHegyNjYoIiR9G0Co6gt5p3MAFp7b2/48XrtJe665RSFAOo6t1GWkQ/Zm0DvU+Lc42CfI39KjYl7seU9mOFGxQVmLqvcBY1diW0SOy4orktu9213TNoSmN3PhLWtK8eRxaVIML7IEEuFJBBURhOMZ0gjr1Q4N2UpjrSGFtzXYtLjk2CXSc5g394WSJVZkmF8Df2DE39b7ynkumQgeTEf/tO/CsmQzDT+WzM8CVIPpI3c5RIyfn9K3+gRBTvvzE0H2dWlnB559rlnQfak0mteUXoXk0oPA1rQFCYC4fbKSS3o3XfRkg1CDfSPsCrF4pWUTm4fmW1cQC/d/f+MDjNGMC3P6Tksp9mKxihtX4cAE+MhSKG6eX3HvCfTQ0DTMZmoTWWtmYVybEpM66RaIlB4C8ZYCGMp4iOZYedpsJ4MauMBU4nlE2PKkAVluqohRBUvslvJq7vDG0WB0sfxYOBddAXwmVUy0Ka31zidZIpumCKDEjb3GOO9o7oy0vFgkMvuEmY88aRqNz2GLd87Oe9yGteYZHBpSbB6uZhbKLCz4RWeABmIE/WCtqhAdJInc1EZ6EBS8/Gjp29qxeZgFZpUX4h6OMqcQ2N+xUC0HP1vxJXwFkxr9bCOzkftBLCLH8vwOCeegM9xSir2KXSeJAwFpWXybNF9OKM5HHd6MgYFvDIEnQ8RINQjdahDOXMAk1CJZqEKrQHXe3+FYhoAnVzGFgEDQnRpu8dltN5iWF0vag6D8pp1LTCzXKVA2eJLMR1q0OVhm3P5v17KgtgC0CKegSbHW2d79efw4H3XPtVf1w/i+C/ZAN43i9ZhEzjFhE/BphGnxCdigEOyJzKVlRBulz+igTsEHnLDDtR54Fj3Ffmr18z1pajWW7AEtURRgO+hi3Q1geVVXVlN3CPOd1QTC40bUBdUbRc8cDcHQUFozjUP2GdEDq276mDTpcCUhgEmnLdBRXWhpBJt4v/dyFAut39eeDdWx+rCxWmH01Ycz4y23+yCPCeEm/WCfU42oMr2HD4uT1p7N1LQMhpAfKehBMPuxtYtYmD3J1g9vX10+ks2IMn5xJB2vJ9O8nEhzXRHcD4RoxsbIP3RlOAg+UdqiC/bQ/hO3oDHxCotqoF0aFtnR6CN2G0m/EwmBEYHsaiHDyBUoO1g3WoEH50u4f/2MVdnye7Gb58CPXmPOu7yWOYm+gwjD7w1S5T7t1u9wO8ceBCjvcvLw8E4Djc2aXISHubYcXpwZwu8ZGBH4f7H8wLAoac7nN5NeVPkx2F40DrC6jpUzTi0xQG/NFIfyKMDjQyfVFQcaC+EfZ5CB1Ix2Ou4imW4xEJuBWR/WgcUP0PW55FTh0YXEnuMjvJ23fRQTIZHCQHAnxhEB5ILL4+Lg20pAUXGa/0+CAqshP4s14do4PBPvyI6vmZufF8frYMSWGpfZt5qDwPZ53nJGKAkR0JMEJDuJ/svJ28G+yvqw+L7yX769iGyH8Gn4ZH0JjlEiHC116V0Z9l8qpUJ6IleRWRvJSITbFcmlMKDhEgaBuC3iENNPRu41v0zsQVtZE78/AHqV1LHNoVfYKGbop+1LIfW5eX9HeT+xM9R/HG79QxxlO+Hg++9/CEGu/EAdULp7L22ChyiiCUpupQo7a5twfa8A7796mOjn1ZBSp81vpfXYFbijk/vglaioUQsbTWMlFgmGU3Xyb+0aCmTpqymuZQnrdmPmVgaon5FGbKwo3WbeKCItRm5T+neuHneuFPkvRt/m5QBoqcRxcmtgvox/FHVgkcf1ziuazmH79b8zm+XAbHRXdTqb2Rk2NFSeQPdBQU3Y27/QebPa0ir7pbQt96UA530lu34kD70oAgRpdk/BBri+a2Rk8Tcv3KxuQFpWw3tXHGSJzHyMcb04G4z8vlaxq4yubK2mFuuMxJhLyWMNiYhY45eYyVTS7v7XKAPkNKGIGldUzhd2YuafJwLDc3/lEMNzfiwjJXsG1L7Wyk+A6oM4fdMuDd5ax6MnqsZps3+6GO8f1CPAht9mhnHJSGb45ejCnGS4txOTrewHaf0DSQU4W/EWx4yTc2Q0tB3BClWe3WjqfWmeAUIzXtYiGkuckllWzXWgdwXAYOEkW0gZz8MzYIbsgsgwE5Fm00nYFUBq/CSaW0GdL3CKvbZm2o11wLpy57AYXryAJ7N3ENbNyn9au1ecaUGgT4nr2snJ4rDd6jcn5K0ykH2IA8pE5U5i6OqXsKIoHTRoC1pwbWEgPOWHuAprfpOMDMvjhiKMZthDL2vkKVCtKwjNyyUjV6qVZPLpd1G4D/XiP9ExpcaQMOJIjehfOIwD2fTBTZxh/PsnrA7ok2v1rF+R4C8rASTK55I7JvhIOKOl2EcJigYQ9EKoL05LjAc8+rgHASSNuLIrrc9eer+yMVHyRxU9Jcx8R8mfhW6C5KaMbc3E3+VURksEE+dJx1i5DzqwSf1Hn6SSdzadizut3NXu8flIQ7q4bSJU55GsVPQdot0KDJxQN0k9QecUL9oqhsbejpy2O0KWTJrxzX3thIdXMjpefSN9tHUexJEPaC1035FUcAFlfOQO62PY5toje4GqK2kVguaiDkDCr58Up83IOnWJk/cWPDfSaBaa/EO9jsb8EotS+URscr1XF1Gr2pmAxiDxfK7PJKKSFfsFMspat5TNfvcaisYxJBW+C8vZqhwQnzuFgD0ivtmDGEeuFHFsM/r2ZcCqakpPTsL0rYOi9o65TngZzsTzZkae1x2e2WVXiBreihisc620Or0mRReZAZrPYxmVhbObSQD8CeFTQxXam7kSKMIhdbbVAmQf/nn9l6vrVxmYW3+uFtGKRuUnX/FeTdf03Qpll2N3r9zY2797b6Q3F92Y/LYXm5EfeWpWJaCuw5uuSRjx8D0CUEyEF+aynpjIZl3AOys9rd9fLS82Qjp4gwCkpgpYzbKH4DRtv55MBLoAoHDE21RCRAo0LlsCn46wImkFaIYk56g/fKEeULvECqwPdFOLA+T+Smp1gLf8pkylzo+wJbV4eDkv1V+L5mYeyqqmLOh5tURKvtwkPcvYByQ2SK1S8VT0mgo7jyEDEL2M1fldmQGS6ghBT2onsS4IIX/V7kNr7B5IV1F4ym3vp31XTzQcRlz+1VEd3+oPdzOdC5c3mVlShkwprLo1FSIWutElYFwYQJOXZv0k1N97BQsncWTMhcOvqZnCVdV+LLBJcpnAsT1k8fIf2kJHVj1P3jaVAPC0xzdeWiy1oXnYTwVanv2MVa+W1+nyO3olOCupmrX5HdiK6rnmQXO7kfKCaBdb1F93YRKZWv96ZugfEi8BtA0p02lMbKWlgkj44db1eMvymnLvDw/a3NOzDr8OTmsUfhsbB2w0mTMpesBO4ioNscwd3sae0PCfRs+R3DaCraaB3bDbsdytyQaHcf2eyd19REG4Gk8BYPUesK6Yl7HxERnOynfERb0PjULmUgoOkPr2UkQOzrQz79SJYiIgOMuue3SuMzOP2Gtg69KYw5sNAM5bEdSuyu0zUVTLAmKREelhej80Ao762kNoTV8etxtDgPY6Lb4r195eT3vkjMCQns+mygTU1ZcjyNjlXMx/EUZez79+9rMfh4Sm7xSCGTTC+q98JeM89lzlwPNg5+d6qvp6N50XHuGSg5GmjxQKSGjidTRJmTPOVJ7qLguzpuF4hWpTK20UoM0FvTn6hgXQX8ZJ1Ftj4dYyxZ5WDkAtU/7dAzZOQF5KuDVE1yIwsbj+CFvbSmI+UKjFjpu3V8LkPslR2qUy1GsFZB5KIINwkiWmkXURh4oPyL+t3lJS2t9ZR13hEzNfh+nZWDnHhv+H8uyg7naI7H7m3zS3HuwQPbZ51QZCAKGFWIjXxKF4AdH8Fc8fc7kblQHvWIs6uVspg5MJ5ExbSqM5i7Kn57genicZuy/7l+IkPFUMLTCv/DkurIxmpRW7ZvlOTDE4TTzWGvybmA789KmKULdQKjhzqGqcWj6DSr8/k4rtb5IuKWx+WS227AJ9QKLT1xeLJ+xs3Z48ahp+PqVkTf34oSmhAuI6VMeZ6WMElxtXxHujTFkuwgR7tT/jxaaNXcTql1c5/KZLR4u1O+i+bnyadyvZ7vzz9n5S6cRtDWE7z3tvcObr84O9O3b0G5irCe+8Aa1sH8POpAd2+dnIdL+Pl6jj+3Z9PTVJl0YIHA/V/c+09g7aVq+cDTj+5TthjRk854VFAW4w6W2JsvRkW2S7/5MendpzN6SknH5X2g3vRgp1iUfP/pBH/bkIOjhWrBkfdANGHbe7RLpwY/ezV3n3F/z2YB3D2YL6rsIbrZdaK3CHZSZdwgvjyH+++csvtZep59u+zhfIqVmprP+DeX1z/a3jD1X/XGqwLf2M3RwoLA6nRxg6bghhrsG3pwbxARvfEx+zoGWot/F2c3jDmPX+0QNDjMyI1OqGtnWxvUbioiDPUvNZDmxY1xmU5gE5ivudXTgNBvurreB5kUPyGSD/0nPRZPGHwI2gFVw1atKrjEZqiCnbMUqI0YFPvaQ3rNrccOi2gxVes3va2JonJee071ZG38t33ghQLckV/gAJIf/ASR+j/SpJOO5tBO4KsQJlb/Re3EYpLfGCtAH7WmstOzepqNb2Sz4/LrWU1XY/wX1Rg3inkKP5AEqktE+jc/eTzOUmjMDfoU/gNk88ZZOZ9QczGST30Ifk//ymCdZB+hevyDJaGKosDfLK/dQHZPDcL5vACeQb2topVkf6Pxudb+BZ0Rra16PpkU2Q3mKGG7zFEdOwPuYTqmBt9gm576Q9+jN2S1OiXJH6jvNBzA2GjKMN6O7XEutkDWgi3AWXXhqKcDiKR6c6rq0MhSaTNJL2yCgpA/4Ppuo/iZ5IP89m1py4nQO1Mfy9GRDankAwhrw/w7+nMRehGU3W61Pq3QHS6dKBo/h0NlDKytdp8sk0lUuXUlR2S9KOF4tF6TD6fw79IvSScynGdLozxvs0hR267Riej/Zg9kTq+TzOFp3z6p3g28yHq6qdejCQi51Xn/frQYwXHZGWhV/OVlkLPdYINMLTrflOCh//BU6b0BiujpZbIFnGMeUB4qrR88rpIO54ndp5HCXXKLXB/gPBzPT4MQWIdnwDPOJsHm3VCxDRtibb8uFZTqWvb2uHoXXtAf1Eel82a+LLQzryH36xF81FOfG8X7H8hGYQx+GPFlj5TrPDB14ifni70cfIPaxo9hY9AsrBsF9TU+jp+qnexfeW4dOpWK4JccDYBaJcDKrT/OXX/iKnl97uOCJsXxEpXHVlLEesn6qay8a4sFNBRHpZ4vjnOiixghY+6gYKFvfM6zDEWtgBReUTpEHbVyWslwLTxEYrGvlj3ZZ6DNZyAgZciPKnQ24DDDeEVxBMe+RqXXqUlGjvxSSimIaVfKXgndvo2P16YIY9NW6ctYB2zMQMJ7E4mdCrfM2d0U/2y1IIa6blhM5FDc4hlT7zP+b26BOXJXpQnsv4nuHPH3RvRat5uv+hR9RVcH7yvrl5+XjVowngUTlPDcjqInF384GjGkPf65y3829N97aB4rgdzrMHKVR82KqUsJNH0+l7oGdgwro71kukAJFv1x9DGznfw519gg6INllof2vxpNozcWSVgnIjc8GQZafJkGrNsMncRNiklRScqJQ+nEB8l0IjMGaREhfpPwdSc6SE4WjSLIDmOZEUoLjSJ8zNMz/lp6Alwz//bLssDCsDQgL9fzGbnbOE1PF19UMfppxBz+adks8Zu2sviNnbW/qPniJ/Lz/Fsw19jW0Vy2Fblt3QZmvJ1ftamFfn+Z1uJnQQKF/S2+iT+ZGOnfc5qaN2fy40ShmGdS5eiObYQlYeK3qvcgKeU833g9Z2XrL+rvxzkUuSm/d+MV3po7q4MZMlW94c6w9j+dpjLthNtj5/Xj+dlXPcxmAlhugLJPnCom81oJW4qoquLALLY/MDfFAKl7Ym2oO2KUhHzn3rDzo+7wfvkwdyeFGNNYLHt96yA55fSEh5YZRR/GtUMFlKKHk7X7YjzDaDc5HG7r0JXtW51d3VVS8m0PDpFkaCb0Q3IUPR18cKnnfvIBCcjTZF8YXvfRRw4o2/4aoanjn4276u+9y8un5jTfNVfBflKmwYdoN8S3VO5sVkG9xvv70VOMHNjVMecfkg+a6vV+PlT8JHR4m5iug2A7esMKoSLaA9LnKLO2hTLrcIl8Ap9aHDdJCfyQJiY0XHbjqvGT8wYUSRSCqfXKoMS6zcH06QJa9yahXHAIaMIMJiaYO0EPD1aBomkIzos3wDK9eXsye2fZWGzhweXldkh93Ftn/1n41t5wL6Y7DscUDrddf9ttdWa8pPdifj06GLY3qZ5rr+MDmPQ3yZshtYr1fW+sahGW2RjuR4fJG5pwfLSbXV4e8sQf0oTzXwoXeaPUkHFwwKz1G3RLPoDHb9jKcwiUEJaPp33ZTTzVzQc15J0o8Me7ZZbwy4ewobhiV/GymzS0Nx/M2x3yJD7Q+yNepMFBGD1N3sg70H1edIfBfvThVocJcHTAK28bxoUkid0Mrp2BTp5CgzjCYAayBxpBqKFc1y7WxbQeljLVdajregqXblW7uOkOqbn70UG3+yasY7KAHib5OXTyAHr1Blreg2K7g/0BFA72w/DprVtwaWXXJ7CbnwyewMMnYbgPD4mj6f389Pb+IARiEcD2fHr7tr69f/vpIPwAtz/ArtW38TmnxIAefbi8/GCWC92w3qXhxSG0RccBqtq5tuUhO9SS3MjXgwNTz2keEKxKdEgSk12Pu5lbBkblDRZCX0K9qY+GMGdHodkBybbJAd7t2mtP73qQGBkHVhj9YmMFvlOz4NI5mWI6R+JSs+T9fKD9v47mwXaIOF4H8xCe/D5RCYKy5AN7Ot1Mnk24r7I50doBeoE7DVkzH8YtjR/GO3A6H38czb+Y1uDNMh1P550hrKnZIjjSfszPVeJ5amEYw5+HxPpOMr45Qdn2CB3EtucwgpOMlp6Jib7Z7d7EwDMMmeY+ay4NsXDFWMwWpyNYunDXS3lExhtN4YsFEGpdFvYHPQQpnLnOm/5kaTbUcI84tDeBWN7UCR8fjqc1+r9iE+pykdHefzZLbkbnCwxsSFUgUyufCU+h1LOZD4MkOb8vC5PTeNBk5Fr4QMPA4Zv96BOOqh1Sxe948ityqc8nArupnak2ddHxP+UYk0VoMaT0ePlKS5VXNUvadJxuGlZPLRs3XkKt68pXFIp48y2l7TTrCgqw/MHbYDQbPp+T7R+DMto+CTIqzrEeFzS5wfUuG9w2Nh60vafsB4Mc1/rDOb5TzI9TMiR3Ps5RZzGagbRJv1veHOb0zUZbut3RDD3isuTLPMDD7bRI9qJykXRoPWOYzWkxPFUGzBguRCBLNJqxp8vNZF4FRxGiyvd+vmk5GuUU+WwOV1G2kqmBh5arubkMo2kG7SVwx2Saxdi6p3MUA6fCic8WwBxNERaaTIbnExr6+Av/VXNArfP09tjUPLNtvamb6uv3Fc/KpGtlH26KLuQZ9OGmbV50jHhRmFmIVvwY6XvtmM1fp1pNxoyhNVPaoYlrzzInQWzmle85VFuWOEpJfPbdoQikzXXnRUgmZIYqZoIqxf1WivstLc9bIc9LOIeVxTJdzKp8elIH1B3Ef0PNUqkBBW05zRurQmFkvZa1e8XAYvYoP4C374RO7FznTHJ1Ezd0jKZA/FGBoOg1wSC+d+zlxj1jsc/YWG2/cZr7aPQ6eQErKemcy3FkRb5C5JYHWt1TkFbYhqQcyYTzWq9NahSbahX6kyrqqdo6UZMwUZNwZCdhBOdCNQyOcIwLGMnoSI9xLueioJxWsLsreKe1sJ4QXRJdgDR61lKnN2YPHNe4Xstlv2Rqfnqe/PTP8p+z4U+T6AyvFz343+U/F48ePdr7aWLVtWfCmSOQPhyk40WnDPLIgHacFelxFpyeR//n//s/9vfZOaVlNfVNcsf3tE7OUE8a0WfQOUzkc8lk2K/Rqxb60EHPCdhpHe0skopWkcfEaD7+2mE0IjLxgYCSdjhdTgfun88ow1sciPcVq2D937EaTG5d40lPClWqtdult6HrDqJ957hIqwoXXSc+RdcjvtFxce87wEI8mY2zL7oQ/J7Sb6/ceKpFdxCptXR+DvLWDvBi/OvzdFzn6jrPppO8VpV6GS46BOvXic/m5MDk5tdGIkhMAXVQQS6FF9w4emgqW3Jl5bH+JuY1j5WmtYNHHNWRwhCS3p7znmO0tgoQNP4kJoyBX3achNL2YIQW16FUuA5d+ZU0YechcnNzfHr8kVK+KrHi3ozTS2xDKZz24We9Cjsf0vO0Oi6nZ9pPG88t9tX+X9s3jpA0ob3q9MbntLqxmGVfzmDIs3Hx9YZ20xmv33hycuPrfHHjmG2WWFw58QThjdN0tsB4lgjN6NV0nKHrNpo4qViZfVpkVf1Ml8aTCtNuqyr/+c//VWYImoMv1PMbaFElUoG4gWfC3EWv3khVo7jMjZyc40rx5bSo5tRMrI8r8r1g1v9X2HHiVlb4zxTM9JtBH/J60sKQogmcZT2q2HOrUshr+iG+/nB2jKIIlhE/W4sekIuNLsm/Wgvyka4LKqOhKghkRBfN7KezFZ89NZ88bf9cbT5Vu58JByu2y39hYwBjyurqVAS+gVCBN5Nfa6/sM6VkNYVPskCrCqOstTAyu23lyWEj80gWCDIlSELF12dZ/WQGh9vj5wf7TIrWBISDGo01Q9cuL9eCzvv3eX1aIOechl5MGyaOJOiMJF3nYjq7FntmruuDZ60t5SZl3ERvQNUgDGSVkh5shukZEuJsXV8nqQl+w2aK3SDuqul1pEaYozFXBBc/Wku1OCOTy64r4L5MS7Ltxk6hx4SWDNW6j7UmkKQR59Yuag4yrdmf2mniUw3PDkl5F/WcHbrk3S9AVj4+FufMNY8KvfrbtknbjuAPxXxiqc1RrNocvz8LOnldn8U//fT58+f1z5vr83LyU//Bgwc/UTUgkojq3FwtHU+ZoAf5LCsKGjAh2k9EgXMxvjhUR0Biy0rfwcB6YGGLo6yaL8rjrDqCQwC9/c1JBgMrasMJzUoM2T/LU7HtrruIhs1TD8cpXk1qBmoRZMZCBEfG/PMj+Czs/yzTZ25afZ0di34eYn5qa9YCilC5q8z+srYohcLt/jycHqOw9WSmLtynR9DuOsOvode2YAB+n/+JTj2kamCuh7JBGlPOXCtjZvOD+Xhhhnjmvzc/M2+h81L1ZAZrJDO7IR0/nRVfzU9n+kqe7LGxWkFd5keWnhZkNuURrrPTZ/gcZvXvzee3Z9M1XqGuBD2f4MNwjq/12utEU/1an0z2f3fJXWO9HdvlAoRaX6J3mNl3QCS+f/13u2tTYPV/D1KQDPo/J+kPtQ6a9Iy+r5h00p79ADusmzJs+2D8jYP9bH7GZkI8ch3rH2WzhpviZ05SgX7Fq4loHrBuC1rzbzCFw/XIZKreaa2vPGb553vqU++01Xf0vZWtrOlZPv/8XTVV+EJbTc+n9Xc2qqY3WutC7vO7qiJ21avptNjBvyvreXWwj3Xd/wl5cNhDx+hcCm/FI3ytWdt+ivzCd9dW4GvN2p5Rke+vTl279U2BKvCynjZkdeJZnsPhqr0RrGr1/yfvzfvbNpJF0f/fp6BoXwWI2rRkJ3NywMB8suUtsbxocRZFY0MgKCImQYYgKNIUzmd/tfQKgLKcmXPv777zm4lFNLob3dXV1VXVtbQraQu8Le+BjqzugwB8tntOAjWqj+XvNygYn+3Rb1Iry9+v6SZhFH6aktkMGrWNeKvhZrVNzT7NblSL3Czn/1/JNN+o6fl6LU6z5ua/W2D5GuHp38GTf4nX/gq8RlvNdILGlm+uMtQOAAO3Qn9HvvNE1A41mu/Rb5Ll83CEobDyX9L50NOKbgyoMVJ2oSLvqQ1z7z8CaSKGvrHGMwldqbdkhtWz0TnbCDRrD9TZV7UsnGE2zUbVjb9WPbnHvZWXZYQqkKSHxtUc8DtBSNjHq/mwXYoSQ4OVY2RnCLH7jm7kmTQpsA2FR/NKMsq5JAL9dOEwN/xzcaltlbTOMJJ/9SvF1KYutowvmTsgQ33NFhCvp+LXRZSVhsIF0E39DCc38skiroI7M1/5N4/OZucydh0RBpWUXlEzVDRGlStRKATy1Q7y6lWpphM3U7tKIMeH/4HGvBrbpQoGQzaOhEz9mBvNk/w4BsOkX6oS5new6sgK6q28wFBbD5VZBE7pLqBheInWmKGMwXAhEwHIFGAw2mgzSCMJ0gPK40Hx+VyQRhKkpDoD2B04GmliGYZuYayE9wu3vCrbH7mvpZg6a2wkdQSX1S/dbuWsWTUvoku45HpE4sAs5WRCmVs4f84QjlBMhWWvjrTCqC6PiGBhZmYp8s1LkZMBBKxDfi4u5XDVKuRyFTSQLjcDaei+NLqjCF5U5pjDjNQc5+FMjMKhsFRMW1uRClfVu8Tzjwoo/YdOHa/LyaTbgYm+PalBZQbIGhmoDDdDZaigMqxDZehCJboJKrn70qBOFZY3INFlIxL9594mDBpa0B0yZ4W8lwOjyZRvCRAMF5vBcMFhjUdnF+ciqoDhQlE9QkFW7qnff0O/V5nEBaWy4knYA++n0WhyeTvhDx+laap+RL+nKreTDmZEZhgyzEMGzolhE4y0n0y0vgetigiInKnoXWSnKoIu3kWYp6jyOcwzdeMB5aDTPEpRJ+DOza6SAP+olSukTdPHIohNN32HJ0FbhW1pI62guzC22SN9e2Ysbi41+UOvN31SAxwVhzYzWqkr3RkaBqHCh2F2tBnxjhTiHdUR76h2p/pvOEGP6vhmcZRZQe4xOOgDHnRtxAdqxAc0Yu3/JCWgA+pf8owmCxf2eLmhx0vV46WGgT61L81w7evqRc2x57+PyWK+QMYmY9aX/rB3vbhsYAvEgSG+hzxrGS93dHZIbEANCIcAhMcub3Co11+d+LXjfCM9vggf63WN6t+6vpbwPVTO/48tT/V9ul9UTiHR2T4NGQe/DytU7Y2skw+3pHLscQWJ9+UkmJeZhYf2jlZcz2Ejg3PkllcZnAP39UIdz4c3MTiHf5PBOfwKBgdtVh9rDEaTVQbvvIACkyQQ5Z9GHucQeJrLcN+gkOHZL4g1Fw2s+/b2RTOzXscUw7IchhebsWRmsGSmsOSCc2LnjCAUZz1ChuqC+aqokemaKdy4uJnt2g9nNy3drJntAv5PzwEFxAsNeQxLT0Oeh5fEekXhodh3+S1MHUjp1raA8mxxqtdqDQrwEujHUe/sPGi3Kb3sBn7s0F69S716OdO4RgKobby2ovprBbDLjWtqULmRy7g0MT5KzRFGPCZywp0xCxg18oe5WsDZzRzivsph3bSAh+7L6oC/uAPz23KHOfsIayYxJ40bTJtMmA8384eU2VIv1j5A5PeGxfodILK/ebF+18D5/Sa+cWuvmR38XS3UPi/UhV6ofVioCwytzUxqEweLTj2H6Cqi1uvwa1jZfc207jeysvtfZmX3YeCHLh9LOgqLS/s6Vsxm+b6KLXP4w9vwaORLxYuPK/+2YeXf3rzy+FpC4q1Zxa5mAPUqHvEqHjWs4tFtVvFr+UJ29fxa+fyosprNXCIFy08M3J4kDYB7kshpKV6xDjyqIlnIJzqbkAbgAQMQoXfA0IM/9V4O4MjaxxDT/Ed97lD/vr7WbCrPzmFQaTLWXJqmcjMOPDE48KSCA4/NFB7zFB43TeGxMwX+zqG6eZRdP1ZLoxniBdmDVh0A9N0zG73pu/NM/TSbU8pqNsdrbVqdslUftDqrrBnCwrOvKWA2eEmPUinenTzN5rM0yR+v2N/fttNTkMcY13PMXxPe1NZrz6QpRRu1P19MmzvHiECDZHacfkab47yTZuk8jeaTGdmAXUKJioZCeWMoU8cCjzYe2RBGdElRY/PpJENftL6IdsK9xu9e8Hd1Yg4MKom9Hj1Sfh2sDLxwh/UYCpxhdQ9oDI/x8L2Al/bHhzvhwbfexY+Xvb3Au7x35N/3Lu6h7w6abN67F4n5TvjDt95sZwhvzOTu7yUPfZHs7GA+bulPSS6Q+KTMfu8nUO0fKnpcFi3SSxwS+tlk7KtC3qVNLzpkZQDYIixzZHnl4/eS4HsycJY5EzB9AvNHOrpGbken+0JkCIN0yya8r188Ptjd3b1PQqD8wl53Q026nsTIGfQPUk/Z4kEF91u7Fu6vTIxATspbEUobPj0mQXRz51a+0z04gqUBZpJeZm9YcdPbtTOQ/jxzDLgTx7paekdmE0kH9BXcXN8Amru75pfVyzvnpbzFs15uOIuc1LGbKpmEs5tqyMtMJsOEV79X8Qkj3coQiuxGJsPvKPiQI4P0WptOpuRb0Kb0BL/PepgKDXpMiO/3ZN8UB4M2+TCU88wT2uGTwomD3zPF8u5MrBaqSTxKollTI/uFanasvwTHwzjN3cCLskxVvtDf+Atzjx+m8Wwyj/JPThv3lbq5PR7+2C7aPR1FwuzEY4xykU9Gi8Tj2IUcJN3vcASd44VfBhdDA/Zj8i4xALAjUzDvkdjOO4cjNyZl2B4mUb9tKvzpBpIkgi9zq+E9JoYYPjbZKWvhG3MMFWTRkh9IB4ckP5pHFP22ff9umxO6t+9vt3V0kl1jisid5b74KcNchJJfiO7d05H+qbXu5m5P//ov/WtL/YJvwGnRdZrS/b3/PPKSm5NB+ZVmCCpM+FJthi8EdGfFeAbxrjNIZ/mcZtM1kYxnNgwpo5VyRO3OzvLo/PoanYvax0+OXr49aaunk99ePVUPr16+/hnvZtE32XVbJe9ZNHzIh0mC5GHkgHSG9xRDGbhxFBonjgZI4Bu/CytngmHTchhMeepgStJNDKZEdUwZOcmIepjZtvMBiACMtH+Q5mgGiNfhOPhOn59F5RnpaQZMSOBVX9S6ur5u482jG7iTTUJ86aAxqhu+ygp+4AwWcwE4w0VrgZCrkKQtrN8hXlLbz24zHBddkUYNmyRyN4l1uDEnk6gdQB4ld9vswoXor379l/6F6A+sByxgpBfQyp49s5MHWGhKyZ7wHLAXi3MUzO0V9bvzrqYROoupW0eCBTFbHsqWGcaLp/sH8ufjNwe/tQMYEpCPtxjHSkfh4XNb7gSuzBshcGvQhuCQM1/aEr5qWVZJl8Vc/Gkpu8nX3mGP9rRT4ohJYLMvO6Zvdks42ldEFi+qDS7Wy9dvT3GnJ9qnXNrVyrAUmuxFKl4YkIkqw8NSOb127TLIDHaKIWhkd/pE7lryBikWk8qGmVGoz1kFhHiQu19AtL03nSVxAqNGMcFEhkL9ILLyFFXa7Z0NzOE98Mz4W4lc6gm2EgdB5gK/3gMcqnk+mSEvpDqiojdUJINHyC6sFw09sRGh7IMe3NZU1Aw/aWhGmHA7sFTaS0mxcQXQggJTF/DA4MkdFhQ0TQYV/2ouhLPOVDAywb8PliAwzeoYwabr8O4rsLHCkCelSexjx4EwXKTaJTIoGntB2SNtt3e4kNyFq+2wKxcGdDmCFGOmg7bLNBRqjESRD+Ze4hBDoT17eb9a/r3WcbmwMh/RjNqOI3CF0MASPOziueA5ZXu4crcjHgB89Lu9cbzOCPScjeuplePBHdsP//LY5v/i2J7NHPmVHNgVM2g9/pfF297d0GTLbdIj1HDZIXRKWFFyG5wGXnOj5GZBysmhU42rSMTBGpKfSL/sI8xqAyeownSutaWZ2g3f9eeelRM0Cm2eHxOLNNr/tQ/eHErLxlcUVRZNfMvuqG6d11hTuGOO7BxUlPyGkYRFRPKIt5d1XY1A2ZW7YI8dkx+aI2NOHBHvEoYHZaMgfn+u12uuF3uuOf858/vyxzNd7Vnb7Z1FkLkSQeztqrUAJHj+UhVyn5DOozI5K6/LbjeRSXdc/k7jBU6nq+SJuiREKaHUgGp7ozvfwAFu3cQLEkB2dsrKqJup1ImeIHquppMi/1cnebcukjUJbE0ASCoTvq9nfN+eVGWkzRN7M6zaiIYDSkVpdFggE9IxHtaTAm8lFUX+d98/8P3KSU7ioeqBRMKGZg9rzUgGU83woanZd3az5tRX332/5xjIo1BndL24lyJ11OVdldi369dsdjHggzc/2z33u29RR0g74XTOAd+iqTga6oRxJlCzrUxU6kg8XTETIadnsBNY2G88dMv5gvYRRzAZhZ86/S78F64HwZOFmAUnC3EQvFmIJ8HRQrwK9hdiHHxYiF+DzwtxHBwuxGFwtSit3WtUVBOQzjHtZJhhgnQTVsPSdJ5YGdaGqIKxcqqpVIPf008VlwpzYvTeY0DpAPqfKdi9z5SeSKHVI1T+EIuiSswg94duFOL3pCU3Vm9zoziEMiWKvMEsfd08/AbZ+TPgt8P2NzvJzjft8zPipuEpx6dvlLJ4ZHfj5TvhN2cWC4j1R1Tfh/Wm+MI5hlCG3xg2OcdjGpZ7FCTCYgaDkcCPBfNSRJ2/imS24mj3aL/gm6RUc5B0KxlJSAiBgwtzCfIDWpTdpbRtEe2lTjTFUOUsrVFGUWtv00oByA8wuwgAsN3PcuS8Bwns9LaM5mPJv0fytIYmTzjgCzZCVp2169jEVnXsa94NWrySv7uV9Ums5bCWAXolq7jzsyhHsNJKEWi7zNfSPZBMZ0IPx2RQ3eNFoRLg8/NkLls7tXx3UeWr9HOS19eXu6J3bk9YxB1h3BWsqnDmDZ7sPFjJZHerinYWf2bhz7RF3OiiJNjMwt/xVXk6JzyivGtJeOwR/mjwSMy5ESKsdwWci3LEMSDe0CcmoJyRYXIN5XRCo7kWYTHuSaXepwjt8FUlHnS91q+y1r8fez8YZBzTL5n8iRELMyjpfKaGCHSi3A7zAo+BGrtw0G9MjrgNSBj5DnUwKy2sVVbG8mSOejWZfRolJtBrms3dIvSjTuMEC7UDcj6MZknfKXLfV7AENRgaU+juS2GKOxPGl6TEzWtjwWgT4fH1yf+/b1KkkmrCI18ptcPRZmSKXGSKcHI1ZHI9YQ5tQnW8iVApKjXOoHlnOAG+H/2qjknHI9RWnoeorlSu5TJkeE5ueTNyctsaquBRaymYBLtCrg+5J5UcG7x5w/mXHdkq/J5lGbPUlrZJrrOoaVMkBQAmE5afBwX48nJGgb66MuL4pr3aBXheEJAvLCBfdD5MOSwoX/SYu5QjceCvLzoT8uoOjwT+JMvn8KDEdnURipHUEsz0dK/DvQ1tpC11c6MH0Mh6/E4Mc2+IXh1+OQzJfcuFm47KNhTxpMjmwZ6gO7fgshS53C9DB3k+G0L0682EaIwJCSzEIXTH/I60hwGDOG8wWltivsmwYRPkPtRTJwGcb0TVV1kcbO3SngY6K9c1x3V9y7etaDVXXVG52WiPzGhNZ9aaNmwZDPU20yCTRFODa1YBFyGyTHRMEeAsiF0ZiB3+n4SY4LkwhWz/3wO/D1a6CCZJXh7GiU6Z3usj30ldEMHJq3LRd//AxNOuMYJln8Zq3cBWJDlMiyOvjDqGujg8tnrNaml96AKEgVByMcaggyXO69QUKbzMoR3B4kXOVrXAxlnmCXK7DuRGMsV2hFla/UA2R1boFq0rKn66H6mp8zdM0HrRBBjUEpj5S/awGQZ46WmSIAwpImPuinjX11jtJjLWMMVNx04pZgS0BAic8FAzXDuAMBDn9vbWDCg+ht3U6XBCIIoc99CcTsRgIEOScMrgCuNKrBevJp1VDBFXLrJV5gJjXl9isk8o17/FOOmnEZTQX+oJIwXI3vCnQHkG9sXs7WSUxtjYLSgVH0SRLoFSHCxgw6G3rJwRhkqcc9y/BtPE7x/8gB62WhgeUjpcK6FItfp/OtWtzaU4OhXZcNQhOkVhGtEXtEkCtVjbRlPYuRX9BDbe70hOG/acJKsbN12VWP037boNCprvvqN0PJYNkWX58U2VE//GinUQWfUMb2/tk/MzFPfb55ZK+tBOPwpHRgm4sK6zUYm1u4X5yTOxZOAD61I0qWyojeIu7wsc3BwH5/civa32AtLvNnO/UUf2E87F/DbclQrF6jJZ81sxWfW2yGvZ0txISXNJozRngPS7vaBneF/niFam4q/Wikq0PKNdct7esS45Pg/tS6MO4drODsxL06vQcUFhZZQrmsukWNUlo7dnhA6Ief+ldQJEz1kbQAKDtlU0H42kPBJ1+dQm7Bop7OJYmJIM1hHOPk6YYtI+qqGeyV5cNU65vkZ7qAoXw7YbSlAi6UlS5dxHXjkSzocT34ZiZPEFvKGCvH66JfVzJOd4HzMDJOfwQD59bh8uzGPNulF4iLFKBJyENpsG4gt6jAB7dZs5y81CfUr5bKbJ90ZZ5lJc+OuhkmUuxVDLMhely/5FCKPafACWs5tgOWs8Bki+J/pPk06axWKg7aajHGcGwAAOEJ0VLViRqAdljHe+eMtgQ61kHWyUdv4GnjenSefWpBt2ee5MMZcOrnQeOOzlJsr/UMit6cuwn1pr7LBiXgXe29+pDF9RaL6/YVVqGK63kGlqhaXOFWlRVwRRVerYH428TcdNZVufC4ueWMXf+JSJkS8betGZya8oI5gAdyaashWanIsUfJrulOBIdljRcO6js7u6PCVbE3UF35ttzEqdCMcGD2PmhCP3CoKlnmDEi243nVvmUjblfynNgZO62QQZ/Nilc8eCQiQVRs5qVXkxrxRQMHJjrUJN+HkuTVbMAN/+nxqgZnGtZqZsbn7LiNx55c71yj4D4a088aRgrW6l8hAb8kOX9RsUJoaQA18CU5jLmFKSKdRNdW0+8CSvb8K7kxe+ZK05yMmIGUxJT9BRIrpEqweP8hPudnPjqpArbJ6RxybJsZ609JydvUzOpa04UR+06mywwaoasWE1HdHr9OglmVZstsE3pqdO33MfzRG7wzDZGXZV0j+E0NDvXvYuOXb6zA946kNxNjv39QV5ZHGGeoEaya+kptXE8GQvT/J5r3oY0E1q61FLWmUFldubU9uSZiRtCDpoWIQ+PVt2jP2tvdtqB7Z2q+ZcWtVuSM5Wk80+cgjOC2O/5pg4qS9oaVz3AGv8tT3jwyRD6wz+RSeN/JzmBaFfmxWUjI02/p53VHRQ0TBXWzqT9KXqjLRbVrXdcxb1nHsKeG4W6fQ7E0lSTUr+pFnhFSuwDVY7soPTegg9GO0iZTDFEoG2vGTDmZtUztyHnJ/MdPPCEn1Ii7Lh7JYBSFiEtxdujLb7h1jqybd+h8qSnGKAUsy7xpNffrDK7kttGXCokdQ/Ya6Hzdzpeh6iqkNYN9jmNtk4iCgAo89BJSB5ooWPJJzKrKzE97F/AnIbYlRnS0Y15lfZ8+OAK7QCvQ0O8X5mM1s8uxUP/G9ifu3Bl0nHrHYeWmeZXSyPFIqSbIrl+cJaWQUkKeA2Lf1DxfQZoI9coG+SiEc3yrwY0YPO2CTGZGQKvV8tXE8me+hoCUdj4BEtkGl0KmBqGlnl+hp/puPLJ/jUs3MX81nd7KVChkTOJ5u+kmCSEE5Xro5Tq6hr/ZYhQzyYq/hH8t3O3O/KcT1ezROcUazAG4f/ePD97u636EnpS1n2hkHKdOjPJrOXdJWOMd4c8HztRL5iJp6ZwaMk7n2/G/ywu+vvzI3xitVuZKs3ah3ajk+42ZznHD4WuKZT01z6mQ7TnGd7754wDyoFMRaoxcey6+stKqtCzTed2YABWGGZqL3Q/D29qWABnGJOadd95AknCEFC/Msqc7nIFVfsbGNqZbrZsvlkuSMv9W5HmUDm6368QGFYfkXAlsVsEMhLGmg+XmjPxa0GcU9Z0V3mlsp85Cv5DMs4kh5dmY70GC5zrfvVpno1MRsFOiJvtxXfyPx/tzv7MVfs7Eyxs8Mwx5iF3lCb/GrfpOvrYYWLpTOP7Mrb2WTeAqC0JeFDzrJBuEPNfRQO/TJCI5BczhoZ+twWhOWF9E1G93TlxVzt9XVE3gxun7kvtb1DCo+p8RrWONJUF4uBtd9IeKPml5LwwtubBNK8JpAmtVztLJAmotY0cQTSBtUAY/77KFzfvcsnfPCbgIMRw43NWAh/MgFMH6unD/L6jRyYgiRxCx5QyXyIlslPWPdtmdj9tbCj8nLsHXRETClXV7in4Gsn+U64cArDfQKbSG5p+U1+wE2Ir5UHLAc/pV6Zbr2gZCzhPfUBwDAMXk+mh1SC8HX74oSCoR6QeaDAbm9VtfE4nVsfVF2/naUTkmB3uTxZTlN2/EZSmofPcu/ensQnmEeUXQKj/SrK4BWTuGEyGh0zfSE4JjN+QThzlMQYV3x1IDl0qyF9yCm5imZj6xEB6X6Kv+KUSShwya47TJJpcQK7cvxsts/JBOkFS8FM8uHNPB2kIPIDI5ouw4jLJ9lpFkfF5XBOXHyYq+InVuFMFcr54lz5zVAiBSav6DNamDWwSp0JoAUmGcuHF3JwGWZJHCXz5ATd/zk1o6KZFoM/rGMtRYx6bPnKYiuN3kNVQVyiFeke0ZUtOgevwwffIdVZzr2HHJGCB45Hv0LqGd5QquxquAHCqwLNUVF98kTx2c7k5+7LmU73yfNdy7yjQSTS/CAZUjDkBDVYMTZHY5lXBaqeLQ3gK/uCKOkB0XmdwfEVvM6s02pYibIdvsIoUiLSW0Y63JiCPIiqewyVLxcjNoebRivivfWIRyXy9TpgCNvFAoDMoRvpTReigUJ4PKLQk9DdyMqxiCw22Tg8jeQv69T9y44XkLjAEyZTY9Lpa9ht2bLWiHP0zVaIb13rdzjiVHOjH+e9EYDZfHIe8yflpwV92GTl3d6WL0yLd0OVKVCaF+89JOcZTje4p4yRf8Lp/+M/9nZ/+OEf39lWyUO8srJeCRqCfrTG9tvtvvQU/Uzm4d3c06Yt9HF4GtlflYCXM+YDZ0lhic3cFlVTjw+dky78xwwZ88GfOtMuwHUNf8MHYhTrJuUgzTDNF72ZCWyW29P52s5/+IrOrZoIsWWuvpHFwD1zJkqJKs9nMtDuKkdR7NOQYrJrBvYTX8hzX35UzzJmanJLMd/+bnv73t6Pzxcdyoz3ZoDMJLva5G6OzBkaqee+crp0AtPhwrImqPWQHQ0t8gO0RJIld1t0bEKieL5PI+jNPjt8GbUaVRUycjPHYHNqAU0Ul5VjEMq6w64K47L3448P9+5dsvLRPYnO9s6vgaAPt8P/uih/xdsf4S2T7X8oKRnOqjEg6g5IcuKvCA4t3w6b33q4R+q+1t7D4BLRdyYe+Bh516DvpaAydAVAHMaHkqBEKyxmWtqvrC++yJXSLZyVue60YW1ZE6174Nys9p6Ej9mhGdICRf0oVl4Eq6roQq8QI1dKYAn7ZEyWOJRrHvalFKFotZ9IHy3lQDYKyZ9BuezsPZR0ckxX3ltu1lJ09DdJw2UTJBnUZHr7JvJOW+PhbbBQ32dJStVz2ttcZSDd0Mx3kU4mHE2HQ2DLzlaYXdaVeJ83BblxYgLLsGkcAlg+cF4D+Xuig6zVM1fHk6lO5KTjPkXF0u6hfzFyHlV6a/0kc+TIZ516SefsriTdrsWZUtGrg+bE15RTwc2DXc3R3ZCFexrlOsnTNCo0DKYmddV0ghc+Mwd0sszqVpbojnH5VdZumcch1ybas8ROY5QkJtMaJ2xU+n576eaTIh46g6ASA2N6tIG8mIxARnIG4TzUsovrEJUvLbDXc4Q35v/elOHbwsSLUaHszaMBwMp6trNAyxLzcgConlOeMWegA044x55sUT50XuqAPYETN9V6kJPR4ZU0njoIPDeJyfB5mc6tx1ESLRLrmXMxGSxDLzX72eA1P5rqEnusBrLENFEFC9ton/KemNW32l8NE40o9DV7LlRgj152bteRRbKWBNMPMjIVbDUKBicpzpspBougQ+un2AVp61nsNm7NUj7ejqbqxUNZ9a5p+48fvnv4/Xff/6PqZP/wQVkv4TT3MWo3P4/4aLmSfw/k35/N7env5ufpKDw7F88XoaESLUkfWtZ+a6md1jJ7rKXIX0sRvpZDKFoWiWhp4tCSZLGlCWILSWHL3U2t6p5rSWrXUnSuRRSuRRumpXdsC+l0Cyh0i8hai4fF+6JlUfUW06JOPh2lc6/davvmkP4k5Y7KWbKJSkt4OzFdGzaOxKKreu3qTjAb5aBeub4H7G3ycwTCEIrOcEjL8pd9x8PrcjLXi2Snv4PDr/bi98beLPbnp8iVNK0YVezElXRAbkqlVztGnEWJdX0xmmBw6jdZMBf9yZheojISJF+KP3a8gqUbPxsBfoBsbPUAIibnlH2i2IY8OMvPS+uOzCPHTxRsdNE79HxDp+EARKfqB65R9xEmnWq/IrccRxUfT45J9zDSHF+049WAJQt+WtgZ3StOdxp9JJAAbQB8n0fCNBHqutnCH1n7impfNda28Ed5oVPtg8baDgKpKBx6ddXNxM+RdDeBfn6OpAGXTGpkOjW9NqCVNiKzugfKY/r9/cZ+G66Gfx0a715km+WiMaNcEf3RyJOYY11O3PQIuVAyZJAc8xw45pHBFrxV0OgZwngmHl77sdrQvqH5DU0BSxMZ1Y57IjnrOXDW//auSQ3+Ney389WRZsJHNzPh6tul3dpluC9yrY/QVVwhYmuva7uyV7dYd/dH49Iulw0FKptm+ErAYQiiSsd62zX2RCOcRT6fFXSPztf9eK8SFSHa2PG3KfgY3uFTa7KGLSxpQ1u/DzN74SQFEQ4gBUwOBJlhOgAxttT2DBo677WBDcEJl03RUoti/ErhYOWxrT8HLT5jai5PHi0+nOX2yyt6eaVeHjgvD+jlgXoJm1dddL0f+rj/rEczkGN9p2bmGKLzqldFAJHG19cejnlXZHjdRlb8H3LgDtCT6YnU+nnWu9cYpnaklO/i1wU60+LS/VmVj3+mHf5nLsW+P0nM+1sfsfbTn6jrkd3xPZwdF2P+Y6KQcL6DEq5UHZ7Nz0WEf3b2ztEcFX48IEuwpkw2HBIx9qLr65F2XtUBz9i8USt8MKWWfTuJPEgMeCEeAs7dCx+KbAYEci11MegqhhdkQS444Tr5m1Dm9Yg+HkQlE82ytLWnP5FZu9FsYlhz5ViQexd4cFkod5zTUeRg2nFOB46DYMc5HSsOXs0dtJob8I4AvKMfT7VZ3UjdQ0bh6ehsdN6NHGxLSG3sYhvHdwdKoXuhVJOnmLgPtrXdnJfW/5VstOuvtrehC7lfmag0mmvfvUsBc54BNh0lHNFQWqeZvD5WSGOFMDqWMvxBhMGbVp0G0MIaJ+ufP7y+/hmHq3WI2jtXNZuh2aAbjQsvTPZ1DkBSZc7QXct8z78Mhx1TTfcOCKod7K2gevgWWzA6deVIL53wHzyt8JLiODK+RoivEeArIAOdWBZj+PvQs3AP7XzWM7yje0kcQjKd47RSvEiErxDA7831ZU+bX8qa3npI94SzoO7+0WguNNSv83BY+qUgzusI+f2gPY6yIhq1BQuO+tl2mgH8WBvmz0MFo6QbaINqmZqMxAPb4mzk6WCBWzL2M0Z8NvNSOl/rpTy/MXb3qktrXcx0UjOrnvwJgMQKYs2OS2QbSkc+jBGhGdRhKYZpDifiCl8BOscJzrU0CGk+YqzK1CalnIh2KC9rRPV7czVGckG9Vc28iGPUWOE9xK0ayKt5qO4sw97urm8T/IjOJtNhY2StTcO9sfKGEd/Yxhp0I1bZ+yYn/TBdg36gHZBFIwy6EyblU2CjZ5P5BNcNPggSCXDWcb3Mjk/MZ1y9O0ezXHF72P1PX90bzRV+wkFI10qPh2hyTYy6viSFDeYMrcjGZE3kjE0VWotkWQLVxma04Q2g0NdD5Ipms644Ps0TiwdCShVmqPK64GyQcT7U0rKCeJrfDvQ1ZkQnsXVAj7PQCzDBayJb7uXbZeJJA/TgY6YlmJebzk4QOfnOcv4jHZ1acKBDtXuqqfJI7JILON+zkMQEs8QefxqGaQekvlwG8f8Jb4Dae//ZedD5oV3zFP0P8dNQ6Ld+91NnAFLwwZtDuv2uI5mKNPdSgi5XOCaTnHH/OsyURFb7iNH5Nn74wSdzGg7h3vmUrHBpOn+CEOm1RdsXquaDf/yANqzGnC5cEp8eKvzpraBhIC9alFaiJzPhmsu0hAH0fgFLVOBZgyY8wa6QwAoUGASPOpm9Bd4TJHTSWUiS25+M20Ki3oGUMpLZUTIIPgiKURSncIa9r/RYWqzBhw9HT/efnHw4ePr+5M2bV8cfnr9683j/1YcXb978/OEDxh5nWL/Ow5urkrvC6xzFQGmJsr39Gi1KptPJbJ4/Sy+SmY+3qlkUYrUMwey9X/hiOoeCkqKWr/Vdy10Vj4N2g1l5ZRBH15su+jz4T0NE4BCJwjZGuJn3kT/qY0o4rWeY64zSc2tnAa8Tzw8pBBkbaEA3u+TNVTFcsbKlsLdV5T22qRi12E3QLq3ymls8aa5P9s/2S65dNYSxmwzDphpo9BWSBcseCJKWxclIXTOyTcvvILIlRK4MLf4Ng5QJZH3gtABhUsACSWm/tkLS4eLmNZIHPazRDNdoiGsEHGhfZutTS6X5EmABb1iqiJZqdONSYVT6+lKNbloqtAisLdVo81INw1F1qUZfWKrLsKkGttM2Snb1i9B64SzoLgK+11PJxMSFtE/C1Zxrm5tXQ085JlUPWvhzN0f1RI4mOGSnqK1pqNfjkYcogvbuqL/UeqARWVaBjB55uPbiV/Kn34xBT3OFQZLW6QMBykhijcbmiEyWzOJGY3WpG8do2oZUpRuNwz1TVdpfb92OuGFsPe1Dc3PVDiU+PHjy1BG/iZzdsqGX+UzivNRfo9JoMkrYgA8KNNWDCQgzu/Dtwnkm0MyXIUJEvM3CeBTl+drWQElWYiTZwVzFX5S20sUFeuBcJKH7aBmulroQBqzDNDp9Uly/zFf2eMe6gS88P3xUGYHSP5kGp5n5BswcxEzFvebepk/ire2j3dL52rqs9AWHhxgtGSqANdC0n7feZus7SffOvHtn1HUhBScTMMxyWHdGYQZjN+ciZybB8297W2YpqUoIvI7zVhrivDMTHXJDfa+9SPP0Ih0BAyXvTEWKqUsYbrJVI1ff0BA5+ApAeCboHkPLm8zdXuRE/Rrc2HLTWQcMX8e99ToaRnPFVpW1vjP1dYCjaGqZeSniBgM3pYQSsAMi4MDUYJ+hwJz0YTsEElOoBHCEvqde6y8Ru6XcCe4k6ru6Gc5T/pQLlTHep7nqy+9W8Eypk3CsKW5Zv7RqV8KWqg9X53InCS5Hk4todIJPKm5nr2MWUYdpllGuAXH3Y1YoL0W2DK0sLYGXwVqHjyzREwscV4wAUNdxzchIVCWmeBGNrC5UkdWHrqU6MXWARkdyP60RwEvYR5gd0YxFGYdbqxJmZWWszp6+g1kNnddlZeiqo06lvKyOv6Ff531ZnY7bsz3PUswygn+0NKdJusQ2zmh3WYOcL0OHTPBJ0j5Iskk7zVpm9U1nOSp4jMhdLN0pSLWII5pkuBUsC9tlbNNk00Lmf9rezh6h9IXXIXv3rUuB07HzLUye1RlHMIAdL72+3vXvHeD1TTa5gu1qq5b+Gn39GF/Pv75NKsdHm5Tj5xRhGz0/RLIEKSeIBYVnxV1T5MEAA4D00xh1UX1BXis/J6tgjHF1RkkwLUMSNMfECsT0b8rOLS+iHKXPD7E3FmmH04vmvr420ldeW79kqgn0LMZWFXhb4MYlvxRJVhYg4aY5aj0XCWt5C1QwRlSAvqqL6+uCExPosoXpUjmdquR8Fj3Z3saej3FelCZken092N4ewK9UOm9YgLm+RmdYpJ/WGuYObBmcBUUgAkjGFiQHYlzMSaGAwOxLIPaZn9LA6liVzHUbzpihjXBrqgojhnf9jbBuaCPs2gpGMUKEZ85zwI4JKCBsOBOHRbYQ0Ut7ej0RC55l19dx5uPW1y1ggGZ7/XT85nWHvXLTwQq68lJRAA1dxTDVnq0jKPxODsyZ58PJ3S/ixPMAXaGmF58NzsMC/hGxL9alHxTWx2DWNDzM2AmokWrfZEs0h42szksH1nDopdoFN2twy01tpSpmGJ3NohWiKP6FSW5vuyWpNBQGRq2F/p7xj6m6a4h3dny5StlZfC5S+MesS9e4d9NOKBztScp3MvwqbsHAilv1pVedFNdL1adRhrlpOQ2NfUHILgrpDG0DtkXoXDz6flfHjE+7cmRhMSagwJ+Uw5UCmm15q5hK4U+qw7DqRv3Qi3tZYE83830JNTEO414auLAQ03Cs3i/gPZ42DP6pH6zLLgJ/ic62ciFW8Hv147S7wusr/ugxNANCd7Y6FydhdnZ8Lk7DFP7gmE9gsqf+egGP4YmAwa1+7AezJbsVZuIYnaOXOzs6Y1Ip27DFzKn+JVHoZEujkC473bJ19fihU9Mdj/AqhDU4Eaei2NkDMR/rXIkr6PyEvq7EnD6UTKEE/vQBigsrjFWu98UWHE4ufCX4YM+7oJXl7v2/xEjM7Joh2mVnxTkSTvjTgHPmYB5bVKC+cTL5rdAdgx6blXeSTmucxwy7NN9UQkNm2w7gEqZGb2niQMh9lRr0V4Omjgvg1LeKaqLadpq/VdXfDNq+BuRlMrdewLAMLHX/FpWa2cCobcP55JgIpMQxij1yxggiK9tB5uKl1Zd9hYa89iyzucJUEOdtbD5i3tdVtqLoMPiKWTQ6HkY4FofRaHhP2BU0vKDoCj2mIH6Qmo9PloaoKIJx1ul0oPBcCXzF9nYsEeBR0Ys7OSnF9/wgNv0MmvpJBfb0hX52xT3qCsnhYRweUwwMzzIdfOUweFsZH3XP8JzoyeS1IwntHoqaaaUwME2ur/VPTHQcU31ZDQ440tmSCoc0Fx8P0zwH4LVkk6D1zd11Zpit8puPPrB6qkdrm8e3YRMBNrAWW1tWw/5SoQIfVlt7YtB1EbSfDNIs0XshE+08vczw+nWdZOi7iko2tG/AaxCcnjfo9cIUNS0g9ZKFyaATXcCZnvR7hecHgwZJnt63RSHWE4xDBxTE9+HYL32RcdyENPRIqsdhZqQgyJdyoGtg6UDyQOXD2uh9ShLZ1BuATRamIOH79kIfxx5PHGCgSYO9l7wBsE3w1SwciDTsl363kIxS2JaWHTBomSGUxlda3cfewF9LKILMB1DzCpiUYBUObDeZb9QuwMqKrOsK4QB6jr215DPRkHyQjkaYHmhBrsIDBBR8rRSqj2oTLsUWsyTKJxk1SbkJQXi4DFNLWBsvFWjQsjgNd2GnTaHLKUI2Nj8H4XCpz3AsTXsZm1XCMTxgmBTwG6/wx7RucrdOgSOmvqf6ckxWj/nPVGsPFtTHAvvA/6llv0CoB1OJFItuCscxahAXIY5MuZal9+4JOPnG2r6rtSgFNUX7oxzb48ZY4Cf78stcgENW14MgGQE6vZ7MgXt9JmFEnwaoEKY9xh4b3sfy/bHsaEalAyhla+9nSYigFtNGRRs68v1v0bWhtr9Qv/e+rHmbYP5rrW/bXG0w4HrF7fRyulvdZ3M13e3/dVo7Nag3NFNLU0zdcekXdXJ1BVtBWwSPedSwyW6qGp2yFFeswp5aO/3SZiRYmZFm3l7y8NsH336biYfJd3YMDYuD8bJeT60YMSvyd+8KdYJqDEjLyUowriA4HXmOtj1TiN1+Qq4HQOCoUlsCcJYAKQcg9uQvqYVPR+jYDaX8yzYH+IuGi+QhxaMNmAURS2o1CJH6iz4h/cCIv4auj8N3vK22+uQVQs1eEPz+jL13fvfEewFkly6ocLiw0i9Q9TVlMgdf3C1BNFEPe6VY0sN+bKtMYXWzTpZQ7gt5A9eORlfRKgdJwYEksctxlB0VeK2xor5wPezWbp1jmkGfEDkmRBzoHNfvAFNA7GmqQFwJvT9lkmCdiO+Ipr2Afz1qtqRxvcOZEyjeou8Z9lTKBNpM0uWeEdSGQCYFHa4KMg2DaYC9SnadRLh3XQV3VPnswuFS4bW4YyL878IXPagwwNOfj+TPPhRW2K3PfmlKFCx4rMcq2ffn2lh4FM/DjB3Qez0vIV2SZDx6u8FDX/ymXh8kowjqXC7FL0rn8JvDjP0GnMBnP/hNvA+f052rlgqf2xrI4sfnzgsrLthz6oHkHBBZ3vvrE5iZsvgudnZoNZ5FKaIl1hQgMPxirQksnErrcwrTtBYr7WFXwZXnHrdTCfCBUnfZG0ews1AwFkqCZVZQIpUv31MGw2CqK/HzAt8eo39QsBLkJ8SNVzBEGAWOj4d3RTwhkZN3Y61I7/YTICGTlaLhpI1+HivpBwirPHufx5uqiGXMNPaSynyL5LqilLmRYxVbBFPCAwpZIbsDv2S/Qf6GJuj8NtQqY6vw+npXZDW02rsPiLW7+y2QY18q4K1hW6eEuWLPXEW/rKLOoESfXhaZXFhHADDgz1AJGkgNnRautFYReL6UFaVvuKCHpsxRj8sOJzPY1P10xi6ZQGCVhhHtn3udKUaiur4Gxm/c8OZtNIvG9Jp2/zRcU/0A+UT9Gp6QsCppfhlSOEMtHqxCrRU4Dq+Qvi29K8GCGosu9LBCEwckgSDumbm5M/OBAnLnV+KdeCFJlFLVtiqURXXfYRabNuc7ZSx4xTN3dSutKiW68jWl8SxmeZaE63iUovNU2uEfRlNvqdY1jIJ3Qi9B8KLXRqODq2jWbwdoeUy/0BA9CixdMTyqzd469maYHCVBeQnIWoShxFon3nNfrAFv39Ka/FJaOPEeaO9gGUy0UMbr9t6T8xa/iV98ew3VG34E6P6CKAkQG2xv9xWU1koNRrE39STEu/Cqt1wG8Vi8UBjStzsfl+Jz+M6LYcm6Uzn6U++F+CyuOByA7hi2nPpatz+Rxe/CBR0247Pd814v1keOhm48hr6ntMCLR7Dr3tlZiK0vTsU7Xyx2dmQm+MWPV1oKmcJkNeynaLcBPN2sJ/HvGcu4TTU63lLcChka1lcwggYKU0H48wPzyaVtxRqjFkQCN7WBW5S+RRRUVGmFOarkEZ7VlyAzAb+nAeelpKgWBf6BTzMtsq7kljd8c8MH3sokpPojAKEUHQvwM7v2ZyiYQ1XOejc2Bg13su6dtHsn6t4punfybiNvqggpxSDknwUc+9KZV9INodl8fgYmTV2PyVv1i5xIfI4SMPdCGkxeTS6Q1+t3UpzlO1xVCkikRqDWOczMkpsXqC4KLdWRFkEGY886v9R4iA5Dffrbk/fuisG2js8ShtJCTKoIFw4RwTr0YbTArEsh+FoyE9WXeArwm9IGnolbKL8TrkFCl8AnlR8wn73OB+SUbGmJS+QUnQPZGbQ8sy1IbG+b3yp9tDw3teBcBSRsZ5lCWp/FCgvYun+C5wxlVeY/HNWrv49urWp10Aylylmst1yUMcbEZozWvSXdjPYp4aLEIylEK4ukZH4AX7fuMVtFeBF7lRkLZf+hpucs1MxbkztTIRNgaZN6Z2JB2pNghwfBXhpYxr+A7S9KDR0t80LXsk/5qq0yvJTI1iMbiZXVMlQRp1tFKN1EpEBdiY/M54rRh19B9Qz2S8NQ0p7v6Gdhhanz1ixvkrKSkJq80aUnh4vXqNJDj1k5x0p/upWUctUzivLqGtzdbRoV8glyl+EjtBPQ+JxkMrY0rjeHOS6NGXGlLxj4G9UdxTjzfKCsW9KyR30+cDaMrdFWwUZBpEUcxP5L67fzLYNdjCIcU816RQZ81rtHuyXd18/T+FbD3gAdtLzQgyezBtzwDB+OJo1faPsBWiQo+4C//zk6lp6wmeZRksOxAAKM7FdCsoGyKIWVdP59yVFicO+oMT1esTCBVx7NYDWx3LZ2g8yaWm9rL9jUPdbdOh17jUuEGzezTb8qc0Z/ArxyAo5iOClGfVr4N9kvpL1TlmO9jswI7K2VHEiPCG9F+3CnSqnQw+9pHdbtPnikMgj/rc9F/b5aXk2IzCfTLB4VsGfhldIImpek61bWmDW5UlHgjDTDirKp1vv9PmrkaTvR8gj1BokdsopIt283MnXcGM6iBjiMgEegQ61ilRGRp4pWeUb6IM0lZt6ZgEAqX2oayJo4ooGB+45ke0+dbTYT8SWw8MF3E2AatuMGAimvkXHsDXumcmxS4KR+2zlZc/NWKgbLVO8dhZ4Nu0oBTR9nphU675FU2WIsVTf0zQPbqp7ngLo8KLJhMjc71T4q3Eja6zi7wd90lrlhn++YNFRqeW3ljcbxSHNubFJT44B9sdV0iBirr6aNvgwfLWstuot69wvNqJT2Rd4+3iuirhEdRZOZiEO8TGq+2Vx8+WZTs/67opAiFF0TDeybrQVqFOyp4nXGypHnj7U4L1l8I8A5vD3LcFTkiOexd+yLY7rMdBbHCCV1ubG5GGbNYgNISd6KL+mcuaxtfQgIZbIHhyHYNPrKHJmNMxgqpNwZDKyJLUBgpolpNp4s/QYogyft3mLp8u4kK1bYk4tkGIFIOIODQCqzvDHPUV+YWYPwvsBGO+cyq7gS2ljjBiWYrzap3vZURypbmlqYEyn8a+ytK9rtmsGBGGSqm2eZUFcPAWL1oqWic08GrT/j7e2FvCcxu4XZTCM8jRxLUJ4vsuV8X49KTdYhB4AmS636tCYH75BQ499ixjxbsBDExgXLktrTjUDgNbTmOHVc64mtNq5WVBSH6pKCHWCgVpuehVG7V19RobDuSKwKVqngWxP6PlFBvFFQm4D1OYrQkYqaLVMXVd9Iy5jj7toVx8sWUuRWmreKjElP/6O7f5VotjAnJIxgkF6SXwhJWL0OLIWNy3aVZD6H5dBVbKZatlnIi5EFHRYVjOEyeY1WpfpKvKIqjFdNB04VHotuM6daltKX1F1p6WbNCLQoGyZJwL0dFKoS7UIDQRkH1PUb5Z2ZLV3ivaoMAZXZGR4l4svwSLihCknN7J2QdeyN4VQ4YiuMjFdHpguWgRtrnar9KfkTWVvvis0N6IF4Fm7CxMipD//8NvYKhpCrGdLEDoZJGv6eldpYSfyBUgXq7iZ4J8kqhIqeQ1REv6CoCYN72MNWJlUE29vrOoFqAPRuI2zZN9zB41Gouu7FUi2IN8I4IUa9QN0Oy5Xp2rBitByIqpgK86gWwUSsItSFWF4B9QkUnVoZ9NAwqUEDxVYXcmr3OHwosnRdO+4oBZO0Z7Whula/VNFFaglLgK2lO0yt7eaLZ0nnwhhFVTk722gBdizpNhlQdBlclQ4sMYCphNQptVXsnIwuKq0Lrd8cC771VyCMA97HqLGzb9d7Zkfp3UggCQ2fvWYsYf9ftXGV6GDp1yfuGEmdltX0Zz0LbayOK2u2p7+ktqX1ocHY0ZdJ20DFVxwQ0bbtBO03HpobWs+iCFMtUwA3XfSa+tMT2NyxrlL9gpn8btcGTVojHrsVYBW92AYXvLcAVtuyu9X9uSu+Gk2YNt60HkXPEEpjWdLAdNHlxEWDEZhzCYH44lxDKH14KiomQndyK2vDnYKNXegBTY4OKfRUbikglRyV+iVdh8gl7gLFVL8y/cu6I7kz6d6Zde+Mu3cG3Tv97p24e2eqvGS7zqfWUrtJkw/tB9tftsGWy3VbxaB/WjVBKbe0foRP9/7Yc64v1HEmVRdDpcxkIqL0cxJQK/ZzvIVlmKvILTcpo5TS4WncOCpXWJPwsDqodOyo1f5e11YXtqL7Jj/nOxcaPsfGmK1T0U7p2wXr2sZR0qtxFVJnzwFsbCxW/oTyMo3OBUt/4ExIKrktBYftRlqrZfmA3Vyvni7x9dxrqqvg4Fud14SA9tPllLQzLdmqNZ+0LpJW1JJNWpMZPKi4ADCoaC4jGOamUls61t750wK/BWmXbUs7HyQAMWbI1ue8qoxQMmpSvVjcoI+TnzlVp645jxHntGYOC0pfewE17JouCKVDsz0LsWFgQ8+5s9N7NDYbH9a9uL7+8tKEr9Euo1p+ff3XyG3p3grItlApbXijx3np+dqakEsWPMl/1yjRNZGfYv3RJRptX/Lig0Q2T2MJn9qlWH19LwpMqS3fisxXe1GGhuF+0F1Ecc1/srpFFMYkKgsLrbGo05s76uNzxQoWZf02xL0dy8r5DJBfTcNyZJCWh0t0O1iTFq4QsVbEUStU33kxXu518E9MmlqWVdtqzKYiZSlrV1WRCWzSWYpe5dHow1SSypdAhTFQkIJ70aAKNu9qvhztGzttDQDgwFK0BqPoElUCmJxNrn/b931xlAxG0sOI5owMrpmGviydyugRFoxpwZsuIOWVCF2Xh+vSrWG/9EutpmIEa0KtDTS6uCXupRrFpOjjWSaIdZwEKcWXw6oG0rgjhy3cu57M1Xb3eqjUMZ/wNvMAGXzpztDAGIhul02IFWbzOBzkN1sm63XoFHjDqgoMsxqm5roZMLVEwrHWR2tXRVP4Mk2isGq2gaC6ZcjU/eL19Rb5tbt2q0Wo7vfuZLXLvZ09eboMNho72v03AA7NiEqkfcoyvOmI1TuAwwQ4osFNFRUtrSh5K7V8WOC98s7SLJthVWJgireqcPsSUUar3D2GpqLACtiUxdQQxL4Emx4wwc27abQvs8ewlS9nIGT0r69dg3D7FCzVDBAlNZuG6CPUYaDPHahxoWsMvmwSOlB9DLRJKMJMdtBv6qCyJH3VQ1/34Gxax8JEHfqxe2iogxMNRdU9wVgZdYhFiNEXip5UOQRKay1DZy7RrZ5NPpEELEtxjBcgJ2QV3fkwqZyQuRrNPGniTsRJEm6h7U+fJXNxCNUS4lgy4FVSoMJd7wR2wCEZCPM3V1KBtlQaL30NRYxYbQh0rYBxTEGe4xPEWznXDiwP+iVMS2oSTquS6pWSLt+V4ap7Eq7o2wSIFyH7tKcdChM6nIzgsDlwLwJPLGXFO/s0Y0+JedId9AAh37odYKSASp/QdtyrFvY8gO6AofECTWwDhLZ0pK/3YBGB2luJZuOercSVZWi2WKku3Mhe70JjlXSCBk4wK+4BTx/ElF1KNgUcHuVRAcBYzbde+GyLekJO1dSSgiVQ3VCxZRP/RP6a8bUp3hrIV6GqDZ9XP70T3x3MCY5F9hCeOLK7VNXPVUBLKJ4npfwJEzyVX86FHoO4Ci19EUCANYLqiPkcrqpXXFqfJZ6HDjKI3/hZ6hR/CZ9vb38W70MDJPFzqNwL3zkqDecjAjBJxsB+Dr/lNUZAfev1SXM6K4PfSJFCuqBXnEY1+AWKzG9SBZ1UFD+rypG2cds411SruspVVZCKHrcGF9YVSaua7ldo+ydUbRlbKFO+j2mDDmXzil7r0aJacn1d/wbU2vRZhNRngURmoB8BoZ8bQEpgQ+F7XB7SYwarDdYQokYKgmPTu+4KeyIOIbhiZ2Qhz7zAVvUI5UWijDdh5fngDRwRKGO7tbKqIriZVbfo+8+u+QPS9p8tBp6xGgAAxADI/EECJ/ZJ0jvQtvw/M3iRem1vHxjjQO4XDub9xL7zP5Anyp0CPiPnSDq37mHiHSRQ/4Ou0ZUXSh+UcYm8U1I7L7DMdmGshXna3obePiROKhjjCxzQ4aQnDt2TczCGJ8E4OlYbbTISeFvcJpFhFD8kynnBbqXj6f1cuoyfGwgMDvG0QZ5s1Mbpha0KjDfKls0WtncUwzD3xeec5FfF/iqR1bh3Kz+zrawSFWLNyo832RMKCYdyVh7EZY1d4aMsdo4uWOggJueFUAYzAs4Ro+goKQ01l9UoFH2lZUOHeVnRvQ9WSG/LE+gdgTKfIu3CjpuhBBg2RRxbztZj9x4sO5ti9A78gx0CI8Q+2hJgT7211gUGBTmCoRhUWe4vyXi1Bc/0uTl35RONA13tKWt8m228cDk2QIC016iHFFldM4yKXeeqab1RBqx+RiuHyzsYeXLtXm9lRnEqq26KgKfWR19ybVbBuUq2qoEc8ZJKIVe9/Tp2g6E1EVZ0E224tMc4PVnN8E0RS+qIbCreZHR4iYzFJMtBuO8GR5NDQZ/PRqO0pxyKJDX6aerY6vCpDlYCCNQ8lYqajsq1Ta92VVFBTtyQJrCaQaGtjxzn21h2L8+zhlRGQxkgCSQD7RONMXFIuoytgTKUAF23CpkTPE8MTIzvsdzT8pt2gKur+FZr6loK1wBjdfingySoJW4yW8Z5483U0y+4zdzoKpNUPFtUVLKXfbyN1w/WjleFtCm0c4RylXGcVa6vn48b7rGqDjeOkYd7SeFcQWS3dBa5lSNMk1nxnXmTPfGd+a0MiUc3GxIrsH2FLfGdudFqNRoI24D7wii03e7mcdSdW9TXXfPjTba3vdqYlE7AcXIhI1Bp3F3RNdt23/LYTZZJXCj3C/4y4HyKmytXlrqqTuZGzpDDTxst1YqKlWdimXM6Xnl2qED3rXlRal4GzQQHbKjWWPlZ1ttQTjQl2Bh+qP160jKVWwPUTrV9X9sA9sW4YcqNNoB9KYSNN9gApjfaAKbSyK+iQiO/+13b1K9eoW7t59TZYPCncEhGTuCrzMpV1iaUBJ5wSxuhST92j80HKWBmKrOL12au2mtcCzIjjQ1Ks9u0adthwc5ptjli7bXHJAxWWo5/bJsvKkCo2r0OIUV3rG6aeJoyvLnifOqDlhWC8cbhl4rLtUebaeNJuSs3TMVYO/aVI5ozMDVF0Ti1L7Z2Gzof1taDfWEMKCpE4esHVO1106gq4Hbc6oI+4ERfaoX6QDkBwzbMQhlJ3gw82dXYX1dIwhjvmpzOzVS+0PVX9doMeBUq5GtB/7em0/DZvzMvy5Y1bbRl5bVz7E7xzCoy9I+WBKe8k/5t29ObzE6/zuJU72FtCdhsaGrq7anOlALDrqYIRaZBSbgsoT2oWFvVDa0cozf5ReRz+WfNws4mRxbWcDryqiFm1ZTVHjfb47GS9qtGWbXEawCUNDWtfU4CRXYmDbcHFTvRiolo0+Lan6wYh36t1SbyhRVzzUPJIkgx2pJmDWPocoC3tNpERl6ZQyq0UbuyDh6ywvviwjRBgo1mDaLIfm0c2WWDvNdfMsije3RH2mGCpvUXScWUaq5zditpZ7fUghSrT6S4q5gP4s+WnmIlM+EIRwxgI0QFOzvqkkx56ujgBGrhjK2fdMkpSlfvhPoltKUo2bhAx3nnR62zeYISjBVgOSWxe8ZXSZV7PzJkSP1u0SuUpBNYlkxeKs6yc59vN1zpIqrINog2kte3AgOQRYLMjGFI6N8ZIUXfxn9UnIk9sxaFTiKNYNjiNNKFytoViz2OLtLCwBMhhW5TncvBoX9/wxxnNclJOrsjN5p91Xziynx67Eg3ULGamlhYtfpb8fU16kAyO5MuRpCWx1RtJG68UWs0vcZBxOGjmCPaxdplkren74plNZf8Bid9EpG9qi5OIYMiWpkquw3AjVZOdq6N7eQzO/rvj0ZeJabxAIanQy2VNFkLWDJQ/NYux6roVjzLqT+GENDYfEyRl7gXfJXVjHWsNkpkl4Hp/VLOM2sEzI2ZOXBb5cU44RVRxKKW7qPh42l1NdUMnSGoJaToxp1xNOWmZt0tIxnfORgI2bRNmLGPiSdTEGDSPtHqJ5tS1RhT5qqmiuhsVVeV1nRJxWbLaeDY/LLBzJnQSt748G/LyFmYOA+h+elkDbINsl1CJSffvclwtoHQV61AC23NQiroQ/tMubUhqHPK05Hj2oKKomerMCQ1bFBuAEHIgODWUxxsaiCtuWVUDfX93kYKpwjxaKPZ7FdYfeOHGo2fa7xR6mupX7t54hnwJYvISryQTR/UPITkYOrf8kvGPs8J912x4/xS7zfgyQ0XPponrJvoqzdKqYZOB6lXtTFUy9nrIWOob4aQX04tq4K0SSdjDA3s1zV7g7R2y4EGCMAeOm+YX2RABtaWFrRIgcFEkOBweSuUV7Moih1wkarOiBixxRzmSnga/Ht1imj/0iPh0oYP6xaUxsbWp8gARiDWYSRGrYqoSMR9FritDrSULTsg3vzLvei0JtYg5R1JZYhKLZFKCejvj1Dupdt3JDm6TUF51YUnSzvi5N8kUoAIgYJDo8hgBYEbhFaynl7vQwxcasr2rH19nnsDfWL3r689vphe2TKHdvCPhe4tGNRFDMcc2MgXwg2PFmT62vOAX+Qgbyj2C6WMvo/qkor4gbfUlm2EuTyhXFX2K6FuLxpFCb5atW5FNpjOOx/rphSp14RuSm0G30gfVhNxE+NpDeIGTtYwW1V+Vo+8lFxpzS5azqHGsp5p939pIQJE6Pxf41nTZp7VgalqVEkv4Ob7eLSLERCZseR+UTuNCST+Fb7WRC+6LYgzJ12eE4/oa7pQjeTef2Oyv90i4iCB0Nyg/qWtBK6vcX+eLI1Kwb0w5fevl9aFqrv/rq/XpQ4y6Ooj0spzFO6WYxlhR5bs7Oh3ti9eEe7HJgemx9dmBAjMJ2t0rxuEDC16WQkHTazFq/zf07WOpY37TuYzNzO7d8+amTEvKqwY7oXLa+XWq1xbQRtDvIZNqbYIOy1scNfX+W/SnCfS0NW80lXN2Vh3ooktxf2r7Msmyr3W9D6rqIYkVbEOFd82zy0T4KFnydd9zXEbMRwlpVQMC6t3205Dxjkzzivs95L5gYcBD5Uz7ssBGUSgluZG24hC290ro0LuD8ZQVT3EHIAK36dJrma5YZWBsqGka0E0NUo3K7pyYU/wLBXxOafptCDpnvG3WzttVc/LFm9YNjEOiyWQ8D4pl8ZblcRJrYbFiX0drmQsWIctAzxSPMS8AiAn35BDQr8MsRghdRZrudzAJKZuz82CrHQwyf8WHJf8gpxZA9vQrbD+9qxstjBV53ehNS8aYE1+E0mjRgVE5ob+Y0QrJRqXWtugkRo/oRgizp8oQ6hiXjYZYdMainWSF6GJ94ZtUkwFc5vFJO2jDDUGO02D21YJxX5DcE4r1lp1SI0Aafi+e31hx27DpDk6VuCJFC+yCaawqCsgG6FIdJdA6XbU62UssPR6CsYSvJUeavAl4db1ZEsbPNluAXfJRQHotwYdO/wnJnfsmLCaeoXWLBQMpHubPOcKy1Gv4tfWD/u2X9vgxqB6DSDtl1/EhNIh7bc5SrrSRNGyaVRWi2iD163sK/e8MVP90mHRU26LaV03bR9ZfumeJ1mD26V604T/0r6dw59Vu5ABjq34aKLea2ms5Jt7MU2qNZoGxAe8U7NyBG4aVZ012KhwVumH7PQnPRN3oLFRwxVBxba2IRB1RZ9V1cJBjQOHc65WGVE4ZbeKnQXIHOFKziXiro1h8EzGGx5hnUZVYTktzUxMN2YzGHkuM/IcGp+VJia7fTZQrtRYqwiAHlRzlMUVecFXoZoVuJpmk9qzsbVPmyZU76xpTunXzMnWEN92Ws1khBKeWoEUTN5TLSCbKHoESyDnQhXVl8uAmw5bYXomOws9Kc1yoN+w5QfyIbZTKKfoRtwQLqRK9GrvnXBGW8baGbuzqbvbkfNmayvVNsy+2HJ6JCdBFVqSmtqZjVTermfpLJ/TJ+1A0lRdmjQjpRZpueE6wyI1PWuJellgr4nCBVqVrOkiohEHHRxqWiutrvnSXWEpPo3D19IX5gnrb+Wp6otXOSVtU8j0ugPUTNX5NOa0uWk9nMjrSYspGinmWrDjRAta2oUqhzxGGoH3LeRn9MEGHNvRMrQUeyBvjvqzJMNNCfwTjePpYICqTWKoAB4sIwvp6qCFZtg8eGUvJp0/8yWMuaM+LNaclNDt3Be/1qGBy7zPWbEcCPwKEPjV9GhuAj+YtIQ63eyaAH5EungeJCXbmjkFwDKR260syti+43BZGxJ+wRefGwZ1uPTFFZSzLGPLbj070qJJJV3ZUZbbAebrdN6Ks8KJ1licY1gsu0YXHTMs14EbHeGur2NyjunIOVOCr8zx3cAt5pfiYEl4WF33tfT4lq1Lts4Qp4g7M7qyCqQrJNV4jKbC0QyFW3vMQSHVnLFQQ0fLUIyQLm9ggBe1BglP2mEyzS63t5EoDNCvoRIpHeBXwJAksDDttXixVFkQDZwMX40CiB0OfRDoHEyDXm8veSgwwxJw1Zlh87rW72a3L8wTibnJCy/mn35QoOGMRgCZ78nK6OVltSRQqkRwkqdSvGIkk3CSPpvs3WFAs5WKx6ZeT8+Zc9zLyzExHht8tVIb6bAedurSwl1yK5HpX8uq9mEfc1YOws9LSqH3CpVsY5AGNrDk/U4DI6UOTSDhDi5fJHCwM5vY63hjxbFPw37NVwql9LGtcF/g+ik9YZ+8YrrjJi/8uOd44QeLXttUaiuLsBdL+D7seW8sBmLqw2bBmxKVdWrrFgM6W50zbWe9hMqml5ItPTqhrzpNoXzG6CWOadMXeBBwB6ssfrqEYzWLRscw7ISLn8jIUd6pJkhX4UlvZalLlaBI+U69U0DSfK7Og1XV/e0KtvpKnJwzvV81uQRteuGLGhVZ2dftYyQjY7GCvh8jSI99eb6Nx/C0EgM69U6XmsIcN1GYgUthxi4ZZXIzNeRmrDdGqT537ET2bH0VckbouK1xE6Ygxl+KHrTlBB7Z3n619I4FkedlT888mOoEJmgKpaStjjQYVtCshF3Brzd4rfaOg1XHDql0bPl9jVLHkYy29sXSqvBu6SqgaHOfxY14/GQJdDgDKb628rEb+x1WPhZwhOhwWbfC6XH4KN6IyLh/YFiMj/EmRI0bEbUfuh+CVZjyqKW5BD5bQi99iPx71RkPJ1Dc6J8rzmSdc4Vvg1ok2YEyJujLH/t4oxEM5NfL8v633/4/rW9bwJjE83uzSQEgai1+6Dzs7EIxvnkyma5m6eVw3oLzpnWUjNNl63gymF9Fs6T1Mos7st4J8KStfFLM4qQVAw+OoabQVhH2Q5/CUAObOExahy9PVDG7/bRSlHAT7OLVyydPXx8/7Yz7rUE6SuSb1mwymbc4x9xktmrJaD/qW/NZkqgx/L+qZ/gKPN+XHvNxeP+fXi84i+59Psd/du/9507n3vm3wfXZH3/cP18/KP37qXg/hmqq4L4V4dX1cU13kLuhgCDefah9Kdr32/7/BDhOx2F7CpueQvVaWX3HtobH5IOfkEwMjEi2JRMTtqfAiGTRGAMEtzJ4zoEJiIfqaQjnmH5DH5EPn5IV/TSU4/lSXlrqktRD4wmtWGRzHmB68k9JH/O88YeDseBvAuHGzwWLMuwDC9sZTWISxRSpfh177faN7ZQScntbaSOLHLhhNhavvoIZXF+3Jc/SFv2e6dl8u6PK1LesV1zC37aK8bkM1GWgBkbBwHDXZGDZzA6COWVklzV+xRuZQvoLWf28SnTGkExGqcK/WE8ni80etYt6NMrU6mUw0r1socZK8reAU5NRwrnD5UMHNkOGltFsMVPrksjk2g5t/GnpuRmlZ1HWn4wxxNvkmCbrPfyHT5Sdnx6IPRtSCzc0MixhoKIAfEKFEq6cSPtLEDcYlzBAOfywFrC+bu5yuatkjf219Dfn8LnWipm+re2kli5r+CCgKn2qTfcrDTbSCRqLmftAmluK/DshZnx9jXCkGYLUZEYISGLNMwRCp76YhuqbBfyyUiZCp6js6ZEAsoPh6IbRbH/u7VL6q167lwbw707qi2J7u8Cqd2TVwq16p90rAvh3p/CFtfOTyOTZXpNVmXzE1IjKZB6a+d3iUcg6pSEnKDQ4gD06BXj3wJZKsd1LD8MZqV544k6zuN5PDEd+RvUV3MJMb7PUTOPXpfLnJzKGIbg4DX0wCPuTuEAeT8k479PkSiy+fzIZQ58BaqzKMAYhaNAB8o2kXEzD9ts3b9solCAqLcOV53eXKh+stwx3gX2Th5ZJzDGWiI7ovSxFu+1bgpiJ6+iNlfv7GmtSvCi/Az/NbDCYmxyDjA2GZkufwxc8BArmH7y4t4QxvRALkDW8tfTMmQpFy4J3mqyJfjKaR8Hn0tqoJ5hTlb9yevyCP/M8hLPnhd97EcBWsppjzS4BYWePKv4Wwj5/Lpa++CV8J7UwL4B99p7TZr6+fs4kZ0wOIwyh3wAgmCmWWbP3RL7e21ksDt4cPl3GCTFm29vvO7TYgLh44/BkNMkSmVGe6dj77sCQbaknht77AIzbwmPPhsephsfR07ev9p88vS1Ibg2QCsZIeIivGfGuPeIrGJciE+9BvBUvKPRc612I8VZb3J2h5tMSkyG1VI/mRQZNUcBjSyzsFBOF1BWY+y25PVqTbLSC/nGtclRStviitKVsuYzScoAmgU8XsPmU2aw3JdlrAYhL0sZAXqc3V+JEEMICqJlzylOWL0+PXgVXIsmQxXul5viCSdnnEGHVrR4Gn2t0/7Nz0Hzmg6YUiMTBiZDLF5yKy4k1jnGHHk2+indmjd5LsoQquzXTw/ZwPp8G9+/jQoyGk3ze7iKBQ0Kp8XkCjG2K8UjaCACMv195FVglQwCLL16hExMqmZnsmdcet7imaq1oEaUjVNSjjpkh1wLQtRmNdYwk+6xLkbcxF6yDcGD49dbd+6L9vx7sYlxeOH/eg0yf5GiUitq/MN6BgSEGwRdgseL/n3P1etV/lsFl8IDXaPKzOqGcoDu61OK2fxt53maew9dYCxwwMBAFSbd9mcZa7Wn4TSs6Dn/HwKpwotFJtgjzldeHYwk2NL6Gk6w7Vefa8sexyqe9s7P0p2G68sZny3OxwPiZmozosVPPknX4fWw5lf20ROMr63T+nQCCUWAK/CdGZmeg90Q/RKEdxjbgTFUyLOgsGUVIVt7CdIOlsRsbEwBg8u1gKdAp9xgWIcWawbjjPCOLvauvMl4iGwLkldAuGBOvs+rYX+EIB/kv6Xzo4dIxi725DqqCFiocFmzAm2qKj/sXwJLDp1s0gBZOotW+u3YblW0guflcI7GqFcMLGWuarGA6rf2sFakeaYp2v+MC5Ev6PtADeEbEjifjC8xcxTUAuaLRqJUCBZ9GqGLh5nnnoy/cIYXVabHHpEr0TmfOcfjX3DuLKy3PURdaoBE80CJvhUegWoztbfNbGxAD0QAojplh3KLF+0irJsfG00IYDKOFM+u803o7SiLyfMTThOZmv2+hm18F8sdlG2cLyGnGAnvxRByLBbCdWx5jmtoeW3JgPl2VoiMsHSXBscjjyQz4+ZV3LFQdxrKc0oycsFXcqbiS2H32Trw4D++OvVMHXOLURV9xBQh8otO0+5Ya6hRkCwxHOQveCVjXKYYVkPnWX5RklqZjWuvLdqUpQzNBOTHYh9fXW/zUM8GNkFH3+1SfQ6MqcrFAtPlpLNvLOgKWaUFBc82G/2lsSENGTrZz2lEc4JZnRKGYpdXkORKrM8pRFJ+HKRCHooMuFrwVYTiiHxaWmqiH5w53F9e7aw16Z314fx6c9c8lGYQRxZ0/J2nGOxsIInxU0TReTmLhcakW4aMFAafXhx7E4txq6IvB9rbVAHpSbbIq/QDqwN3AQ7CwqP5PRDmhPqbq89QdD6ERBk3mXz359558E0QrtAzQeEWfRb/cjkPhMAfglyv5KsjLs2V4/5/B2R9X9853AKh3rcf74pdl+LDzvXgJf8TbZfhAJKtwT8zhn10xWoX3HojlGO/uKF/xt5YOK1uxGkAJkgYDyC5YnjLaZItiHi7HyDXEO+FoBbjEP9/SdCx7uOUYmAsfMKFfACIAU9EH0A12vGdL5jz6fu/lMrhrnn5ZgoRHi4DeVNBzbK1DtHJzFhhUSnWq9kwSvF0BW7CDNpQrT+YyKLAi6pd72Vmm9+n5vfQsNU/AtBul2splB9cWlYjLMEMfsRJQHc/1MSKo2nhTPKd/jM3xPGXYLsL4bHoOAioc4mGsPypWIU36PnJvqZxAX1Fs5huOQyZfC5cGuQfqokKRYE+iaHsSLjqSAGGuSpcC9Z6NgRCuhK4iqjX84CDHKr44hY4IBmQ1cQI8yPY28JFb8ZmZzDnXYMIKaHESQuu/OXa0LlghbT9xOCXX3GggToC+0UDFWFN6Iq4DoaUHPPD6VJML4LBTPx/Dd4N45VWrYDleeDMHcgok0323RUsGU+yHG5oqzcfY4NSBMuWqqbZQDSQXOavAZmuPwYHZMpnyxnQgsSpMZBXQZUiMjWu4iZ1ohmEVrlmUSHn9PWZQtwY13rQfDs52z+n2WVN2r+P/cR8JUfvuXhup9EBi754lv9FSxJoILASXvcZlWYo0fyMj1QWrEsRIPvKWTKJ4cKfh9Oz4HFnILn5dbXC1Q+6dqq2yaWCSeJ5QP/pGFhD3pLcAjlmyqgH99k7wQ1ZX/+vBM3nLIRalWJcGcYK+i0NjfMRLtiCztIh3mZuGNSwwEvoa9cE8OzjMM+vchOmSvYkuuA8l4uORwwVlwFlepcAuXZB0g+7hrShvpSB8APOYgNiFlayz91sEAvYEDS+SGI05ib/84+O3f3xsoc4RhDtgXYlZYyM1YCBGo8lVK4JK96GSFLXk5Dqtk0kLdRMzAJqSt1BzjWYQrSnzdTFdkkoJTY8fhNjNowPmTuogQeYYhO1/tp2rpvvf/vGt5CNM6T+h+D6tjSk7++OPzrc7/7y7Lq89/+yP83NcvT/+uLttV/rjfuDxsel7f/T8HtRBSwGUa1iqIVbFIiav+RrEwtYp3+sgPvhSCFoqHe5iR5NvC91WTDGAv/DO/ok3bd/67aDt9QLz7PfakmjoSjs+OhhZI1cD/6Pn9cI/7l/fvf7D83GS2NPdPb9nFDoV9Oo1zAnKYQqDnVAjJf2ANem1vc63/l0eIozE6+z41/AXy/yg6EGbNj5CDVJ+8F2XpIn4EprhCHGIvt9GK7bkqnWUXD5dToFqpz257dop8hnnZsfkZDSNGkk9DcORqHgW/QQVCadHL1ExPMlAJAIp1gYTQgT2LjCBhh+U6kzDQ8BmhI358WRIehVnl8WYDo7EF9hq/LW+3kGw2VLMGzaORnDoj+ENNs+TS9Jcs74D/j+dTS6ii9Gq1S9IhRO1LiKQ6JJZjCIcqb9g17S8u+sU1vgjqvoNGH4zF0cpswfGNpYukzrzyasJ7PknsOM832ZnU/eVXyPmRZjahAZQw/BAgfpJVloSo413Qry9HUtEJsW24rgK5lSsazhin1irgtyTJpuFvuHT1ygDvEYJG+576MIDLZs1+9nzivDlGG8xiioH3+uHK0yxa64k9nyiDgG/QHNe+JlaN5t9NZbJCsV+HswKLwfNRFZjmz1+O0ZEs4SkJiR1XIRw8Tqdds8EOkIvuelkisHMEZSdNhXgziTvbFMR2mjkDRzoDuNqSOSPT6IM0VUKhoBs3yAmf2MReaDkiLPoPQZImU+TOB2kgLx/fJxPOoiEQO2hAPD+7O76p+M3rzsMR3QIhpGdd1pKcM8TJCFz2glA4BG5+VShjgrdUae1PyK7E+RM4JurSQEDWOHOQItTajQANOKtF+Ge4k/iWH98lWafoGsEX6f9qBVlfXOkzPgchGHwfgSQY++djwZGFR8OKZhICQ7Fgd3raymjESeFqgLzpBUdFt/0iyUpU+/GoJy0BixpxJZIcg/X0KC+wyLaFxp6ObWibdBt2A+DkHcEiFecQwxVyVsDS8toPbg6AgEoA3+FsUMQygRBgJR2y47uyI7uuB2R9YLVDXdMnfDPDV2oAZgOVMyETCo8zCioABlQktBNuZiSM6XUqU7DgsPAsqbLrAMRzRhVWTbZAMTyGd4n4dje1ijMdU84vhjW6vonnXyYDtDg6fheuNe1BnZiHTHT8PhRuNtLgdWkHcuCHxBDsmxchmMYgTomxy4RBkHQ619fj+mLqJAo3PcqetjCLIzzHi87r6/RI8MzVXZCybsyE/ySVQAOv4Q2P6JUXO5fc6zxEsULSwPzttpO8td4dq/w1VvcGk2smZjQ+61MshY9+F87cNUvgJ69jK7GQap2q99pqH6Hq9+B6l00L++vpL8/u2Z3+c9Jspx3yduX7cLSjC3gupVUv3rX6RCNhY5PbnrCaPNt6WGlegpjUdiXohwchZ0jyZK6MIYY0m+BhhIW0s+RK9khuIYOzVK2Q9rQWQXLMYbOlVc0VEuotNJGyzHbKVo5TaNrXTS2R/DX3A4hZoijdnt8zH358vRnJEum6izn+y02c3GKOuqe/6Z38m726ShRNQ2g5lNHaWUF6yu29PyBAB3ErF0qFBe0Vop4vIRUQWhyZR6JwvZ8EhSlFEYKuvqgfIBTH3lSSZ3kPVn1/o6v98ahulsrAFFl1d/QhrOP1qfzSTwZASui3hQoOgO7NzYkLUX1O98FYl4s+XN7W0odvSKc7owlaYUfkona2pVGQpKvNQconslwgmJ0oyjFlLdwZLGHLnGuV8M0HsoDVfGslJEG3iTA8wGH9wn4hHtKvmP7WGZpTS90an5Uag8HzrEN4gFD+H+C1eBoGp613745PoEj7u0p/bt/8uQF/D14+urpydP2eVclpxlNtQn+Cho9f3pC9kyjqakyXakql1glugCYBdDXxWhygX/j4WyCMp36da/IgM7hBRWVoX9NRg0ozCv+TRZz2MA5/sZpq7/5ChqN8enPaBGhWfAU2p2b3beoymiXKyvdgsRrOA80tkvE1Lk9/icsPtD2qtcTsSjdedTpp8BrRCuUwdlohvQ8s7Zc38f5hqaP801Nj9kyVcaQnDb5gJn1W1qZax3nq2yq1GVRvYt1mp/MItIzTlDfgxpavxtN3SGhxZappka0WtVnxBFw/O5q5fbA2dtmuWp7sdoAjYtKw32MUaNavZtvaPVu7rZ6HS3Sy8gebJZuaJqlblNlvKIavq9/cw2rA4cGh0lmJXsenJ0DQdRrx3B8XxkWvVIdv4g3jOhF3NCKja+6/wP2WEo2YPtPTj4cvTk9eXr04enR0ZujtjheYfnBy6OnT4CO/olP+J5ffzh6evz2DYzG4ieerrQ3ssVqfgSpeFoGd9fHqzJYf/Q1C5CGJB+T+OkpHciDH/xq1GBtB55qJifdzMWlN3NxqWY0Gl/OktEk6h9I5slh9Kw6xKBbL31tn6kMjI2N7s0w+fM2MPlu978FJr6Vuby/8lQlYdcWqQwNoSb2P8Og6QllcVd3bUHKSchBLo8wYmkbiD3Z5vmkiLlI0K+5z1aCcgwyBiZZnbR+5NMFeUepaO2w/dn6AjhBqdDLmIJOZngx6pwn7+a+WDN7Kupat3EZ5mlltMgMG3Wf1ihP9SVpQbd5wEvj7Vxs228avd5Uf0Hq9GxbTIRD8+GX+lKrb0y+rJixNgi11eK/AkZR/bre3/IMzoG6/TYpWvmQtNExGspIWCfwZdLnHSE2W25hviCtNTHuK0Ab80XE0gHGBIAdgDia9DsWAXwVs8v32j6Ysspyvp9b+df/AiYiOMFsfcZQGfkKG06vzWj/NXRrZe5IYF8Lg4KphYJFEwqqgzeuT8hSBw/KEBdc9MOK9vOXMYYjE2NufAS4tqXvOBmHXkUA7LntmjfuxOwPh3KZWmvtCYcmdOtS3jUNUPiTtUU+9cWWflQWa4aETg2R9NcF2rPqdJYlmwmextC9RYv7vhgIc/3O0YLmQwBspuylUr3RlkavtXR0b2SfgFsvFUvrIt0X3kIdKz1t9hOwQtv3lvBhtrRfoH8falv6MBp0c29maIz9RSoRUq3dTchom3T0pEFAr2cfZjVCo30PVP/FjbgRS9wYNOBG4bvIcJiMJ4QEpNe1VmIAFIsimsoFEGcZAAOKzq1N9MY1dClY02HdrEyVm7i902jj5P86Wf8CLVfAGjQAC20EBmYdSFfbl4sRrMtuH3OgKoB22VSGa1jX6IBnXJFNTVh/SpBfkkEaq3yuws1Gt2jdOWWMvb6+0r33bCYG9tjHX5BEThbJbJbSPRwCSRFgAB8W/fGRAZXrF4/++NiazOCFATnbBQldxf/jo3A7U0Pga/aL5BKWRJt6TiczqkRnvbmIpPrzYQRzjfIWg70PohBZS7pWoC28wJT0wrRF4ogkv313PcXb/8J6B2UGMGhTAJ+4hG2RKWT546MaPMwXpPgpXk8uwyuOFL0MF1115+0aOB+HK1ylKZMTtVZTRzHMt/iWrv0Y6+6sbqwk2ckrbexhVO6svS8oQwg5HEoclVV71WLUZ16Fj1w7oivB9l1AuMZRlg6SfH52pYyZ+ueISPRQ+n5AJuOGMhyXaBt1tfJOtrdPmntfl+JKKGOY2quxuNI2TI7J0lRgdlPbS6JXLfDMOlpGK3z7/b1j89CjwofP7MI7XPEhXpPrL19tNJO6craqPBSmf2Oo2PzfP1y2u8IYOAMrnh5IGae9164uGZitWhydtXblMUtrOf1ZbobsSEgCPfoSGtdW8iGUOuVOp7MsFVsCvZ5QJEj0EitLceoHp4agH610tJ3HHPKargF6H9FahuWZsmV+o2xTfgyy+tVD1hkneR5dJkHljKLQr40NoCD+RHMxJ5gLqtedZ7OILBvYR7b6vj180OY37dMMgzRgyvPW/hST9TD5o29tEb9bbfqwLdb5fDUC8A/gJDmmn+10HkFjBBS6StYWrz2dJabdNOpTgoL2buf7WTJuC2SwLmcobz2ZjOAca88uLyLvwe6uUP+1oKqP3Rc+LyOxHpLp3jfaJvXBoxXXoNumD6tKZPnXHW2Esm5M0KtzvFKyEoNnxi9I6Ei6XG4/unnKypJj66jjnOJpZtOuLKUYGzOg5H3SBz6bTcbsEabvdNayO91RQxMKb+E6/uuhYmIh/UAX6NZYt1QaBzTFtV/o/A69tZt27SuBUQZuex1CtycLgrTacXqrjqszKUvNGR2k/SdkOGSi8OGJaLI01ouoaqAcuxP2ySNJrcWMF8hyBeoZyACC7O2Q3aCKrX5BFhAspLUFudDiT00krLyJ8mpTpr4w6USbIhHgfV96CQebDC2g7wa5tClnF2qA5Gu/W5CNasEHbhpmBvbV/fJ+XqOuFozoHJV8Y1mjCS/imyKcWd3o9cEj2S6XdZ1YwxIytZEeroTeELhkaVmqdK6cEu6XJPp0GE2NWHK48tZ6OGpzplKUMDqROu98gxbMWhdbJ1ZfF76UfIoKL7UqlPxWWvGOYyu72kDFRVFZKKaeCZ6ASVDRmta6pLu+7nfmE2y5WGHUl5pP6Ut5YzhLWEGl+Vv27Uim6HajRoCp3fodc+uHcSZcBaXfdHkajtl6QwWjqgUb5VipKFvJ5h8+kD6Pt5W5DuloPQnOCgU/lktjxU1gUFwZFymWqfFAKJiWG07BNmZ7gUMHHUKf/lWkC8wxMYBhDWWS3mwefNztFrNReHc9Lj9iIgIlnJqT/jMgj43+mGYwouDDBsWLUq1zVeFh2+B1mHqbX0/U3ld+J0wb5PiNpRMtpgrzRE4cbgcY6TGZAmodSSWRqvuyH6b/H3vvtt22lSWKvvdXUKiUCiiBFCknqRRlmMOxY8lJHMeWZJfC5pFIXCSUCRBFEBQpimP0Uz/v0WeP7pf9vH+h99N+6PMn+ZIz51xX3EjJcaprd+26WASw7muuueZ9SoK4fJdXnPkYL1jNB3opHQvFDEVIv0MWZIMuWfLjc3M2i2SOS9NJrdhxBSV/KNPWaG5F6JDQwni4IQDrre/hCuUZgiftciNab2t2lmJgZ90e75W8Nnf4+BjnE1AQaHLQMU+cJydyaYB37bX66nmgwryT3+ETp21fPpPmpdgKmeWxiHPcYBrN2VjXDbRko3cvn6ddIAP1hB6e4INs4IMuUTgRSAN5FvgOPgqW3F7uYSxC5mK107ETh1lFAV4HDBI5eDRwy19wIZV0aFk67cPlY9HM4XJvz+LmVUF/OcAmTDHd46WHdoGiibs78eE6/4FDjUWS3aVlq+ViiBQRBTvO3VOGY1OgmB3XvnFEUVYEzfjR/uH1TYxUiz+dLU2tMWh/5+zu7kzfD0ftBwxdNje8XQKXhyI1EbQbKkdotG8naNKlr20CK9kNnD76RAwOyVRiDf/hxl49QQYAx4zB5Xrmkrk2zOGHIv80bCzdJDhQAgC1c3Is6VsQLU1ZymIr8zIOJsiEiigryp+fOV28RY2GiWOwT4XL7hksJIDAG+ZpfEx/DnHCZw4AsFqsnqevnOBt3shNyOOYp0s7wh19DKfwFA9jz3yzNA0q2gz41hs2dIzO75cFYLls+KwhYR7qoYUJg0lBFPGD3WDgRHcPzqTNp2B1UfADfLjJX24GPxaxSBgd3jqhcH9VO32KZ8Y+ojCltHLnYnnPeufOm+4N/HvcFd1IRgDeFhFkqQzjKWRdPnmoWXjTPXeWJXR7u+SyuO6JnbtRhL55KZXNtzlds8tVHOrCOdc8UBEETqpuCnWS81tO22yV6KqLGkDPUeBuns+RBF33DSerztQgj0yrep45vXp+qhiNlp/E7mJtYRtrzurJS+lrV7FGyG+vhYyOCVBRSkdqFtQkcsvjVqNx4vsNpATS7v4+kR/8C0xh34/3o2EY77NX6X4Suh8AdJtDrnLUTZSfL5VBcd1t/z1mFaRh5rx2z+qqfp1uq3pcV1WTqNdU/Yurqh5zuUIo8FE/LNy0zc5Aay+TiMRmK+0OYyahVuJq4QFOgk9urgarn8XhXzK/ASykcSl9ZjEjqVJfaRYtMEgpEX/pGfpuK0lLbuLHLkpdYEllPR6/Bqant8bfHioDTcl+CsYTcXemubP8hXfJ6D7kM5znrB+hG8MQ3aIX7V32EGVTtknZRImcMCoa1zgFYzPLqZvkE+ZiUhF4uZapx1I/xYqkdq1u+Z29Qrd9vuhA0GIARkz/hDoO1G9wW54II1epKJhLocsYRf14cHdn0l9E3syIEbOd/ZfW2//QilBbdL7UVF/ngk1IiUFg4mhY0iADGCAPGpJAolUlo1QwRyBHdN46rzniV/ZKa2ZzbQW6FxQDHi364cJ+ynVGT3DR6NSiezXe0OL0DlOmZ6JIDrACXHXyRFzrNjCTWEVon/l6jZcyHsTNdJgwpbXsK5bKqvRJSz/KR7BGkt1m4dlUHHRG1sgLKCvKYV0eM0wpuwKbcUEUYsyG+SgTtrQbMQOKHaZsQ428y5xtOHGi6dNCqITeN/D12p/K962Gpsdny0BhMaLJlLQ8MYVmCrnKfpgkQu2dOHGlVf3cyWsatcVIKqdVmpOAptUaQ8wndmB7djSwhNtJlvNNztDxhMeJU+LxhR4VbynduU4cKSQ/daSY/MwpCMpv1k4GpGR+HisWQu18bC5sqddu5GKpVQnqj+Uo+BBE/6xr0WEJDtZrnPrCJuLYBnrDvhmo6Elj8w2jlmyuB5w2xCo7TK32RAR6EWGa6AKUijwotFh/tlquKYCJ7j/oTXxWsRD4RbRvA+6gFxKybibx7xTExUskTK5QLfdGX5wiIfamzJbPy0K3nDpEHSObVXiDaFxFncwLwJScVSGe10vzGRIKObX1M5ez/8JoX8X0+KH1TISXkakdbQrjyewjdn4AnuwdUopiwK4w5+fCrT4ldAoGTFTAc9gqzQVydWiP0IKTNV6amf0M4wrn5YW2J7X4awzQJVq5SO3L/mcycid/LZXO7EWX/aE4eOuBBAqJx5SavfEUo91ImxzRew5hChJU1Z8gKiFLHzGlJ9xhGvl2mggKF0TYGwoUIydmPEXVjwzNI5BXOS6PxDmr0OtCUx7KBJmAoYmePpviN9mCW4kE32JLTgfeKa6HRtLlAyX9Ypd5PthR6HljHy9teKMebMbpwzv2w+bB96IW+2FXM3fwvfqDXeA7oWDhja3zODgh7dHOsUXwMfdsMxT/ViZGxMjohVeAoWLMfY1+G/jDRtEDzg7+rHNhl8xEPjgAsVEOVm3uM5qw/Dp/B8a+r1LHGCq94v6ieXNz00QP7GY2HTNls6eZtP0lLQdnPj599b3mULS7m9OKal+1cKU5jyhsE7Vbs+EVWlznXa1RzTXKZrNJrPnqvntYfZyPVvu7h9UO4ySbadV/0qrv7KA3lz8bfoeBb+PWcDzjv4CBHfOf5OSISXm0CEjFyDtsijx56054d0cmTRepPw7QDXGHOtWsAgHzA0mgGmAOIid0Y7MoM2bZ2fXu7ul0OlwC5qe/0CIMT9+sQhPAghVyHYuYIzyPDA9xC9yZctvl8p58T67Vc0W2vn4Gt4rV7VPUFczN0h/ol+GLXOwknKhud6CSZ4nLLKPs2oGF8niezjhQ/uIelQAQh0uewiWt2dkmC5eUCelUZBFkLOG6O015VGoyxMa1fQEQRKnfqh3pTAZilt22bKi80+EG0vi7LZQWpyln2N4vHeF/1L/H6bONCCi8MAGiZp++kYuhbSCbvQ9kbBgbA43TSdwqf8ed90uWhdzqmdyXjYdlkBcrU0X94yX0i5QcOpyTWdZjnPwTilzyOGB+JC3+ihzIycmN06E8IsmrdI1SBSaN05Q1L/W9xZiPSCAjgUEHmr1H8hz3cQYwC6fCNw12J2HwZCfpARmboFqRaMisVBbO4vXEw8AvBnzAMIu4HoVCMEM8HAb6WL5KbeZ8KLcYzpnQgvyJnRFCGJQBhxMwRpqNonCGsT3kqzAaXvmGNgncK7LcErBU0P79jocXYG0hZcKQACNNCOs0WDpC3tsTomkxOACUxdaf/M7i1nzFGeJXsWx3d0ndgiJp58xxTecb1hQbU+uafMyKU0wPbdWhSPKgTQEOzLJ38IAy0Q9jllgG0yUnqk/WzGt1qW0Hd0RfoOXPYt267BrGoSfwAbw7XS8ubQMDuObfLtlbbpyHxpP868I+sdb0GjEF3R7bdlbI4EkCx48b2z9b7rlds+l3bB6405nDFthl/FPgwCpFTizQi7e7S4miNLRA2hYPllLkbhNBll2b7Vo3y193Nj/7wOQGfOWBzR1NPCCm/l6cPd64vyBJwd/HEk1znuJVSQWEv6qdc0PHVesZKSUU6u7voz3wmCUd2je6RdsF5qANSEnFSawO0qCFA3Hg7Kqn9QWFYLns1hZg3/+rk/okrG7I3ftxqQAcGFJsP5Qua318NTgUDmiZw6TSYYQ21ZRT0cvGvqLIWHkngzV0ebg7QXvkbbUu6Yw0xiwnHGebWWtAY6ApJntYo6k3M2vBcglgv1YLZTL55jLLrrJWkSYbGz6iMjoZYl5PuwhzrF+TRe3mhjJMhobGJ38fh9tv6/QjF4TtdLrcmogLxnCpyOmCLVkuLgbeWCdTF+jb6rgZ9D289VP1WWYmYe1W1GKdiw/rAkzP2kLpIQCzbZoMdPX83CzyBqvrcv4lEDY4ad9Venlm7sVjU/CGMOKMMoL2WuMw/pD2+F8TOJrBWsTzXVsW8CBj4IM6MrCH35Y/KSItX0GyuU2vfR/JSbe4rpaKYFsu38OIRa4NH7qGSN9m2MO0ywoZ625VgZzo8SRSAdhtZinSiByTBe3L+otBby4XBWPzwhv53N1p24leViJWVOJpbgxaLRZqORdEb3eX10WWsw9vBry6fJZLjvMfpqk/S41eKMNPsQFE9BdIS/prsbC3xCqVinJ7I1fs+zy/7zvAte8Qu/Q9SccsEWOBoiLlu+Gf2mTkx5spSqeEaU/dd3PFdYVn07EMYaKCIe2JiE/wAyXxAmvxC9LmlXn860wzc7m7W60BlS1yDcfF+liAVxarLvJenxRFbxQxVVldnlR4PZ+sxZqs6Uio/H3sjK54VIuiyDBj7mMC8bT5Yd1+TGVoVRZGG0XXAb9MBspwh+UNfkpDeUUfKdCsp+xTyiXQ7YG/ZpCwqaJewqJ0REFRWlpdvbIQdc1u3bRYgb/GbIBriWW0Mx1rGByl+ELYEFvabgzbeuIj4PoKkh6Kza0yJbh4l2QWhnt2YvjnsJyDKGzn4gexTg81AdUJmbHasZQicQkO27SCfwWML1A+gEzI42Ey4AxzqqADpgi9ieooz0YkjE7J8J4kSn8nt3Xarja/0KxdiKEhFSspQbi6i3oSnKlQsTYeK3NfQS0JLZZUOhu5BFpZzQA0m5mPHAD5TmweBZOnnbt1ycoPzwuxPF4AdvMxrTsvqIm2j9zqiZy7HzcRjt88oWysXL1p7swU+8XUo0EufSmmHe+jUrn4cjWJX0zcLO0m9iT+epxNu3Mb0Rh6sWOcsu5CPH7vD+coM5nEp5PMxexU0xmKTkL7VJnHMN+cUm5UzhMb3JFidzcy0ZiT3s1D/wbxEpe5vHFuocKtFMQeof+2eQTE90scTwptwklHcSwaGgJ2UK8n8esRYxLNNzbm7QNyZzL2uq0v1nIrTpV9zXFrwoqb8iXP+X6Mmw8wEpMX/3rNkoHbVdNyxaiBuMCU1rBp3OccZ7i2O+226JtlicWE06LgG9Y2Zlpl4bVZaidW80Y+wTZF+O9ahV5l7j1k625g0oZTe7XGtA34Q+zoe5R+nVliX+Fxbt9Y+d2FlwtWRttjeLlkJbWdhpcnUBJ72emw/rRonMO8nzSmU9/djVlsWE4T/Dj1MVeV793dhfBB8wd32xgv4gq9S1B3vBZgjaGR7FUM1xf5fONBs4XtVrB2EIWJvLbSaOG7CC467gYcw1UolPgWZqmteK9MDLyeGbaoM+GJjxaYIUX3DG0xiionuKBti/EL60ePz6RkBODVl2VyVLUsE3HT6gZDNAV21ZObPC1K6bj3B3krNR1o2a0JRKnQxgkWiPlzRFAEtijA5NX8OHiojGDGY3Y2wItS5dRUWyemQ+ZmynOCe+oXTEs4QnAVseoKYtUlYrVImpHoyJimroHeMmQyr+V5oAQrocWpyzp6OZfJHs3JuzwhiaLj5bJFmK1EJdjx+OgYvUtOKsYFAyfDTpTlP2JZNSdRa0CLR7EF7uvaGRAR6zlPSt4vSLAYNqdeKlg4/hP5XgZaGTKVasu8+22ZArogD3SeOImaY0Akm0rWDt7sJaMozi8mFGOBsVcWgyh6NUCX9k01OPdWqLP8hXB1JshNssFnWXbExXPMz8qtIxBH/1hjIHZugdm7VcweUJg7c5aRBa6sI1kSRnOsO0GoB6TmAMw8vdleCWjv7qiTZxqv0EOL+u4Z0bNa23hez1ppeOuXUgTB/cQOEFt4AYQ3u7usAoL4m00gLjlhWJUzoqn1jglW4Yv2Lnck3qjteKMdiYiEB3PYzxhDCNknhd0Exm8BlL7dX6DiF8gMwIiLe5+gJY3qbPMJOtt2gs7ECbJPHtAe4yKFLKbYEk9sxSsQA3KD1sCbG2Y3UMauKGzrxnankzR9zTIb3rS0p14v0x/zCOC6jRmYlKwuxNtagj3e3ZrdoUKYGUb0yIReWJktO6H1X17Iyf1Q279aMF3K+oomHFXy53dA36LjtkHLacgoakp139YNcDXj26KlacZlOEjIcOlIgXQP5LaKjVZvjhamyA7sqkTAKK8RocJlWTzcSY4kESpET5iKSWtFT8XakveGNJhfoB0GqdN6CRz+7g8sdIyaFGEK/IQGIdlgi3U+Cl0pXeuc+KH5oOw9ebS0axZTjjhSXjQFK1ZpCKfsfkvmvoADOAP6ZgyzhYMHR8DDLRAbagIN/wwj/UKnxImg3iuT7JMt0BUaTwM68A1bBlMK7LxrL9EIzN82Yqa3CbfHnduwkFf+DLXhE+DrEkafnwDeGI/f+oD/uyf2PBdBtHtaK+Aj7Haztt8UvK+PtZW4La7EUUVYo3Nxxpa6OF/Ejl5ipuBZAvzJsXW4dN6jmzSC3jvn2RI9+tQ6ACxNfUbKfOcI11WeIOZbFperP4AiLI06/9vKhRsBNuzwWEbj+lYpA78tRuM6psArx/a3uWhc3zm3eky8b8nhsz/z7VPffuUPHGDiXeSznvrOss1Gv22nKjdJ2/wH7FdpLzRboAsfRrtCsyn4C7Mqs3BPfT6fxnPf2THf5/zLPcv+yS953RnotE33Fvzzyme3ovnc733HnRmtu7v3eTf4d7Y4CFDsAoPLYR24wN7AGlpqWYiuaYqjYnR3znd3M03g0DNm08w3eD9KGjDzd3d3znvbSImf/BKWcAUf9w4Zt598aPPNuBCRFm9vESW6XXfSjeE0HDY52jQwoT02axQsfDH1nU2xTihXEOVrwUxkGBLdZtFXPDzGURECEoXA5jwQzlLoFdLQjLQjs5Bh6TDDGjs6ZyXhnK2FJbux5Tl/U3GUj50zQS780DZPgPhNWGrZW+emGKGo+AKKq8N4osKxHzkquRvgijOkYBUOzj9JLN2rfKvaJ/SQAQV/5BwVDFvOnfPeef4dM3e6hcOdz+1jA9C9AURxji4T54AJ7+7ORZTw986tQCS3BSuEW5XvR/zkKO0IVgu5jGB390iPmHZr4QueD+i9xXAQYrtzSZ2dq6rnxarnxaqIqVZh+pSykXff2WH6IwwRA/lMfbsYaPp4bQMn/a4XCg/kU/9Qc1RT9OKp77jmd1YX/vZd+13PYNnODbZ8U79nJKwX/uYYD6nWE3s9KKY34IxFgxvIv/LFfeHlevewa6+OV3gzJkwEiCN/+gA3q0MGWBqxzZKfL8BYlQdsLWxV5rkBzHEAmN/18KpdCnKto4Z5ATWYK3nlx+rK5waU3wErICNsovtZ4bZ35W0f8MvDEyZbETcD43QYyntPyFQMEIPCAVU3zLI455Paa+UUcczZWqbsXRVumWo08WeUGNw637ThUtbw0Rq9vaOyYTVOo0f/AueGmeAV0ZBUEQ0JEg3vmHRgvrs7N99Z9rvSnSa8WN61+BI8ZwVMBmhwvN61WPakb/Bri9nZAWjC6QFo3mj7GOGZQfrz2JwCR/9OkMmndIHZq9zu8v36Lr/NxV39dFuGVxdclztEZFfT1xg11uriv+u6I8UsmXH7ArgX+ByOBLTdKmhze/PuewKTyms73nBtw0jnhcOEhp6aMmbR/s/0IXeTe/qQ0zBz3txLlMKuOEETyv3OGCXo8m0PqvZag4WK679u2+dFAnAhw7AC6Y0hiO0TRgOcFiNDR+s8lyWZtTN2xr5dmmeY4W11Vn2UbpxMeWtn3VkIlz3sOvw9RRc6BDgeOJstws2vtwgwk0UNzL9BkH+D6h+7j3l5T7mxC0mrRIZKPVjshHY/GAsjh4JTBPL5OY9VkQAU45fqBS8b15PJB+ZvO5pOblKSeAx1R8UsQY2Z8G3UKzee/viy1XgZUH632GeBO0R5UpXFsAPfLJLxZArtdjp24wZj7gCowzH2qBreKNB3MhkvA7TRTzOXMsKJI3IFpygb0enI4qthsp9Nx00m1msyOxDhvhYq8cIpC2CQd6W3a/QDLzC0CmtRecQzr8pQ6er6oswA9UAIshws+0ExxjPP4c5vXPQO4ZsU5ZUCZpVDTACAEFmHumO/7WGqrL05NLtGQUIwsAbEDY3aTts+aRMQX15cfLbiSaf29kZta31xcalw1Z/bpXAEgEAw9ADDlEYurHbFxZk5GGqBkkPSBQLLqKIAVJ9QFn8AwJfHH8DuJUHAkfZcWnMvlDX3kllzn6ydl0uoT1mRRFdMPMOM3dA1Tt1md3cneLUDFuNykbs7uOHrTmPUqvlSdeCjVvllebj05hUngFpsgnd3c3r9DZ8l5n6jX3d3MOFxll6fwCLBa/l7LYIsBOb/MRORsgThs86xZ8RjgOsRIrLqaRfxaNTKv0AdfJ+hxExHhN+06+J7bwrKZ7vlCCjo/EshtfGaf8qzoukXujSbeKsFpGHIpx8MVGgxHkSo2UFMgaQ/Xml3d0bLyGc8WMPJQIyUi4Im1HiOyPMlwgFU4ApRFiWZifArM8jBlvnNcB3RwnlCqbXR8ScBcIEWfVnOnguuZwlXoXJKg6dE+JPwspiW/ZDFmk+0FHeHcsyL3mUPXVjQg0XEwTNlTj9KbhgUMsXLuuJHT/zQ4x/09m2jx6pgStSeGLtKFKE0oY5XlJjJ1BE5iRmQAJ4GSD/UAVLR5gVTF2FYGx4iwMArNZ/8iITQl5XU4KWus9j3JtHl79IGy/Y9FeZDl0AlPg/Z9Th0XXgVzyh1K7OpKxVv4PHKt3zZK2brcCXCrxirgbdiOSUHGS5iXLQcV64sTZkc/lwLK/N9Udxhu8CRbPgcsepo3llXt/KbuHGep5oRqh3xRB53d/n3nkjwwe7kPwN7mlAghQ/+tOkN0+vRBBji5jCbXTdnkw8+JnaiWL/P2jLcL7kGiFSTKD3KJZTkOXlZ4N+QB/6Nea4u3v7TJGSBhPQkk05m80ilnu+4mqHKN5R0lqc7ykUD6f+mN6A46YeFGIi54IkilmsV4gg1m0WM22mwSVuoyqPneZhe8HfaCSmMCDWzoiY6GdKzVlPlSBYoh3+w1RutuM6hnCK5wpNybcm6CGQfJi3V9ckU9Zo08Nqq/YYtGbM6YPr3TEWG5J0FlcuVcSdE6AgzOYjCqILSsCDr0fFkoz2P0KAZ3t25lvT2gM1GjaAYGVNYMUxi2LHWoJ4aos18fkuOShU7HjsAOaWlQmsEWKqKT+IKgSXr9UI9Aw8T76lF6ul1xycwC/R7AQri5cyPzD9HGFz2lEgwq1tV8kqWtIquER+4P5sAWqATXR+TD+qex39O0b8erm5cjkNXjtDVbGfQ8hZO8WQa3rIA1Jdfw/yA+fhs5a4vhYQ04N4SRDwi1uPUaGhf+2j0QHozZrbdmnwQHha0yKg8Fc4WQQuHZEodqfK1e9Y2PR5EtNe7PD49/REGEGhB3YNcUHdbPNpQCzGByKbZKHSknJSHmgMMrR6ljtRKvG2XSlBGSq3I03IRnp9SKA2/HztMVH729qUK5QHLukLV5omfpsTOM0ofwxGx8cLojP1hEu6nvARG7he/bYADXrOLKYeH5mWu7P5nq+/H6OB8aWHR9+HU7xJ0bCq7fwPFegBlwDHRu1DUF4EDueHZxkZEuOhCO3sm6XcwYzkwYrv8BDv03EWqCvt5inVOp76/eVL71HYKY2MoUFsJ2I76lUCyHY6SKv22tvQ+K3tpaQHXgkTFyRuH5uovmT9dogiwb8g9Gtj09kXcxa1E5YW2x6alhwh7o4fYqG4PkFmpQbXzGCUICJPR2Pe6OztxLvyYjvu/T01l2bwwV1E2o3P9AqN9YZO5VcSbF2V/gDzStCsCQCCOm73BkZCXdm7G6LTUEwRw4DwJBJi+JE8ii3kCRJO5jw2Eflo91WywxpI8uS5g9A2loVu0YdFm/CeXJ9xDUnnnBwq/B1TXC4x66qMkgy2A8fM//XchyA+d5xgHPp7cmFaTHMrDx+L+N/6MhCd8MiQRQEGBg/EEKZX9jv+IkFv2+EtRBT1O12ljeDW55HVcvU62/yWLmuvmqrjrSK8S6FVcUSV4fPC5qhKsr/Uqnl4l2IeSFHL58SOtF2/t6VUivYq3/6itzCgfdw7QhT5aRxOq0YUHrXC03zmw1kv6pJkoJh+x+Ijjn9PFjRcnABG/tF1UJHpkxGwekHP+oZwHRf54ATjjHG4kOE5NWHOTXr4CtHNtWnsd/SU1D0RAQ745nmTTFF91Vc0wZumf9JcnPgzTo5faPF+17zfPZrNL//tlk/0kQ34WmatUYDw42M/prKOdCv5ABSHakdIwV/LYIqeDOcSAo3HRJV4daAzX0EK/iuchpkDhv8rJhpoHWpqhrmHGkwaWTRMg1ywDwxmoNjUCk7+74CmMeCDhDsaRhhqzcDb2gU9n4bmHC/gUt+gueDbJ4lkzl6bXXFqrZUmebS+BSpgkGO96eMXTENqhGUtiYQJkwgJocC+cG/ZKKRf7BuZFSRqCt2uMJlOgcpoj+YP+NJRKtOliCpXUsIOeMbpqptk0gEk2D+C6uyZtofayYwyUnlRZAPSDHo3HNGDh4tx4DGF3gSIVf9ZcNtuNsR/M4M9Ns3+QLAYNaL8/h2PSZCNpAoUFv2LoOmXJtgbGmivH+axRzTqbdC/1+1DbKCCytAGMgDD90EgWzUeNZNk8aCTTZqetRZLs1y2lEYz9RQNOTJQ2XVTlTRuIbcNg2Rz5sxvfjxtXwwQW6t5tRWHcvIGZ59qsbqNqKcN4HMZ+k03outn/A67eDf+bXsNB/QCNU1oc32sSycJ1zKtSwhx9wYOr5iPMlWPZtV3L1jGFTzOawDlB6qnZ79AW0m9oRl/XaG0NPq7JdusLvdFHjdlwlI2HU61xvEZbLEe993RmaV1VLX4EZ65R3k1c+Udbu7/f3symGXpD+sDw4+nvKuzTM7QGPG2YFY2weaqZ612j+2kYI4n8FjDo1CNcYhv+HGkMew482z0bLh22NBsRdoKDpvU3tzHkCzbOEyi1gGEaz65RDDf5YNSf+PrOUn/eJEYp15NomHeEs0lE69VbKRa7vHcdfec6YjM0Y6VETQcanaEzfjLrbYac+u6KkKp2XzWuLamR0JvGf/x7w8gNYKDNnc+cx5izyWi6Kx+F3doCDTgZTZ3ZVeiWTKIB3cEtAv8S8F83vwB08UU1SuOP/HrglwXdEjwToXYci3dHg18UrFLdlvNSG8GiIWflAjUB3+LJrDnE5EkUTJh/g0vRDWfL5udtseCXjF5o8DugUbwPmD3OeDjygY3bVlhuGNuK27aN4TYQxWhpXtqKyZLnbn4F+3UTerPrrtEB6Lv2cRPYb1RxfD1ZdI12o93oHMD/DD6q69Dz/NjoMqOEMrKhPKL2yusarw4ajxrHeHuls+nkA2w3F7YSUhdv3/MBtA7Em+/h2nCHCeDcv2SwmYaCMq3tz6HtdweN468a7x7dvwNU5HaZsXBlq48anzeO/9j4/qvWF41Ou3H8CP7+9JHND6SB803b6fNQt8bUR9A1bNpc+bgWoXCNydjDxEviO39U36NJOruYEkpNZSl82RAvC2UFuiyUVq/XA/u5NsAh3sO85DBe6kP7oIb1QXsfxi7ZwIcA7RekVpPl9E8N9qmmHkpJqqvRF21SYYqRjy7wesnXijHYMSUyxQoDRbWetZFap2jMSKwzCeqz62F8hSQ7uuojG5zhJ/jNP7g2w/MviAOniOTH9IJ/R0PT2XBM9xrGrqZiPrvnEG+vtpC+Rg2pq9Ovig6sOGilBgURbRRRAo/BKZAzKQdtkckMUDSb0BzNVsx5i1nftOi7tbaJiUAHYlgEg61iI/Qa+w1CZfBXciCGjtlviKIrIOjyFNs4xQOcYmcbsdbQR6LQe0BevrkOmngy4yv+CdNyICHKj+UWyuugcTWF6eE/iO9TfFFHNjMEvYUIx9odQCOfiHCroLUMhGANn6VwX7i43WyLs/wWu+UtLowftuJe29bBbYNZKArmgZuhpnDTJmcwGB+fwyQhUxQZIZyNVbPibtHir23+xSoQ1X8rW8NwSP3mBPnN8f4WN+f5J9ocjVysPnn1HKwfe0WsVsk5lHdyG2vWT2wDkJhhR4MS1XSci+X1ywU0WnIYHrgivQlRz4P6pWHqC2qgK7ucTfBG8j3TRCUuhrGSXGQz0zjKQ6rOiYX66hm3VsXqrvrNq+dIi01jqODsmlnVW71hRXBsalqXOzVRZabLofRoCV9LWfwKrVjhIgvJBRllbyHP1pStHdQu2K6Dkns7YPacmkDO4wK5kpuegS7WaNiYe8m3Bz4t7ZP8J6SaLBECJOeIvRMrH2RGDh6jNfc0jDB3pG7UrbTSXOp/6zxZIv+KjaPHhORolxhR7rjXv9VEiLdMkEcM/K3OLbIXGn+vCcXyA2jx2FKpeWx1KfpGP7YjezmQHjC5mc13yEaZrUlZ3cviklCqUpS607tbdDc9FUqUIwdO2C16lxyTcv/IOjyXufB6x+R8fWT3bwdW95yFKLgVEkUMzHTcgp5Jl2ENmC9x/9Y+GigLx3PyYxEg9t5+B5/eaSfovX6CeLu39jngAK1a374d2P1Cu7cUJkzW7vVEjjvzqPTFap6Tidgp+VneFBbyjAdH7QFKgBKc3cfC9tnAOixoo9/AzvBB3MKi8R0/zm33sQIKVFlwfTdUCsJpZBa5SeOz1e3a6P3DP5DDMVMopYCEU/IZTpkFz4/np8cvf/jum7cXz14//+bi+PWrb1qXFoX35vaOLVJ/+U9J3aqNAA3N0PVfvtjdDQg58niXRxb3pW0Nx/4UtrucEvxI5hDn0v0jS6UyFVfBEA3gKu77a0aGoijzmglxHx20cwJIulSB0hP36YaLtVOmA87atmAtoiJrkUjWYp5nLRZ51mJZZC1OdNYi7nEjAgClApdxyr9sulf5xDnpgCKNYDy5aS7RpGiiZyiqlWFVSh07BUGSonh46NGf/+l/ohw8+9hm7yGJywRgQD+nmrHRp5oJlBESe5ZFByd0xo07emcc5RwDfrA4aaR4iu0idiBs3A9LEni1G7fNTrvxEIawglsisM6SxJ/ipYtiL+ai0W+3Ogd+VD1JGD7cA5uEvJtoJ4NEg8Jfj5N4t7QwR4JafBbJG7d7pFRl54BN35jngBilysxtSZc7NJ2Zo7hpNMY4p4DHlcTL1h4s6NE+tqzuTU9E3yj3eqx6vaVeb+/Z67Heq47ULF2689rFQJnckg6jB0W+nvJDk7xVRHP2htMPMngzAdkr3wuHpmGSd/s0FSfAxXa7jTGK6CyKa8ryshr0xuiyljQ7COwXEbSwV6g0eHrtChtfhzfELWKpNf6bMIW84IVJEVfK0kfV7Xlbt8SToR7ED+4mdRi2kGiD+52tlxNLewBZhcwTTohbwpjwmCWkz8wWqQpbF2PwO5YGPmvxnNeONpee8ZtgGIwCF5bnN+1R2+sc5BK2SRKSEgLq9Bx5vrTRm5XSbBQ/4aZWxHcTOcIfvp32nIU5w7j+1TurXM4SjHhDznffUxAHIBQNly4OA+gHFqsq4aYhtcXWFOVRmkeofe5l3bg8s3MMJwlVAlnHK0SoSEh6lFhkhpao5nJgxwYlIK9baa332kVvEg5kNEo7KvTFwnGZ2qDFWuU2n50tDr5Ey3oyMAZg6AkwbF1MAEuJ3D1gxd2li1mQbPKk0niND209wdhawTfaANmiqUw15fKmgrWDQHa4TRSokyqCHqkSODEbwMq6cH9IkmajEviet8znFf1zRTZlNtwmV6mQmbwjzYT9AO794L5XWk5Driyoxc226WLTB6rwuLFFHRyNUV7xSa5gw/NH2RVq2eg8MLrb2KIjrln0SqKmYiv+1K4G20n8jAOu6luq9oQyj/F/FSZlZWlVtbytUoAl5MDtKoFcXlG6TaWXl21pejwGKUwHZ7z1AwyN2fj5n/478D3NqY/ufujnxzkjco4l9scL0w/lNfyOgTNwwNSOMcgLuTaaVSiSvNzu19oxMQ2UqmxtpAppxGI4mjUf4bEi3gvlrkubJcxKpZDrJf5B+GTpIGI7cC5P2f2FlnatBoEFelgS1muYVP7nf/4Xdr3RL8TF9AO/Wa3LIkosAVn2fxAoBQWFLS0Os+vWLSLUirK9fUEb3Q21O4t9+Il9YA/f5sCh1n5B23m3sO3vtip/v9SUv18Wlb9fNvDd/ZS/UybbhqoA2Ev6l/ehdfGlUJRuNKPSZOaq1T9+RKvSXqSyRTbOPz6oRT/BDZ4Ox5sG+bAmh0kyncxZi7nN+27r5rW1zWt/Is09UBOPGk+/aHyBbeD/262vGn/4KEX4Q5T6f4DOvqe+v/9D44tP2Z32GoWfKBgBNFJc7J/+emYSbjgFXIlHeUFg4S7pz7RryPnkZq1dKzie+6xMaS3Ioq5iLKj0QdDt0BCW8Acwp2EvDtjzAY5J7Ve5NIKdVrgDiLm6PDW7ZBUX1CxV+LK6NDarFaZmN5U/YMUPWPFHrOyj6rJ/ZGX/yJtu85G3N4+EN/3HjU0/yjV9oLVcvI+/LYLbJ4e28hH7Y+MPcCs+/Rz+wf/j8e7QLzjvj1pfNvD/bfovK/lTDTTq83jxn2JdBPeSQlGdBjx2akdrbz11nyNwlfHIlhNW2NDPikoproAKHV2Ke5jf8o3c4AbjtwqFaFn8O1w0b5qRhyTRV4zqKVWutSepJp2q2Zt67kYxYzpztUkRXCGkfVQwlNRsH0hC1BhKsp0odgwJgnbbjekwHCNlOvKvQsrVmrDI+VuG8OUGXbRGgX65hbw8qN+jrauft0s9qDVcDjfMpNpaRDj7MODdYojcbpDyuokiZ2qOlvUepqW1IvDizD+MvEK3UyDf6wj7TuXiGvu4oQaZMTSYPsao4MR+la791KXO3fEEDoU3Hd4ADau4QR0/vIzNlbC7YkZroWO4k2SpC1QyxzAEI1YKaW+EHuAvq4BCStwT5u31KnwyZNBCzLqTMIn1zRTAHL09MW8NBb/nGRfQyg+G32KaOvHSn07pbRDG6PpP74tZHwIxzrXdOWi3rZxg4HKrkUYNS6aYNvQJuxS2tBhRGY1idWbLpRymOIReyB7QBtz4+X/8vw1Y7tD34Ib5+X/8WwMno2/QdWKu4OClsD88dsnpcIRHjG8HCYMnCRkTCAf14Qjd08N7YvU/FsV0cKOgr+995HI5K3QeGH8lfM88FmU92MpZ50TVNU7lCQt8jnOzsWHLdllQOhZNBYMd61val7aHjTrBFIoUGw+5Udpf0ZVSdvyJeobCg5ocrwglNc4/HjNOsjEjVBbPpK+2eub2B7Uc98ONjHjbwpg/urf/0aKJakHYwKjZb9JiXT/YDWmwZluYR0VRIhU1/ZDBtEpCrgO2lm7nPc+R4WBkd39s1PnZcmdlw2apMip8bUXEEub5LP1tY81fPpS/pabtZdtZ6XPsGnWzN2yyd7gqlYB3hj0OA58J6HOf5Wsg87jMIF9AvDVswa7nv4u3hi0lBPkC8rWBUaaG+Y/4xrBnk8k4zX+gVwYGqpuqD/iEcfrTEM0ltIHKV6wt9QGfcF2ihIe6Up3Id1AH9lOrgyo+O4yDiXqHynh8g6vE3Jlzn/hLw74ZTilUau4rf2lwCy2jpOEHoPGzWW7l6MLVPMZTH5A00OgYGJQXRnNmYB9gHeBCTSfBTDcT4BeFqyGsQLtjG55ziSfps9XLdj+GG/vSjhwSysFRDD2jV3JH82y37JYGGApDQ5Ccicn0uDklNkC4vHsJJ7hBjTbQefiSN+Otyx5vl6zRKFyYQLdOPgDOsqlSo/PVb+2G5uliqWYKV1AJu1wmGMcOXZ0vuaddpNZGLpeOJX5smythfRPfw6Y9WiL1Xu26di8j2usmLJGwc70qW4Fw+LEGDeFV80XbeJhSqXMvJU19zzlVE0d1DUpo5XukxYE9Zw5T8OOHyUY10kdON8d0+qP8Hgnl5GzE3KLjljrfh/ffQGkX9UCv0V9h49X4/8r7rnec1zBOw8m0ITefFfu0218/6QGl36rdBY3LfxiflrXkRLi3ZiNKr1Lm/4ep7j/4cfq1H0ymPtmNusOxL0IC2cbP//wvqtTTAGPClgsBK/5B+WiqK14QRMm0YMR3cw3wxDhQ+Na8mQ6TxmjqDz80b9BMeYvPyEGeTxZOi+rs0BlZKfvUsEHZkvlxEjYlFkWzQusdROsstp6klfCRHytDM4RJgJPIMG+sYvQoc/MFvDTy/EQxQWFge6WX5NqvAphTyOAuNbiPIa72/x+KStrr/uP+P+7vs3jPMYW+0ithjPNkU0M9yiTONqW0e+sGBkxPL8upcPNxr1rXk3SmR0KLelt8oDa6MtwT90SjSqfpe7vAP5jM1xsJN+AhshSorMWs55KC0rhiIPc1sHgZ2wXxAgkXGgzkBBrC/BOUdCIWmSOMi9F4iDmWpsyNjkyVpiQkvD/XU+S+NAnJJPFjyj40HDV+/ud/M+7tNsxtoubOk515wbjgo0eT9bgvP/CO/gIW25OSeTvoPUwWel9LVSMYhmMWhJiiCtN5M6SKFzAD6XSmLuzJcDzrGhzx8II2t6ntGuPh7RKXhyyju9wOiiz1SyEkmOiXhMgTyk2M7M8M/SONPRMWAb8DM/lVe349MLri8YCsow1m4Nj9q53bhxw1o2FmMY/lDGvKTOksY+NFy64MtO7YfGF0SgYUWgLuGjpLXRfTyVgiPeIAiEnjMBWP7FWEFRlzoNg0/n2Y/07cGv8Uyk/sORXPmt1lKlUwBVmMBgcioEfR5H3c7D/iMoUq11MtNTYugTb5TJs8y4feawFNBfyRFtucBdVna1GkP+EeCrlCRm4WTkUGAqEBfu8Hs4pAINiihWH37mUnV3NB1KDS1LcZj8nGLdlLzhNqR5u+P4wS3e6UZlRQkW6vODQmMZBDE/xmjpcT4SPENoUp86ootsWZ7w2N8SKl2A9HKeYAAoKiWyKcCidoWAAf/ezwGihW9lCu6Sl6C9VHGFHXwUxMhdc9eIE/RBoWt7JJ8qaSje5ojQZaeTxyaGOaflIolQf91wdVTfRTD6+q0F8DaLMSoAkh3CZIY7sjgDcosAmp1hqXjW1orC+qE/cBSM+lCAey0Q7QAxgrcVA+KwjV4XBcmoIUYm2aA6+snxexGFcJtISRH7NcpCjtJGGG5aDEHVV7zUr9nNa7zJvLamZwYeC0ux6XBVtVzFCYP559lN3qfEgJiVAaK6vyzE19L3N9E2PsA5ES5ZmnnrcXEd/EZ9j17DYexenILPNen/QwIgBUn8P7UKIZruiOV2HmelOhrdcMclFn+tE+5GHP+Plf/zcqrP71fynSOX8ENh14+k5BIIuI7qXXe6j8pIpQ6hsIW3Twyz2ocHMH1qAQGO3XQzuf/r6zQ5Z4YyOjx7F2js7qVEHF/b3Q7hPDSx37jShdKh5rPbncKpENcf2adXO4GR1xRuJL5CuUv2HB2/AelEMVhkolhvqUSEGphn79K7p0+5WOa7pMocW/xuX8MSTcFSrI8eqqvyZ2OtsdXmD5DrbHsTA24ekCZnYBM7sPwswq8MwGu5a8Oc8GscKnwOkVlTfoxksEk27W9HBfzliSSa7wZVBHvSSXFRHXaizvO41fJrgt8OFlTJABJiCKZiMcEmkynF6RR2GKsQ8O7xeV6RMBZQBAGfxKQFmKdnrwq1IYaQ5n5cgZuv43QnGV3VpMSSg21hKBDKvGPE9M1/6qbW1x0iJXlBLc62gzN6TQ08Pj/g0dAndkupVX4lFqCtS9LhoTl33OOatwULIk4hHJC1qOT6iWYXqnsU+CTHjR+uKLGntG4gzWmKqhxOwX2CfO+xfKKjULL59AeVTFhC36dDYdt+CxUGuYeeFEq5U+TAhcdLMz+tRgF27csEU/ebe2MQDsmu8bk+V80r6pQdY3/eR9AwLEWOFqCB/ZW16L0cducCqy3ZyhERxUCnUkg9uwa+axAwQ1AJ84bqG1Z1DMCFlxWqmbo0DhBTAlsZBiJ1XSlMzhH1igpst/uLQ7FgZo0W4CGF8Ghx1Oe65hBUUi/HifXg2Mw1w5BTeyHL0qllN7LMvRq4EhPOlNP0pmSz1KlEsrgA7VfKzfnrzGLI1ImofB0qRHOPwpBUAnLuVAuE5Ld30tM2OtADuLvUmP2ahhlJFMvmIO294rVusZL3D4f81ACvYAsFqhh+cN+CUWNRd+w7Z3ANAA7cKR41p01KmzxYSHfAG+1v8ZJiSLxNSiYsFxjUOUXVHiEHgROswNtVZtHVZorYWNYEWYBExdYlPMBR5NgHlLRGuHsnfY7DHRInrNub3YYu2QEWJmB5a9dKIeCxAGpF1/YJ84SU+wL+zNKb7JiJ0ZLU/cSeKLIB69XjCyz/AzN+I4JbsFhP4b/hb4tF5vtbbf4DOACQUencB1CfPq9Viybvo0Hsav4F2LZfbu9XY69i1+uJoMx7zgET57S6B4Qvf9ZPoBeWKt/P0CBGz3+d1imlMdIIDFFf9l0UgfEnHx4AFOEw+MtsisM3U8UBsK85wA8XxDtMVPGbr0Y0Mv9pcVcY0K4RezCn3Pkoi6cxWWqFDnnJ2bl57WlXpnNEzDPmd3+nmLmRlyXNC7bPz8z/+t8dmq8J4SK9oGqmFt2ZBlbbdt+BSgUIhDr1OTJ6UQWTWCu0p4EsiEG13d0NkfPx2Hw7T3K8+pcjiEUdHX5D6BLfRl0Ee+Lsthyxc455zuK+GSbaAKvMuSehh5r3iDJ6dqoPPflgAiUmzI3AErJUUVy1gjw2fDAD4lTdHxI0ByjC1lXrzvyTsr79uw4Zizna1ITqL6yKcpaeiuCzlkUm+7UunHoIDh4ZNnuTaKc6e3hakLA4FaDvdhK8LzlP06C0IiFQ5k/MC+KSsMlY3+Jp2hoe57ZKPeyBNzXFIkMFP4enkukgcN3CypSTgqtaEcC+rbec6oh4YgHzTFIpkuqfWt5f7F9f9LA83V25CyTcAYsjwBKdCz4txjnBX0yyTAbQDxk1Hq2NT3U3qvrGgJQ+RVo94IeHjiXE7tHMXWPfsU0Q830005U+hHcmlyIZJu61l3nuKmgMVK3jvS/cQa7H/e/qRWkFvVEprrS62UD8lZg+L7sYyQ205C+ViJmuX0IdsFELWSsdsWM28DUpoGh2CEof0AvaFj4xSJ/S2pdh4g7PBgpo2ba5/QQmVnWnIUBsA3LabS0ZO3mNcjLsPKfxV15vUxLGVmqM1LVBWTUxis89ici1/WyT3sHhdahM6Te0Xo/JjZYd4HTqgRkhGe4NfDtDGJAcVI6N5nbJ1IldFIgcIZTlsGDY9TzOetdJJNXRLaKBv8izSLoiFcLHzoPprgcVnKOZK8jFPHQMWqOspQRIXJfSqQF4mo8WO7pgb7PK5p0JJxzHlwv5GzovwPrwHJTrttG84lPMEPevts6F77b/2hl3vxDEOD4+XXXtuvU96AKmoUt547fVmGrXVVKoUIAorwAdRZSVUNw6iTauh+Yd7I5HdEXLgjwpzFohriXtxig9njb+UUcy/EMLay6eKGzRmsaqmuHpCzzY89FuTqk4SF/QTMh3DqvEcsqjq5ftGh4B4ao0d5S5YPhsa9ZBtRieYoyxmGTQbN3Dub2cHe8Gx03MHcRRi4oPhw6B6RB5QKN4n/+PcGlVGlCdiqSzL4o6LsZ3UxNgYWsb8wCgGd5Yq56GOFcRetyrihBAvYcvnZ6m1qFqvYmbX+7aVddFV8nRbKrZUlnZr9/fuj4lv6ojJaP2zp7tUHK1rfPvtenENupR+4dqLafdZPlF3nqHstd3aa16bsh7/vtNtaAIPRx9hnVJnPfBozcuOTWODczzjvISYgVX5Bj7S0m0r7fg89+y/z2vkbMA6q9fkqmH/Z94+JUGstyIwNgBQdj4dJ6jOrg0Z6PblpENbdEkJTQqWAQrZno8qrrai37huiVpUCuy/5Q3zsDuMlEPpTf1CrzdauuZD0ONz27Y+oPirIsKqFFjHZFyjDQjHh6mBYGp0MTQFINSkNb0pS40Z96Ig2xgf+vCK7apeswobT5tUUGARoysRQSVQp52PeaP/WbuRIMHG+rcZXX/yWsrEOdKLzNHIO2m1Fmj2bqcA3PLDK68SPn/sJYMzQOdhuulDND+j2BIOSfWE0srVesat2Re/2B3/54xDjebGwZjygyghwZoRZOpT3i5hDyFvLyq25srUAf31PXngearKG6QyTzHKknNjzHFLOHrNI7CFPB2LlluPrsb3SWpv6CSDQmGgUHolAo5dUX6JB5htxjyZRThEARHjb2+Wx7KF5YzSZwD7ExvYOOFoJrVzz/ZqrYLChz5iyhH+aLiVrsqlDpnXX/H6FGcOpiOZS1XtBYb9t4iqwaG4o3W3JJartz+bmAm7ERW1U5tEw9UnTzPiM4pVXGVt7O8EO1+0jTdOoJYHW2kkKFmdeIexOak5aL6bDK7Tbq4jbVd99pyp78b0ZjK5RVI5st0vbvImy7Uvjs1UorV1OI2uNUpjLe9prVjGWhmlID/rai9sSN/eGHMVAt9F1Fi0Vt1mS+I9zK7X9en64HWVFa/JKwywpHZ4kuEPZUkrIXjk/hw/R4hLvjJGkWbxoLUFkhdlbvW37VlO/hOdy/7Kx/Sb7ojbS30C3i0cM9XQ6HS5bYUp/Ab8U8ZPGIldi/P5gM6r/+0M9D8AuDE88pIESttnmD7PB5E6Yaw+2H/GaQy1PfuF0h8wkdGEvRWIjUx6uhaB99jqbqJ9LjDLU/2y1XA8uFSG0FJC1BLgUM2h21tBRAa61m5eJ9w0hwVs4r+mFTP4WSnZ2cU+gX61/JaBfAtAv/y/Q/zpAvzLshQT69ScH+gXP54WpHe3TMuCfPADwWwj4VXB/CqC5qIP79VZiVpyKjdALB0hlyRybqpGYNSJqZ/eI51ULvfeCUR0yyQPx16Dy4l+HystK964K7TMyV5TLPGdFm0/rGJPpYD8eoN/628gMtWyrtsveqOSTdgDYkFkyqiXiaA62D3Ac/2p1+4Pt1rcbtd5AiXz+kdG8CkKzTx1DekOKHFpu6GISK/2DIviKXJYwhc4FweGVB1sCHj8q5v/ucKPFyOtqScErVvAFjId3SIqDnBKIperUYmKoN4bRe2hKpMqr4nfG72zeqg2/Byq8zEObzS29icIlSnQRAp35DEBgEp1iJ+T9/VHu/S41Uo4koa1gMJl+8L0XUyim0yXqdW4pc68No/eR8243ZJyYml4/9ZpWTV0iihKL8zqyV1GKGZuGN10No1S3I9FLXTsub0cWrG5H5ZbNLYp6nduK3OtfshVEx0D5iHwGmCpOb756LH+FDbpkqLhhwk0vrUsv9dgLZUOHTzOYDVOTZkrEeJZTJdXHhqiKlvQwnrlxf+E/xp8g96C8WbDPg88Wo/BX3DDXj36VC0aipM0Z6u8T9yrPSbGGpRD27i5/rbPP1seBTH3qWulLJGDm2UxKR3ifZSL24G9wXwBDNaou/0+0P6XFqV+VHAn4IjY5NohzdOLqF7sXb3CyuA95tSkDYd0ix5tXtdPYhl0OjHpi+XVk4l0Ts7tGrVAs8p6H+vXxi+6MR9XX96e+ErpbdL73XKj+R9+MFfHlMZj7L/CzeFSUK//JRT8sQ0iPNfuDyGQumLGG0iizncoQDLt6KLii53h0hYui4Fx+IOUNUJMv0NEM3cd6IZGCWpyJEdo5YHihmhjz3MgPY8wzt5/UqAo0T+4pp1OfPCRFmPmdnRhgTar65iNnhf5tRLxqIeTTbKSbZoex5wNW8CgsPHOJ14zPlom5iiceqhqVJ520O1txjxhgup0wNJEFROulWLrcBM5O3LoJp/43izCdpffxsKzgwXmC1st9keNlH1M27rP12UezoaFw19mavYGCxOacw+6FteoTKuYTWfSMOjtoma9vQGG5qtTmm+y9b6pSa97DvvveJ1byOvNRPyYnqUGt0TT7/iDDiVoGT20exjcqOBNi6uT7uBVIbowR1JXt6F1WfFaWUTkPMJ5I4yG87KZg1Aa6mRnFTqTYJvi4oGzQNx6ye8ggpQfUtkFX29NgL2/JyliE0p76Lk6n8EF3AWYfgY+ZTeA0vANEBIdILOslWgHO8QhXFCLvO5luCMFDjOVeMeQ2av1QYJqjFlTbOT5imdgMAXo5BGh7yhuwKn7FAg3WAD+X8GYhXsBms/Hmlw80GOc8XIBWMBiaOEdjduv7eVSKnJGVFiDLL0CmL4Ca+HKU87le5xLHhZrvc8Z9n921g7eivEWzT7UcwjuArQl3DnA/tvV7uAW4mlvAhmhiD3LeMRfoUYGAFLbwj51bWw55axhbYzRynkemJiQ/GfE8NCr5sX/TYBpVtdz4Do7dYmm69goT5AQ2BjdcKWWVp5mJyARLXgv18M8ALp7OzDbptpInzudf7e4mj50v/iAKzp09D79J0uglXEFX/tScW7u78ycOEMfzx0B5jX3UgAX9+QBL7yxE/aUT9ue/Pxgcso9ose/5i+4cdRDdDFqx6T7vLu00vIU1wsJ7nQGQRF53uScfx0NApe210EQs1hgUw6MExXQKFZEnirz1A3SxbqkVWecyIsZibXHkQPW0eGwBYK6Yjz5UAjJP7UZkqsWLZVIiM+F4APChy3/e3SWtdBL5QlPp9pcD+LywYMlM10lQqm62Wq0EI2+2hOf37u6O6e3uZq30Q5i8ZIN5zb9Z+F4UNAMLh9ax7EBMNuKyMhy8k6AHuZOs7UhN92nEzHNzBLI1u55ObgiAKBahaZzF/gITEfpeQ5lcKUjjdOmfRw615jx5NZxdt4aj1IybofW402p37G/YR4raw9ZWVBeDwakHFlwY5FUi8rO5LChWIWNb2BomyXhpzq7DFEMrZLCLh9jqj0NOyl+5Dsv7Hpg/DneKNmVQTJ0DmWTuiZEZqgQsJRbaD380r+HGvpt4d0PPEskKZGI6jKdMt72lV23zYcSOKhkNF6eTzL3+EY0hJckMpVWRBOjaYDJF+YvxaujioRobu7uxxnPGT9pr+yJyYpEVbTUJAlgeluMztNnTMcteCuS78ALh5t6hSGyardf2DyNsJrafjVRzwATR9sFoTZ564SWezWbcQpSWusPYpiCqrBTQunELTiWV2dPKxCw+S7NjNcO9DgYulkjKBWQGYIX75TntQ++xe+jt7VlB3xs4IeAVARhr+1SClOYPk7rTyXj8zdhHPRyhlYwv/aFEiDxOwntK+EBl3HyZwInkArKVScTKzNdOdAjcG3tN0yTrUxMOJi+ivZzjLYk9BOZFZGaWZe+4rbc+oq3XI4ANWA/eM8Hj+lAkccL1KJY0Iy3XnZb4bu5E/Tah0HmvxYidryeLE6ipzAzm+Q+8PGDcQExl0WLqdvwsZrJoke4dX63FiV6vxVzWh7CjFIohRUB/GocRMZsvpnDlYUKO/PB7Ux9Y2nSWLwfr1k3QfUZsrNeasApmZq9GmFdXknELFJkxD+9WFsticLzts9RZJeh3NaeEfvbrkcMPMEvrgae3t9MGOjpmEAJQaQCpxL7ab3UEpOCkDEwlQKkApiBfBjZTX6YT0f03aO68u/t6RLgpcgRWgGuWGVF/M4JLiBCaGdmIulU7YcqagesZFtmfPQeed2nZc+fEeUILFDkZYsek18KMvVD/xFrbAAaYX8Fe4l/lOOG2hh4bzffArvsxQJrBZm7YC1hZuDl2d+sL4UraSyrHgJIHWqprkTe3oRBrEbf1qTzibzldI8/l9WQa3mISiHHXBQLz7WzcDdZqqeXkehnfRnTX+L0Z7O42O3d3Hasr3p9OEgCrCx0CYBczfc/Im8V75Q+B6SdYSBV5hUQAYDdUqHFAQekMZtQUDQB0IKv5nU/ITRC8LWTP8WRR44zksHo9VQ2OCh4UOpAYLw/tn2vOt+uEledbQGtDQ0luX3Wh1rBnqNNv8Kwb9HtAHe+EHz1hIDuq54oD9EqXryfIk7hmnNqFBgPVLzQM1/KKtnE19FC9TzvVDZ22PfKv4RolOxHbJfMmoJ2BWDsEetAMgAjLnXSLs6lsZN1AAgr/cHcHhPBwPIbjueq7laOkINkoWUuMQTfeC/URwEm8HTmvRofEHzRuRisZYQAuPlhpJFxamBZklDpAVNJjboA0CvZexz7aaw1BAKmiN0Em+lrJSINq2h7Z5UUABIcO9Hr7+obSbfVqmFR84jw9bAF9Q5L8KWDpq5g3qFdEeSSM91UYax0lU3/+PdRKOZyodlJG2XovxkM1R/pw4s9muZnzltmSPI1d2CmtC7Yub4GELb18TeBVev08nDJtUunLUwV4Ys4X4SR9zpIQeeqz/pUIP328+PJbKEgfvsF4C7lP4jUSvdOXnjaIC/SkwfIn1cP3x/pG51Y/BvgxxdHIbNcWyXdNOCDAgZiZUwK4wkHJMLIYUqjD2PUL31zsoHSw6BS5FuA9kq1DESAF2Hj49T51WJw1vCMzRxPHMxI+u7szd0rjursrvysQU+yKzWgNtpXF0LerAC0nvoF1yyUqzuUf9ngjcA+zk1DEkwkhvZ0EbU8mcUxMEz/uYroaYZMwSpj52ABXOABqpryLmM8NLVAT3lDuY8uDx5kPhN4hqZM4cl0ziL2eZGNxqz3PpvKEEJ9ORaa0DojMzTnfFo7sONqQMwNuGb8jVZgr9/GEYWR1I5NEgOLSXHlh6rJ160pQDQ4RgZuWxM1BSxUzKWckCktsvqrdgNcTnARsp6otIFMRoUEVEbq25S7dtz21rQEQNmsLRsZWGHGYjkb82Wu2dE7GW8ajKEjJleePsqsuYAMuemBHHCMhcN6q27ETINVwM0lI0haPgDDggaGpH/Mlci9ZOY28gs7Ujd79YWTTmL9ZoD4Wbqzus5GKRMfYGDsPHd2LkRguYlnhXNwWbAbQ62wEr+D4hDF8uBomFMIBzs/T2WwajrKZ36X0fk16aYj2XhVvr26fCX5SWIchYfrTCZncTWeGHUAfk5vX8dMEbwScGL9WAZdeA6xfT8Ye1KukrrudL9pK6dbmJCc0UaLp+cttYI/F8vchBl7sGoLuoyTJZcoTqq0VXmicosBXSvvOnKx/Ojg800QCpgdvnDPpCujox5NYj4SB35z9WXDRhm6UFrX4xDGzuPZTLDBKJJBo51gjR6twmb8Y4akTMd7fPnN4JnH7ho0JgEyfKJyQN87pk3bPhNvnBija0o3ywV/CzRHp9GZbhLbiNQOoedrsDEo3T0VdKMdq4/RxCU/v7k4xe/fZE1rHfD/w/Q0SgtrLM2gAXh9b1mqhpDznNBQxw3fhFGNFYZUXkyk7vab4qN/aJt6KOG02pHMYQeL0z3HgdnX55jmTyIhApI33sMJ5kGdmjgbJnLvFj3d31NN7nPAp38owfTqD02FGrfJJQckk2oC365YBvs+d94DudJBzPHsBH4oUX7ueeNzbswhOl0gEnQCfjGLnHKixFchDWv/UPhs4ST1wrQj4um905Ha8Lh+PW+gRz9vh7eM3u7vH5i1O7vTQut3bw5HAWwHbRwBst8R4HYlX50pe1raP2P7snVmH59BGaQKwLCfOebP0voIiPQcmHlMlrtfm8u4O72uzltLtL3uJgCMbf3cGgFzn9slA0FrxBE2hcxcOI/r0xbCkMFm7ahkBR62gnJWzFMPlyP+BtflZTPQbGx2UdjNgKvy3JJO27H6RWdEuxZ76qUkau+qqLJUSkkammLNIWKDNDyVF5IaAmJfdozi0HLlCr21NrN/9NYe45kQACrXjjAv1NO5PZHv7mjklW5IEhZllgCDsKk5RUpI5MoiPIUDdNvbNz49O9QoYylHCLtLy4wJpphrTG2anbxOv+WlZoXuzxBwsL7zQe0V6aoJIudZ89SXncXETjsdnpBvROCLJduTghV+GvdxLiZkFdayulcr70WWKt9xY7B2XE/TaaZIapHVlU45bsTC7u8bkJvanzycuZQ5BqWe5VK+8cuVCrVxDLW5d+C70b7rl6qZgGDeJVrIWE8BaPLR0BQejc115+BYEtWetcyehlWTptZnbEV6UDwKJUKYdko1qMgDPLi/72npYD9q9bvO0cbjFET911Wy6ww8m3tIljC9kyvX1d0R9qWDzmvWlUfP2BVI1zoYy9xMqVEg+pCi7mlI5rJSlRL0EZs7PUuFjN3ns9dCR4mY49VBAOXQ/sJ81IhuvLAeLpFgLdu+lwkQvAwz0tDTLqEsSGUCDZ2NO5qPBS+yGY19UKEGKURSVV585QSN4FefEjrTLII8Y2x8jI5LAUTqo4jqowv26rrWuZWuzXAqQalI7lY69Y+5cAbt8d1dGIKy6GFjVdNv1XZcnU1ASr36hoK0WiNY2MIooBvEqNCYzbJAzoxFTrdSVIiVIQmVKmIfrwCpVKLkusP36UqwL628RaCzGM1wIyftGRikv7+dyYilwZ88qhKRTRyjL+7lWXBxUMbj8XYEiEFwIIH+b4uMEh2o9d4B9MRHuxQpqaIofBf2gaK/ywGr15tBmWz8jZfJqz5lb3U++mpadZ8FOJ8gmrmS5ZI36u41nRdpgbKINib38RWuUU8ZmNTeS7So2cbgo3FaoDnwMw8ieSPXv5lE/xjzhTxwXGOHVRsJXkHM50KwufHgfCrqwwVl+L6tv6z0nqDgsRbpeyRyAO3dKfJOgg80iNVV47uUpZU0qWNTF9HMFdd3aDdf9XXOtn9U1K/U4batSUHK/wfOypTe9nrAMqpqIoOQMYbdk9OqLmfxYVn60yjRNfp46GabNVBNyCDkyZ8HzC8okb7lXutg4/0WXztpFVofLTuyqJc2/JLls+VVeAFpof5gMbJPUUTZHpfZCyhOkclCTVZY+JAI9lpWGYt2UkrFGFcllRVletO7mxdaBLknypKw44vLopEraOydR9wIYGC6YWFduZWETK/dZkKNVIjRYw199CmS2yEnaE6c8FFJ9RULPv0X3nDcYIJpCzK+gQ5Yf+wOJlYtFUOz+JLOU1Py2IQ5wsSi5MlrW7RMn47BULMFVareciylCVT20Vauo2/anX4oqGLZq+tFM0oEwKY9QzKc8wCrkVdLH1PUq2PpbQYrnp4no8ZZk3be0e6QFFBqE8iB77W5hzpjZrUDP5Q0LCpPctjhZbl81CwMghNDvQTBzt072+wOSGx851VYUdA6O7u6ORNS9W1H3HWmjX4wnw9mXnzNrSICyo91d0kK8o0U5aqEjDH1r26e/PwBC7Mh5V2ewcbTGkZxjlySpt84ddy849MepL/s8bXYOz52j/rvfHwz26M9eZ7C3WAsrTChx+O5xdvhub08MdOo7nvnOsr9zTsiYZ+oDdPnC4u47FdKw913+isvZNL2DuVGvzrkt+nVmvn2+B//CAIQS42QEF8CR7UnbqRp4fG+/Xws1WM12ypB1ln2j2ZwmFkpaxyY3RbLflDciYTqIY64MEIqB28enh7dqYY6cM9QAwJaZN/0jgpWBo+6m4709W72/td/I30coGbbWqmloFtY817QH0GCfO5UnH3fhlg3xvf3uUG7TnFRzQqN4d3fSuh6m5hHRtOfFWzN3Tz8BkH7vnPOmXvnOTf/9wH7qO69U5I/eWf+VP+BE4+E756nfe0oy7r1FF+BsjXDWgJ6OmX0Cjg4aamMrb/rtgX3hk2JPruhz3+kcPvcfJ/CPmvlPWPq5Pzg0f/IfP/Xv7n7CcB1P/d3dG3z9+AJN3U1o+LmPLf/ks3bhk7V+D8O1cWB7C3vq12B0Qjb2e4uN971z+9sKsuUdnpztbfD1Eifj6GMOBmzzqe+825v5hwhOwv/hlrs9vGNuD3BSUIl96pNbxBFzd3i/tnGfSjD3noEb/Dn115uP0Zl9dj81idRe5LQ6mygVSaNwPsK0alSZFXSjIgPR7Ee7te7uXPKnMov2FHTrdfXXZyNJSqKQtSPEsUW8ycQbveqPXe02pWatB62WpgPGXfUlaaeZOKFXg7J04mxhUXkmLeEB+DMn0BRQUD2QmqaykFLz+zDL2i1MhykUc2ICeYOP/OZIm/4K9gLbWmvkO8zSc4TyMBJO8/1BN6MgVEJ/hoQnV5NFyq7FtRkFG6wftuBFKywn02y8CzSMbm9CJq3odS1eaNa7QY/cwF/G6CfUaQOUYYOTsd9Cz03z8hWm20IvNFG3gfnBG7/7bOWu+XFe/66BDkIMsLwGV3q0Li272RHsd62FllLVEjVRlFtzYYj2piU4fAxKkEaTiXJ/koYKgVOqRSPtkQXGdgMGvSIGsTklsSDnXUvGG9Q03UNB8R5ip0pKx6UGvRru6LPrh2PTrFC0NitVtNY+Ek6RrpwPmh6SctJxpQzOzY4d7CkqJHviRLu72WMnWYuFXOeoXR3emCPKaoN+ywTmliz3dePAuzuzzjowsux6e7/EUh4V6xysl2wSM7TdruHpmfl2RTfcghslmkgYe4KAKA9TiHpLWruscvwped6howyH6rLUTwMzYaZYb8noVlgyuhstGTNbMzS2pCBMVncQnekm5Eo66JRhRsgLycUDpReHwiOnnjko333srloIA5d57X5lCMKLPrAgA4Bu+gFEtU7vn9aQxv2MOWGeihHPnVPivxL4SwcHGjwlXmwtvDQr+DYEjDkaVtknjttcYpMnKKK28t2LoZcNyNBkq7TNReMtgcwsDZvxfVYY6jnlYHJ9AvbYM63HToVQSzdoss+cU2modTqZDceMSOm2NSuiPKGyVy1TtY+dnYrVQdobVid5fNNN9paPnZucCF0qO3FWUsFp3zqm6W1bE69mTUztfLDxsfH/OGFhLV7HL8UISVShSPsHVjRrwarX4/RrRoTqnFOxCaNiIyJik71IuOvaJ+zkdY+twxL7LmiH7HHhi1VhUpZVCcQIw8zhBG+yN+P8+07n8LTH2XhyKT0p7LNZASzNM6t7C6PZWO9E2BNwy6QjqSzJ37BluV+edjSrpYWmxahlJZALnP5A96uEwyxo6EPvcUQultKOPet7A3vuuP0E6jCN41wGdA3WH0vu6hRDmQgrTYEcmDQ6v+h5V41AxX1VgT8DRtbD1EcJXPiuDF1re73EeRL0E0CbXfj1FO4ImLvF8V4mr3z6EEmzubKySKq0dirU/bwRkktxTzfuuFRtHqCzaRV6kAr7HPZEvlHNis/uGNOiMF/g2tpsSPXV2ffDVU6fli/oSQsh/kN4UG6ZUbZtAiGQRVM2/mzrcKkw+1w+XIXLgfYtT2cWTnWebW3WsK2aIuZePZR0jdsa5va40hun/jITp7I8FLwJMzlO1gUczKdjLsxwGMMGJ+4esJwzYVFsfVRn+YO8OrM/prgGSETveT26/IWZPipjMX8kBS4yetmeYyJxvn/QdTU6IWs6nlWyMyorb3UPSFx6SeAncLBhXdfllSDkSqvAh0opuXRmQTaS2VV8gpRVB8V18er0z9EWuizSNM9qDdH5B43zvSeOtxc0K0BCOXVYLlu7QyGVixh+ewxV6yuSmgirsr1hldlQ+h6w93I8bFsyYbtWXBFepXafsCmxmbK9Hs1tb+O0unwazS1zOCyMoAz3ABGuHTF1AwkucuYNAsGjTh1rdOWaKO15wLcFePx1zsaoUvdfPDoVI8I7XIAGOccdlqxrOXFFNxsfmDYgRmxhPFxS4uWZcoxuNEMNH1n1pl3BvxbNCLzNJiHBWiqra+zkCkspz5daSRYF5+MW0v6oo1mx7jQuWnM6cnnTkT6wbwPHE759m3cj41uRVG/FvLwV0f22IvqkW/H1knZBVncLy15CYnneJxPo7N6gWToxrrYqXnlVgvutSrB5VdyHAijdsGpZssKyFHl0Aq0nbWtVhvJqMRKHe8JxOc/ww5x/Zn6SDyBPkFxY5T3OS5VqTdvLhDjyDEGJHC9IjXSzAnnFVJPiSrSn0eBRnUQk4tL4wIn6Huoo6c9eZwBYAo3MXeBWSmblqE3r9dprTfLhOUyTVx4T1/6RbIUH41ADY74/EUWt8liIJmAREpHa7lCxTcgaHHr9RKr9uI2kepXQuOyo2VwHCme1Wi1PeHpg00LMI1kuWbLyhmemG3tVmwFwrCic2fCDfxIPE6AOZ7rfcuZolhMFXhnhRLFe2yFFWVgEaGHhWsJisiwHYS54GKOKGEyOKwImoCRJAZXg0gKu3mAyg4AGRoID0nMw0UFAi6wCyWTVyMIpWsW5OfSpLpsqo/dsz3QBrKwq86jTyYs4h00DO9cNG46VlxFrptGVVgofa/xRLdfIyRx2lJpB+hybobWullhQ2DORj4v0bTud7icxYd1suxpi5BuuxPsY+9U6m8d8RA67zplQStC0T3tOjdCvVPJxu6q+07ZqHScsG736au8pxY7lwjCsKn2ucpcJs+TMzYm9KltwV7vjK0AtGoVOxfi4zB1tddall6tqRVU9cylxElEXzZKKSRIOT77wH21ZghzaKim4dHVvmR6srlPyFWnRnW4J4iNw3J6LXpdbVWNAROHtF+Q9QqvLMlZwd/fPGBqqxmtYOZMVVkvSUQIR1H0HZpKvZ6l1GKRA6Q8kwjgJVeMfKIOL5SiHugGi/FLdu3na5+7uy3YbqHTpBRXUyFQse+HUqkkdXYg+f5Icbt0aJ7AXQl1X2SLn2D+Kgl30xGjkOq63ULQc5kcJj2vFpMFEzMSPnVDSLUBExXuhtX9w18ZQTSIw02PXip1gryNJOe+Ja4VO0OzoIoCGjCaJeeTjZgdIdBX98vkoHy+T+1G7he5d1X3cDzDuJw0gs9zCALJNA3BhAC4NQPZ/NhJTtwNFc8aKtMPIljL3rbDt1y0R2soSwVsLUQcTJeeiGiyc5yOKGJoJV/XFobCEzfZCNu/lYw9q9pdIw9IfoGEfnxxay729w3LXC9X1cr1mcRpIbu0B3D6J+4uBFE8DtCdycGTlejjHvuL+fID00WMcgjWHbsRauk86augi7iKjgtuMimMtLBjBe+I8OcFGFLV74mDjh4v+CSdtT4gQgz6kdjDfLNWmhhMipZeq5SdOlm84GRwutYbZNBMgmhOd0U+ayW9dS5x05PixfxNgoDmHD4J61tc0UWs6Fwdk7grzLCGtfoyxAikD2vfD5SSbfRMEGLWE3rDfCsSPR+YKXr9AD5eTZex2Y7To9UiT93wSMS9ijEGG6pLCW7KeBt6Sgsxj2CTDBlbAXaujQV2+hQPuAmuwdJ4s9zpAz/c7Azgq/FtgroSVdmhHLGKzi+L0MPanjPVGTIW4UT0JJV7aRTvH9/7wA4aBQit4si1i4YWtQ6/lZlMMWC7M+YFqUu+wM0dwBJGz1Iy/ZSG6r05EdbjtT1pycHl9zqmzLPC0ZLQK5+ykJSZgrdRv5/RQ2Hou7+Mlcqj13KLc8f2zgXP52ep0nSwu1+il+NEziMylMk7e2akaj30G86AVc3IbfuOc5oLF2W+06eTcLo7ZAunKwRzDRSbtx8pa9JaLRd/Y51CzbL5B1tUWxhVhiyphgr6e402PZqzFj3hZndtHln3WO2fr2JLTgblc0gNaqT3yzM9WR7C4NlxkjbZ12c19g3fyM3zjbfVvaE/w/SWqROfOCo+EFl9oaZ8IuYWCALVbiCdvMPYKhnBRvniJ2iFcYDIHghU9a0mYP4Rax3d3xzofA0uwzPM1x5oNEQaA6WnP+FWgF/ZNPNkYWkHrC77pWOmNbronEdQbVV0bQjc3oDWzRVxj+zFsZG80agUCFcFN3g2QzDFPHbccu+NUxO7AFYWlhguF4RQiW0xNQLAka+ibkTmXvDXP+zQk9hP2ZCXPBGCk7omsepo/SKfq7Dgn9qk6zISZTnZ3Twvuk3S6C2hBq3bG9/Tm3jhAh7IzdvLXWvDZhc4Lz+FycWkhFip2BDmayA9mZC6AjtRDRiAFLj4D3C0wOMFC0STfY9R7wUjj7VEOTdA9HdlV4QS6T0e2EjV0b0d4XcR6gPKvRZDU+6WaoewRdliVauY9fKLIq/k8Mzs74e5uJn1pX0XOisXzb9s8ZQWGz4qDSfdAowP/QnMWAaT7AzuToQdd8YsJGRkgMOZSw20R4rZYUfxRC0OBAXaIWhi09IfJYXoTztxrM6EUJVBwmPqYAAeD+KMJBhyli/Fkklz46AZtdCXtk7TozeGCKhJynkzGdDQM1EcDtoNP8OoZvHnp2SvWX3duo0Fnd9HCP2urW6g/9dNsPMMWrgotWMrCJpPR+fTv3ZALxFIY2TScLbVsIB+A9cRcAmRceoGVLnhPthxXmkXRcLrsXk6mCZz4hjaeBqxo4zefrfT+uEtE8ytrfWl7PhzOMeWUAGTl+pRJQS5II8U8qbnJpjM/AQ4H7iej5/LVyrJQXycsAjXwj40A+dLD+vSj3BRTIrpqYbAxEbuQNjUAyLhwJ1EyJNjinXcDR+txkk1dv4vx9PHHmlU/rKyOP7Ero1v9maLfYPMEkVpDCVCnF3iht5iauutpIwg96D301tUVRKPFdhbhDJsp9eRPacdhOFPKtXIBAD2dzIdy86Ez9gvWzQ0p3ios6NT/M9mQGpjJsghSeEYFPImCst0qaPpslRDUYDqL9W/EUwmGGqIxCUxybIHve2jVthb7uVbRLVG3BkccGLxtwM+gmsE+nVIx1oSjgvyQYz7cSB/j9TBtYLYPdTIU6CtoZ4vte41RNsPi0XBGoszCicoAZNd6pE4+F3f7XMJYgN8FQv+GqeDnBs4Hf6wbJmF22gR+kMScxCmSE7rEE9TIL8CluvACKed8yGjVCZFjDiqWPxAnsKEqNGJsvyGa8i55Ave6QeiAii3M/Qt5ZGTnXqlzLNPAMrAicDU3WNUuLISHB/OSMgCnk+lM2l2LWPqvoj6sFe9/0ITHRD1SkP0dTSOi7HnFVdTr8bQnP74+eXn68t03Fy9/ePHyh5enGLIt2VpIUiLNJY5R3e1vRiJZXdkmrlG4L0N2X2LEcDl2sqnAu1rY/fOne1QTgCAqimc+VrZHnCQ4HwmSwKjOnWNISqFUgH+wDEZClL7jW8tY20fQRxEPCMygrivDrrgo5e2pn2LsL3cSdWBvsKNZCf25cvqhKEJq11AAyb4ZdgntAoDzXxKL6kn5PgCxCGPPfExACeT8eJJijs9J/G0WJacT4PvD9HuArndwAwDFhlpvQQNqogudsA8cj+LIACOI2/xN6g4TH68L01oLOGTxuSqC1EAl+ABzDXjc/xuhPagIN6MVRlYjHMj0jpPWi+nwikIerUrpJAHzzihlKxIJXfloYFK0JqUsBeLexYVosJUxaGFC9wMsjJZ9aThKJ2P0wgljoFGa7cZts9PGnH+jMVxI+wdtQ8s3OUxDRC2rqvpTMjZsA/AkmGB7AuOJqLkDltqpcdPsP/qS8pZSpieA3bp0ZLkUxOn1ENan2W9+lSwu2hePDuDP9Go0BDaZ/ts6+AKTTgHtj2Fww+F4clVcBrEApVTuwP0MocOKVIHp9TSMP8D4c5kCUQodBsvmyJ/dAMEnh11MpyaTJD4grXsxRZ3KkPeSht8w6pMLVqb7+o9/x60zNtZjKe3kgA5yWc9EzmuVuL0O6iohqzT3z/P59Fi6SG0tWKpCDrTmNyfPLH06/9+/GbmhfHwesV8p1RyDsgYQFxw/yfxy2TjfNnQG56i5bPAfDGxKGedM5tDJGvnTyGYorhvoeE0huyKGs4nKQOpg3aQM5vx2NW4MfAEXvUzQnUvU9yeBSwmV8o5C2VEVKhUo8xwTZUqCwCZ9AiM+IkcRMq7m07bjmuRappE5+teomB91HBZ2SZ60UvLRg6rDVZ0XtJwNtDrV+b2y+rJkIU1KE9K4bva/Qpi54X8lWqGEI77XRK7KsEn00l0hC3BFX57hxd4N1hyRXYeeB/wlHNhp5hub0oxWJWM/wm1BQBhoBGXvPpfMgzBNfY7NIprJJwDF5QIk5TFAZJL4qPewEZjMRgg4kkk2s2Taz/xka9GXF6ZckJNIVMZkVACPvRaCaIaGcBVpQ2vRWUM26mZToKab8WSGGasnNzBI+W0CZBGclebnWvl8Q48EXox6BmO7ANE09OmicTiJvuDqbew1/AUsmacvzp/hDDd+/ud/MVRezs15wFl67RvoK208JG8tZzFY3lrisZREZ0OH7dYXm7vckD9WdFSVcvQkkcnbHSMZLjH/pUFmUl8vSfNEah0M0ZppOSv1zPDdwMGwQypeXU4OHCDmejcyQ+teaZy3Z1euwlgSZHOAGZlz58nOPA+TnMpClLKZbGEoDnDnAeLOTmNz0vAq3FqA9s4mQmYrsr0XWq3afK9n/Pyv/xvg/+d//V8bsWKWJP4UJUaYtdoFVHjV7Ldb7a/8SMfp8X0Qa1Ue4IQRJjuYbnJDXmRA3rELgKMhja3XTCbjsnQO2oCGRJ5feILJ/9P/NFhCYHmuvdqDxsFuVqZV66DgID8Wt+qMvdNl9vHjTvvg8x4QHvH668sue/y9eGfG+/jTas0mL8KF75kda/0dFFOfCt8PrPWrry+1pJspS7pJwSAOWepNUvJkjqFcN0RySi1Ja+bEVZ95vJG7O+3diIUTL7wMr8J4hu2cUIuYLZbam02Xq8z59uT1Dy3WFxoPxtbaRXkYXxWjnwFnBbf4OLxFBD8w1jLPmbCvCHtZV+10aO3R1qqZ30a4ytXSCClch01DT182bHgPz9oaCPtEei+kE2Y8iZv4wtI6+462lGsNYl1roIm0u6wFIX2LC9K3WErfLg9VVfJkqq4I1xM9BWEcptdv/WE6iYFW7Rlr3gBpLOIZpglXSorQobzhs/wi9MRL1HCwJe4Wy10DGaYK4pMoKQJPIhSrSuvPVmHvsmHCn/XIwjTc60smdVdCla5WUQhX4xp9wqGqKyTVvDZ7rK+KH7jYOEwpzyzPXNEwuRSJBnfIrzHebENALs0GVjaLP8QodrDWaucT31Q0YKxdLSEcMCUyqcORl1tohQYu3mUO36q+jz913508t7mp78yXhIIqEdo3IRmhEAWQz8VddcW7k3ETh4XMg7rnRrDNRODiTffoXjfdDbLK7Ry3sO2Cpl8kgKm+zirZHkD5yDLf5OiOkBHhH8ccfPphbgEoPn6deizNRQOx1FzhgaYk7FX4jbBHdxstt5WMayT3ZD6jEexBNVOEbxv/8e/AF8U6HrONkaGR78m0II27uQawg42BYcC35s10mORI6yqCjXVAa8WxEiHHB65DLh083H/wKw7CK2uw/3n7l65Obdv5JcNhyzXTUPqvtGjYg7ZqYTS88i+y6Ti/cmaS2Ct4CzWoxNl03IJHUWuYeeGkXOvTwd1DwE4bjVyuxM8PgtaFVAjaWlA9OTO5JCjc+tuZnBrNwyZH9XKTK1ytrK1ngCPnwzHJzTQW8rmfAFHRWev37E8juOtQMROvLY2SGU6vyMgyhevZONSyOiqyNVQUHWoEOTPQtjRClKJvobMKI0J5jr9feJiREkl/wVkuc36VPGl5gws7VI0O+Ohyu8aMT4RQqqoZxsxxCUOB7TJ+g0gk5DKpjdISrVqWFzcpiMgqIaL7KRFSyGW5Csq+BSiL/DQdotVqgYKq2iPW4fKeO3o1Db0G/oP7kHKS5aITTAe0lSiXxb/LZv+gUnKawY4wmstA1Y1WYAMDXbn7fnLtR/50OM7fB78zfgdbiG3b8BNXBsVSiqTulcahvhVHw+BHw/2yJDbLZI1xazJFPrHcLntvMHpyp11sW0McvIkytBzo/eTI/lJv/GtpCvdaT6WL1tdSiJ3VEJCMAo62Zgj868cPQWi7NwxiQNspuEKBCIvk+QNpsBzoC5azYRqlnmyjQYxo6QO6GQD2htNk2IZlbEIe4rzlyQrRHCmAWM511sBxajPqNVxjEDhNWK7gMf11FkI2z5ai2NvHTVS1glP9/9v7tuU2jizB9/6KEmRLgF0AUQBIkQVDbLXdbluWfJNa3m5SQRVQBbKauDWqQBKCENEPE/u8EbsRHTExD/O0f7CxMbtvs3/iL9lzyczKrBsAivJ4JsYXCZV58uQ985yT5xLKbv65b/PdCN0M4QTWe2ocb1/C8UYKisXkfMKZk8xI3LGRdp9ygiYmwYs01C9ShpC3qUSx/tUepnjv5R9ft79F1dzewWGZxcRKJLc9KkjxxWw0SZTyG01CqY0NFqIr8Y4RDeYhqT1nDzots/B4vyt+Q6vLOARLN/ou+7yCCzuhjZNrKcy9j/gA9sNoNvKWx3fXCoExryGqulxySj8lstI1jc7OkZ6h2rVMny7i2SLu/mp3+N3tPDjh4Pz84yLciCmBzMe0PdERHm8gO4o2erQYDICaNfa6EGqGNbFZxQwKqjf9+Ki1V0B88E1rNugD7VtesGQ/zLfaIrnQRNdfzBJqlKG1d1D5DpH3HHqXw7Gg/u9CJSyYDEIb1QwZNFhDaqK/knpl0Lb7Aof+17udf93Xn/4eU7KfdLBN+HVYg6eYT1HUMv9yuyrzwDceYzll9CbMvesta09Bbqo4Ba7XORqNvwzn6PfjMpg884D4HyyfR6VjLaG3Z5rTb/RF1QJfM44qr1Ptg2M28MZfLObk86K0cZEB+p4NTNebbh0eo2GqMXdyotKRUcke0h/kAAgncB5/F18E8zs8BoB1UmjzDwN5D9xhlYyygELA5pCJLQY4vPueKtSbqv8c1lTe2ryjJkj0iWqlSZTqb+g5D9vbi5RbtxJEtwxB9EfAQwvQ9BPrRiwiBFG/txoHsYdGh+4qnk5wX8F3xVaUFn6gnjU+g7qT3uPqauyFZQdCkU5m9rXyRBfvJvIKOObjKeqXX1X4XqGvsysR8E2tj6/0YqYWYwrjAGc18PFpKbi2vkDtr0lDJJ55MeqsPJsOvFEgKFJJldSASJnOLwNfjs0oHAaD5QAFnvI4h3xtgMh0VhuhVCOFjarFWCtcRYXfw0Twkoqsi1NVRfJTnwvN8lRSSDRgw3AU4GRoyrjC/uiNyO2RekQCuX6D5AJacYyejEIvyilJmVQuAeNig2s/Bx5SCRr+ZjB6coPhfRZcBaOcAjKfShnAXD5awu4bfz+fjmdxTnHOrpJSigYp9eD7NUCiz8w2Kl7G23pW79yc3Opkag3DYORHpMIaNv4yDSfVCr1p4kSvYapxjnDcoWFqokmXx07oIszLn2ZUJOLDSuos3vVmrEbQJb1ZV6jBPIlh6U6Hxru/EHazinqp+nB6UA1KoYKqQxX7SVQN7cOm3HhraeQ3EYzDbjT/Xd74H+w9YBMTuaOUl5YFS3jpZ1bUrSe/t6CbkW0l5n5NhxytfNiWaFGct/A56z/X/X+u+/+A614axoslpuyP5d3O2YV0Vj6Pn7BOb9J6m2/gNkJ3NBFZU0giQ/cZIUVoskno8AR96cca6SeEbLlbciKlYvaiF+a9YL35aJVNX1vKrLUakR7mB9/PIT0io9boIkKn+/yXlpz0/Fj7rQFgkyGP/pIUcdHxwKWSJf6VSYjCMg2L3iLDzW+RSn8xhZa0phbqRTGUb9l0SBWXYaheRRWQZkaKPSdtP2SQsm/WyYHJ1tb6kIwMkbQAWEsPktlTrvq0byfKDpq81S51dZLDr2D+B+ZX0puRWsNaxgZnkr0JNFSopc0lhd5HLTMmX8KYiBdShjTGg3ziFh8olLs1WyKQWlTM4Evk2I+WumuEhEXRvCVINoWSAMkHnoVk/emtKNqXWrsKN6ewfyIFRPE7UUFUmokocIt+FwAHF9h4iSeJT4bQbNjO8CWhVa2fU9RItOatjKNzkoLlzvqHllZztwovce2hQYEmLw2ZYZEvDirjQz3EKNO0fPGQOcx3Ko43URc9pidr4k4r1xGXVU0r7wPUTHilZlpyHiwm/rTw6KHMO9n7ZXte1Vq44akdRXt9IsK4wt0r+G2Rol26iQyIrn70Bn2Obh/mZ0hBnDE5IHkGBFBDgF+fllBztxqCElXILXRBTiqf0oGEGiHcq6Rfi8lOPav/W/WsSLXspFIv7FsUxGfCZQn3pLhfUSGli4gjZd/VrgnBjm1VaqR5wPmC8m0fv7E+lTZEMrXeXltj2MRFFO9sI8FwEhKBJyiygkWtV0kXEfbqtRQ98WSTkPEsivGsyiWhOCt/LJTeFXF6QBtPyV1i5cEDkSRVaCtVzqlVXGBxlfUSwdTsg+YH4+NTxNllsCy873sWuggUjDcMkOaODJcN+WFS7FHiQEzI6QJ0cvphjjmTOCPXOmz/baePwaRZxfIKLJ8cZVv4XCvqcpJQsE9yXLVxGaC6jitCA6TiTkp9uh0LdsGtTIJFPIfqPtBSSb8STJQXOJv1x/Ps915nJyEsGftUR3Utl2gwnQXHhWyiCScYQPUWtf6F6UY5MtuoLH37CygdSnK/HAtD7aRM8V40U6J5kaNqyCtg6woz3hkySymzmMre9TF/o5aDhktXIRCPVk/E9v9xMSqtKgtdyG5sZcCTg9BQ+hCtDkZ0fjzDVpQ2T4N7v4bl1ZzTMukdskwnRID8UgpticNK7UyxWW9CXAv5tMEiKxH84PIVem0svMTDCdF+3PREaQKonz30rqJlsnoDZQzwqd+apwoqHQSAuc7Lk8oBwqyISTEB9iI5zrX7OSUshCbgsBlTkpROBGQ4GQXuYDdKXn7+r//31yV6mUhfmTWSxUdA3A4SfexoC2EZGQOh+wi9uN1p1oRpkFrCeeO2SfTOkP/v7ztIywjjKJGTFbveFZVK0ic9Vf/033eR0SlHowq/qH8cDuZTvQEksCtbK//6v0Tir0xKRz0pltANFvF0OGTJGv3UePSM2+JClQ2EvLNdUtZlrerCHnNjTZNW7bXJz+vghiWNgMZ6/jeTwYiGFvfu+Fh/p8qZSvIcXbCLaB6NLfSBOqoqLp5FbOeu3fSXwK+HgzO4sC/R1WHe6o0WfbgetFc5CfyLLOCk9luuX+D7z89RhLihz+Y0F3V5l+NSVGTJwhbWkRzZ51NvJDTASk4KhPr0lzsitzkvsEnlLgTL1A4mDRbLhFeaSIDGIqV8lj8WW2ifRbEXL/LUxzijxz6D8CcrdcGVMJ3ngPeXBErZQn0MKoj+GAV5umaU15MP4AQlCpEcu6jU9JLLKKD1LXTEnF11xAYcrqRQR4zXZun7HoLsQrIgvPm6p7lffjXQnID91D8B+lblfd2vsrdF3dcBFcB3/+MGtef4eGIP8AsbC2ddSnBjpdb5IL262UPixDQ1UU343oixgXWzlajErqmQhw05IFDENXZqWsIiHCgJX/gW9ZEf+JZK3KKp2gYORqOK58tkIEgiqzsUrEygd5XN3gRnI3SFQy6CZvNt3bedxA50HPV0S9Tax7HpvijlrCGY+EWOjL6eSO2dtDs0NH6wiaNt1RL6dba0IL2S41rYcHS4qA56jynoPUIf8wiR+UXFcH6Y9f9Ew/PRKkzKJmIR3QNE2pPh+o10RPzThRdbGErQIg9Oo+n0En9eBhbMpx9GlxZuRt+LLvpTb+7X0Tm2pSI+Rbr3Yq0VJ9YFwsGHhcZuJ9bq57/9z7X4Zr4QFyT39Lh8pu7Wa0r+EJb4TixwIhNZ1/MwjoOJHKd8W05cFgWGpVlrb1y8pXv4OONxNBQpuJO59o/6dqLuLhTYRw7swHEwnlZlXXKfhja7Ug188rP8cnp+jv6OE6fIQ3vmhXPXty+Ajx8hLw+gY8j/CpfU95g3U4feVS/kyDw3ve8xXJS97HlO9aoRh+OgZr/gYwAllihWYU/0+o0z039XfT0qzto+8e3Z65r9cjckHB9LFDZPWrFAppPnU/SOj2eA+8IWn88C7ypwX2qb70SnWdACKB5cFPhGr9jj4wqs0BMWhI7Dm2o4OZtewrlg5z9CnjmHH9u0q9gIFU1EF4RD+YiHbWQs+oy/UqfyWt6TlVrWJinXlfi1ciW+lVvWwVaeWOnwVD43TxK3thjnzmIP5ietw219TW9yeicdL19DVS3Do1yuK7ZQuOsuow53qPLAcHktb2lNxYNXf7JjnzflhnAr9bpL/5WSqgnulLjz677w8Xwl9KpKu2Q4w7M2szk3DSQMSnV3i4j4nBaf3DRoImw/LeCdOKgTSkdMcuhI8TCDhAAynTHFI6SKi11dwBrhFnht9vEP07kRIw2gPnk86m0yZGi1hDScOFXugu5MXh2K+PaNGoN4O3JckkFvcSylzM84gs2EfHjwhz2EfFKacuHP/1ax/R4SB+wzvOejzuhwbR2TU1n4cf+j1QDu8xnkLJB2yA9UtAzQh3ZZYC84cN6QF+3YCI2E9xuRBlSNa4JQaQPAvuoVkCly03y08m9jHq9pbGSiKqzfyKPdf/dOjtW7d0iQmJ65M2v4SmzYWbIGxon9tgDnsBsjdBhoQy9kaGCNMPtodbW2hA/0GZzBsBfeKMzy9LyBmwoDoU1n38+nM++cpORVoISqA7iippNvguUXQGwLOBmUhSQHDx5Ui0oihag1PaEmPEcGDFLD8O7dPRF3KIzQVBctvGoyqo8KjANnU6Wr3quVKZi6PtMoQorQCKWqtdoxajN//eI7aSKGRolAjUDlhDZpXQh7hvb0JOv39ApYuBXFbnQrThP5N3pDoN+oSfe76Y1baVpNy8H/KvpEaGc03qYUtLSuyFVU/j6uzKfIiNSPmuxENjf8QPZpyYsvoFW+W3netlrWs0fWvvWsbR1iXIM5cMZA7nOcS4pqIFN/Ep2o2BgE2WV6l44PNRCRQfTJuIgL2PJJmB4aYZhrdOozlF6Oh10Z0XqVnB18bJpHC7rGUyQkBekb2H7N9tda6KeBiLCIsaGJhKMrBUn0YtXiGmaFk0XQle0URTmk4jA3pGJ1qJN12qkHhVUosbxYirAF0oX1bqriMu6zpcXOWjjsVJuiZ23RMSMyMZ7f3CMovlDFk049eJBKFS020QzELC70LlDUbnUjygDkCYCrQ3MUtEXesPLFgqH8OJ6VtiAG+s1iLIyBMYAaG/+XGUZgo/dkaAKMHGzz0Bs9QXkjJIS9CpIGFcXiL+yBweKHtW424hQ6MLFPANlrEYl3Rea0w3XvhwgFJfzpY+SVqYf3E3AZHEhstu5RYFMKdK7CXhNlb5/c2EtTvAC078kL+6WRiLvqRRBDzh/tayPnngOJP9hfmeCE+i0nPUe2CbvgH4tH3+j4+OQ1sBTqG9iSP6SAYVe/BcxvIetPqSyxq5+qyKfJLjzDXfhWngaXPVi3Z8xQ/aHWvdQYm6e0jc9k5JlLteifwhi/tf8A1f6UYpSeQs1fVZ/iJEDuq3SjYCHe5ASWe9uVrb0h89/rYP45sMlVdRm8bXCYjuoZIzlLTg+zQCOcDEYLP4igDQL7vWYXXZHJppsiFUZUK0ZiOKC/52AcX+z7DfRuHqS699e+mo5vej/0q3NgR+Og9wx9lKJCq/tK6gPCFuXwyiL0LklormQQYzuI4B6DNULx1DGrdWgjMRINvInrNLE4hubGWLsw4K9Onr4+VkGJngLd+jLImZaXNHhy0sVirZ6pMb5sXHjY4+NLGRj1ac29xJhs8MO+XIs5fR5ss9LQSdxZr9k9+0z2uXv2ac9JFt2rk7PXOYvtUi62M2OxvYKKn+R1Sqzo3nMON/601j3TcFbjoCFjGRM9VT2zV0AnnON7mHoteVm97D1Oeu/Kobmsic4j9XPyPIC5hGacBT3q9Uu536uvyAQNWvNUXg5Y4Is0HKT9OegNjxv0okLbe6N4ch6MPHwgYE4qFZZLxn7b3m1sfig25qQLArClORqOEZVXJYeIu31IqHybwt2CF/Fjlc5OkkJQJfEeq2K80+qpPm3E3hxWjtCGNYLAbCESLI/4oblXH04Hi8g1UKF4ZwqUMGcJdZQ60W5a//8c5IRYmwo/eapXurSeiTMoJ1alHIpUoae8DuHCTypL0ipoxfmUBfBPhc8ycTMfv7GAXwQiN5WOho7COs5WiGo10+8vKfCoqFHkJd6ejWA0L6YjGBPUCfPmgwvxFBBZ1WjR59O6hg9x2PAbcw6XpXN4XT9sWnc5kXpj9eAzu0xvmTccGQ0rf+u0d946ZVHEdOBXiar6nlWx3yafwRXZO84DZU/64ME3qdBnBU8O19V7TZgPZlGBfVU41hziT0t57BxXImKVrJ//9j+sASJBAUA4iWawg9/YG48VFRVKTmazsZ/Eg6MsjgX3Ji0Q+Gj1zboGNZTmpiPKvdHFrhaLXa3cssA9fmxbutz1zbr4hFNWiUko3XfvvtED5B5Xfv7Hf0apzT/8S/nrc2aWk/FPXN4WvRmdBRvPQm2gsyGu8vZCJk6lrrWBDwQWMhglD1lf/LKNgokcebMoqCiJ4PiuolyOmP2wrsN5QBGKau7stri38Ds9Swx5TbeB82DoXtm3if356sPE/pQnP8kXrfgisARVLCL1VdI9EDtciG9iIgRfTmNvhNRztWbPpiygSaipirb9GP5VOI8X3giJ6gjof3FzKuoSKGzgCYB+pNDc986MgNiSov0TUaCSW6rZQdDT6dsfYJwuNT4795mogtxInaqquKJOG6cImjkOPLiwAsExyIMt6Z0MH1yx4+nMbdr4DgJ/KQlX8+OKrWRU7hv6iYpQf6riVR7BHRqvZzfG2cRtGylB9VnyjveC6OWkt+pVj4neIMlaJ+LqJwG/810a73xBYDz0/YScxlOUTNaEjekfxfK6lIFbIzjJVBhVedM4qZrM4KowncAjMI2f9yo6d6qCOdGFEuteGKIEtDeeVSu4VStShLDQRAgDIUKQUgZaJJNa3gSntkZ+BE7YBdI5VeijbgoJcLbBd4sDSNTEZ5Ah+vtwp9EwOY1orBY5G8rvLUg5KTg+Fm8SC8E5odtLv3rTe3wjqUy8HUlYVBPAMx2YBAc6+D0FLqmcq974uIHz+yOdPmRfzKg2smjvzZkppmxLDqyDt1v7PULytm/3INopila6aKgdo67uRIfFzPWPG0QNlsR2LF/DTloX3WeEuvp5yWNiOXmdw3kO4PDTD4SSDn4Rzg2lHBLmrw0GKCqKE3sH5D4MLSsO+k/i480+QvOib55UBAbgAP7LoIovMsJ3v5/grmEk9H/93xXyoODPCqHUbCwaqPgJo7Ndq5TNAbMNqrSdcbIic15vG41X6QsWvB2mAwUbU5td75DKSxqqidnD3XH5NBc3wMltgK+htk23jJDCni10mOxIXMzgwmLVoJde3+VLDN8XI/dkFfoyQazbn+iDhZVXa5sAxAuGgvlcfjMYPfwwJJ+4CvCJ+GS4mQCiM13BvOAvDVXpXtn2sCUlMeqZIB3+MkttY+Gdp5c80UjQmxJQ0UMBuewXQ3I3BeAMATHBFXdaMk0GETJwqibCIu3G0B8hKXIysIfZN4UUZVkQqTulG1jBZ1KK22HLVw8yS74KgJDmJgFl0wB6fFKVRbgVa3xfw4ghPqfCdU9IfOTdY7Q6mA4tcll07Mt7X7qb9+EwifhZd7qIU4hlQ9Z2a7/ZJP1o7Q12I/OXv8WEGiM2ckv9gF35R6X1qCp68GBwPED2AwfTSvyOohAJBsn67gXyNgGwPBMYm3la2VHOzLH8RcGKtW78/E9/t4ZeiFZQqMvxdwvhBP6KUs+bOj3WwsUFj48JwndvtOjriv7Ic+BTJb5KCF8Mmn7yMJdMtj35aCcI5kWWYB5oBPNQEMy+QTC/e3cvNAjBOyBGi6heanBC8/ofnub1UzTvIIfmHfcGBo076z2eaTRuWPsPS5Pe4k3ghxHqsLtv9sRCjPYwWPYxXG09HkNDZlgi5fn5b/9ioXQPxY3igN8qfLmOQ/N4XNCwbVsjCu3ehJ//9n+2KpSNUbuNEjstH7Js/uD8RIhytpSAuSx8rjJmmDonY3qyeF1oszOWzuHG5uNFWp69s7amQUUzakUkly2LPV5x8Hucfk6x8zz7l4gr0whqW/BDW+qg6r0bpxll6KOwU7czebrjRM78XvgQf8UuxOXAv0Fi9gpHIQeInpZI/Eq5v78Joziio9DdbAiZNfRGMSPdTVqE5hIfijkIaNKsyTSGY2sx8VNc3nsQ3UViiB1o8TshoBcJAT1Wmo73jOHnrl6PddsmMbBKiDucztnsQz7MurnkeEb3R7GOBn2+Y0ukJ8WiRtxs14g0kX491sOrZ/QK84dcDHWuKZD43I2SMaKu+0iVGc24bGqDIVbD5TJ7dp6Rr1wUEO9V7EAoowgTD0ChaXtrkOoAc9XwpQvPnR0KyzPQFaROGtlQIGOVaCaVLhzSN/nuprpSli84FpG7+usimIcB/ADuYxQgb+E6e037fMA/D5vNT5ygbcN4zZfuPQfl62hm8d3kJ6C5ptdf0oMtqvpA838/6/nwjY1pIN7lC1IomM6rlfvz6TSuMDH3+1ktvphPry1sE/E6Il87I2rd+EaYuf4IOVUoA/wVENrzKnfz2wZyRYP4+dQP0rNX/fEGF1WIg3LhZFykZGa76osxw39r3c/2OOLd499Y8M9n9HYAm3SEfCr8jC6CIK5Yg/k0itj77OO9T+5ZaPJzDWMyiCLrqtNoN9rWO+v51y+tZyGs2SiAr4s4nkXu3p4Gii4XrE/2fjvylriq58CMzGOYj9Vvo8VsNp3HkVWtVuvXQf8yjOsXyxmwlJGLD+O1moVvgFUcs+rYm0M76jAiYzec4AFcg3zYylB2PH1bh3biYIgsrSA/487P+9XhHFoyD3wL6BSrD8Vrq09st8/+Ql2PXIS6LpJ+PrRyVa/H13WhtnvjitOgq6cuc1PfmqnRZXCdLk9pqdLSpzWyhfMocJucDOdH6OekK6YTn33IrS+njwLmqQzc0r4sVeOFB+vbRZ3m+034R09l3iUPvu6NZheeiw9InAxsfRAXINPz8lAa+WnEKBLIK0TpZfUVFdRy84tTFkHl1DgdDrE4P6A1ZzfZLK7y/nA4zOblVyj1QDLT2B8tUo3vkynLBA5JMx2vtbkXpVp8PveWEYZNMZMvFoFYpumRgcWVQjGFBRnG6VXqxRg0KlU8CmahZybhFpKdLsrImyM9O70g9Dx0fStLwtEsjpcYA1avXDxqbfdiGsWwi+kSjTw4VOrofAUGm0KQ2Nbv0LjiuTd4Qd9fAphtVV4E59PA+uPXFdv6cdoHshPSvgpGV0EcDjzr2wDV9K3Kt5BhvQCc8PEE6MKRbWENMBDzcAj5T7AiizRDrN+Pp38JKxrqnJQXy3EfKS6BWS/YFT1AMsCtPA3i3809WKLWc/gmHF/Kn8+DyQiaC0QgLCQvsi0sQsdKV8oi+iM4CNz7vP4wCw+FRmt/HowJZgIH9wSOlbHvtg45UVyndc20wRdxw9yGsx/lg8ThGBe/JEncwaIfDur94G0I91ujY1tN22q0bMupaeWpn0NvHI6WLktQ1NzpYNitIljMq8khW4Ru5Q8B0IQwODvOfTKbauyUehiMX7/pO61MjuPed1rOvjPI5LQgx3OC1qNkJuj8huRhq9NuppKFLNG93/Lb7U5SEXJ47v2gEzwKhnoiVOwdesNBU0+EOg8GjzqHh3pi273f2e8M9pMlAZPlDZDmzHQLjZ902SA0B9oTBGmAYQgt9R4d9oeekaUcVbj3jzpeu39o5EoRo3v/sL8/GB4YmdKfJRym/aNWe9DNDUsPudB0Z9/IRb9e0PP2wcHQ6WbsuKDI/lHQ7KscdKvr3m8f9v1h0j4VayDTZUSSwZH4T4Kso0dtJ+kM0uAwyZ3+oZckSieF0Mymt6+NmhaNFebJ84PDZjfHxS6OSh/WTTcjYoScw0fOI0cdiH0vCpDKEdSNJHYSKqc/vcGzFLEqmd5NV6zOpsVXEhNfQHrMPJ+oi+badVFGXY8E/Vvnd4Rbo7uIxyN5YgtSkNgcPObrno+8EV8EwDXz2d/p0s0p9Hycxn43eyTkHCz2v8tLoCY6F+D1i6MexzBuUW43UzD2ZDofeyOBgaQYdEKX4chCKSxqdrxZXWntiItc02lcX8ylClazK6/5CxiYuCttZeEuZ1rKmd2svX5/7l4DQFA9oeeS1zVzIfgoQuBjaIFMEhl/+tM4DvzuJoA1sEgXLfuibV907It9++JgxRcL0xDcLEq55iaLtLW3Mpte1CKZX5C+7tt8oK/0Svr0IAP8K7B3l33fjrzxDN32r4rXcfrmsxdhXV3ytvXiS6QE6j8G5yikUwQBJHoDgzCoPAv7AbdR0RGfTxfAQs1hdV9XNNJhu5WnNez9ll+CqGQNJpPnBON1BGkjbUIPmx+v4XKxgb1cIVULW3ZUZysPPA1xXWgYHu1/bBwkcChJFTqpH4jY4GCD823s1oFYwjoBN6rV1Rv4BWfSKFjR5If8UtaUqzx36Us9Ulf+WLvEw5JuODINYicAFxxXw+HcGwOzuxK8gosKK2u4Hs/ncE0U9XAtwjKsRFx3dwQXWh3lTevpyF6M7HEwWawokXkPZLjX4fjcjq7ObWQ5p/bAm1zBavEWfji1uRl2MO4Hvs2eqtJ1j0PfHwVdWWN/NB1cEkpCtxp7N3LD40Euxpt6w5eHTdYANl8p9nQWo371zMZxhTPFoyk2t2tmVRq5OYtN5o8gAcksQQHLZHO2JAPkdBNlb8njEQsnpnMOHPciKr4Tf/3t5uXG7XbDqHoyhr0Ywu312j7BXfIaRS1iPvLOsJ0QWGx5shKXf51FNnXSOHXR91LROAoRkIAPJr7bIWDNBmOl+r7WxEskB1IiJjS99eaoiCAZwpm3FHVImZLghKAuVEyIgHXh42Z2U1uZFfK46mbYes0iVzcNQMOAOewh+L9WgCxrSSDwU4613zTdtgD7q/YInAnYULkzYXRkt9mSRrudVjkDQqdAUgaVt+oerB60o6+TftuKXgsE0TW64CuP97+87czyVDTww1gdRWICUQZeBFvnYJ30wjiDgdHpznzkcjXRqVMMV19CVxn71kXgToovdizjw5LarcQF3L+7NiycLGCCdisUwQKAGd21phHcFbcqCUvCD4PxxmIorJr4MDmzcHAJpxPcpLCCYfuvDDpfXJVA+AgfD8xysKCJlq9+mSiSEq3XOQNOJPqCnRLE8gNu+HEYA82p7QUGLzqM8gBlV8LJhA5p2CkCWr/sEji40UvgTtg5xGudGBBpvcUkDkd1ekiAo1XtLOz/vXCMhw8wr2vt/pH0pHHRGPSveYewWxFXuBXRLhKUYEp+DqvTeLg0tZymhml2JGOKPDN8T+KoK1IW0KWQXgYaoto6eUCIyEpuZaYxsoYkoFZXqNeP5ZcJUdW4YmV/PVMkrRvSPCKxmJAp68YwvAn8JIM+1w1JDq6yBGIjgtP2cpnk8Pe6wRLn5oplyk2ZcCOTxGWW5CxVjtwhDWSUmkRtig+HPphuFpRAjXNalAM7aVA1s61PrBbAkAgZkLF79KZMcERCGiXntkRuMVqmjQGvIJKbKun0pA535ulrRT4jr9dAUxSAZouUdeNt3Wmu3rKZC9CGmNBKElqU0EkSOk2ad2zEBBono2gsxjDskGS19vivdWO8hK4JqoGHM91DgGibEPm9bCMstPm0sS/Bi0e6sc/Qjg6aqRkHdiOqFkO2N0OKFh5shjwQrWtuBnWaCDtPWrppJaC3QzWePOWZjvcTdAKkBN8owUfLJYNtlGAjgFJc7S1A2wx6ii7kYOHqBfZx8QqTASODzmsycKoPRsDGQ0XyjE8S3baSH+CVJR4sJaWm2CYNpiut7Vw+9mFb4RpdmRxWAwkplUZUVQM3hUrCj3WDURhXBR462L4UZSaT62ZteiKgq+/L2yp/HHETXNSPyoGOCAiWYikULUMA62wA6xDYKcbMgZkTsC2cNExt66ltmfpIT30kUw/11ENORZ+DMgk5WEyb3agUWhvA4mLxo2BMS+dGki2QkOQeNK8uzGxMSfIPM/mHRn4LmSQTgJIAgjV5dBq9KVNPyfshFksyW9S1a1iuzJgX74hrmO4ymH2COWyWAh02CUpOEIO2uAlyJjjxkUw81BIPZeK+Xn5fITjQgQ8UNMVWUun0xRntlp7RbiUZB0bGgcigBZBIMHhCRLIp3JBZY1/LkDrByRtbjefmWsyYeFnmDQ3nHv4FBG+DtW/ps86/k1QoqqfLwkCrXa/ULwRX/sdW4v33qOkH50A2SBPQVWIMyi3VtB/smpVOW+akvdXTWO8hkwLl1g3Tad3KJDb59OLAWA5f73EwnqFtqrjn0d4TqO+46tgwbjDC1abtDOe1ml60VV60VVL0NInHBasgHw1BWAAhRhzSecD9cB6IN08CFfnIya7ULxf/gHOW9L+kzI5d0ZCQLkoEeQ1dScwA4SQJQPITI58XBhnyNgwPsgaUSFs3pP5ZP4ivA7gplD4ah752WUFF5CbgomFpaNk4zcF1BoYaCBkw9uiwYQV/Zi54ykGyCzMLzn0iuEhtvASqJWDaJTBtAXNQAgPkk+DIGioM/WNizlw0vKqT4hXwZAVKPTqxKWRe+fUk28bEUauZOFAOlo+BUh2rXoIq25fWHfSlaAJu06sSXFv1ryHi6a14N9Y3T/5SXVBwdBI83jBynKQ+VuEw5ShsqZdQ4olY3q7arCty1fTHsW3htBsXiBB9jNNNqdVSTckUzQ5pFkctPRaMM29AjEcQ3SaGM5BrFRaQ/Hqi6N1gNApnURh1KZYazyvgx1MzSxQrnxREl6tsJspVpqB/i0svzfL1pcBAYo/AX5lSc1aaUblMBZgg7Uan2TpsBW0iRThvZejtlU9u8jrakNO9uvX60NGoVjCDvw0ihsyi6q92X9zG4tORqQEkAcE2qAgwB5Ei998DYVtDeIoWXafVrOLLae309elep2kudaENc3Cw01vALSvLeS5IuzDm4jWrk3k5KKlTqe8U9JF1eu6ujxvq26KbCsOOPZWqSFhx8ZmlQ9bK0JGiUcGgsebQ3Q1aSV1bDBi7ti4ZLPPQKjnH5XmTYEl1HB8eAewcOqEadqoiFJzaRT07cw5PPz7Vm8ezlHnZ5JFtBTuO7Adr0PbDnw7DsBbjlHcKKGW8/EpzNr4qUctFq1TXtkCoYHNRaQpqm3HpAeMJGWmmnu61mqvcR+v2ztNagnDD1FDJmtVq5k2LQHtQ0M6j27bz4NbtPMhvp9i5ZZOQ7F7N+eGGeZNgZilnu1KOWaq1XSnk3FjjREpPVuJzGALHwUnrBrJ/8jGqmAVEBrAcqk1QnQ1QHYI62ACFsvYZynJMZYcMl4swyOSmwMr4XfRtsB18i6Hb20G3GbqzHXSHoQ+3gz5E6CU/o5gvwSUvKeiHMwWdGb6lNhZlCFsM3N4KuM3Ana2AOwwsnw3MIvRwgFE/VHrekxBGHFMAm15aANhpbgVN0nMEP9gOnNZsXx/QTa80s5E2ohueVmZImztC8mqUcVgAS4ygECppyiVSnqRi+uiZ+C2yqGt6HiWsG8o6YFVmOSCQQAtxjVIbNWXDZmNftfCUnASl8vVcJ53r6LmtHPQtE30rk6/nttO5bT23k87taLmtNOZWi19lyXIMi0PjAMCwJ0MF65TCdbpIXplMoX0SOXO0s9MTGGWH3kpMMzVMTOu6YVq6bCuvbCunbCtbFuOsZQtjaqY0JmaanVu3k1e5Q7WfnijJAooxTl1vsiQ5CmAxclQG3JVJ1PgUTJIjoVAJFr/r9OmqxHUjJx79SperyES1QErYz1UhnYkMZjEGZbNRgkTBlOAxWLWNPFoRFsU1FaBgwrygvEkxbyCT8zAoa40CBInDmOLyaD5SUhyzS0oLG5MSBAKiBIdO6m+i75V7ihxITNZAnHwQRwNp5YO0NJB2PgheQCrqIl8SyauXylg3vAmaFYbw21cP+XxcjqfTGMNcuRpIlw2Qo5s0jDLCBPqV9VLr+02loooHqDBmbN2MVqZJbguuAmsfn5jqeO5rIm7NctImrrrZ7DRRfqW04BSsbl1bs1PJmhmslpc1WU1nZlJFgurM6Un9cHZz1jxrQ8PP5ud977TaPLXlf43WPq0Zvb9YwGpa7X8vPRWq+CvTfFfBGcm1rvzU5JTshXTFf2kiydFirr+YJja/eqq0+NXT1FLTExNrXz2VbX2Nh1lh1GukoUmvnqAZ39o18W7Mtp40Q9FKSxFW/kvmYO00l2froiFbDg9/pZQGRSoGshLRrWzZXx/1Z+po1J9KuoKGmynxtFtim6q6GMButk17lOJitVo3zyQ2GS+RUoxQQtTM4Uy9yKdGVOXaymOzzRPPM/1r76im7n6aREzIU4PPnt2/HQd+6FXZ5RH9WVs16O9TN19Ealy1XKRYmKnfuyZW0yp3AxoDWEOlSUskim1lJlkUrV1QtBIUG+kRgXer0VHXemEhcbmn4J0yeAcFWGQJVTD8lLf98AtUesQNgSLHAwNpNGeT1g0/jNDIyz91he7KZIoMJtDjge/KTKnIouXpJSUN0GkmJRQx0Mlf3Unp1Bi2FY6ywYRNI/EmGkYdtOwH3GPAent9mfWa3CyckIt68rvQI1tQQQ3WowGmuZSWZ0M/9Ib94SDPhj4Iho5mD67Z0Aed4HDYzOS03ft+xz8KOhnret8PnMArsq6H+g/8Tsq6vjl0Ws5Ryrq+7XWc/U7aut45OHyUtq4/ah498g7zrOtTHc5a1zcPj5x+K9e6/tGg7SGhmW9d33m0v39wVGBdf+C3Dv2jAuv6QavjNAut6z3noNV8lGNd32m3DwdennV9v7Pfbh6lrOub7YMjz8mzrk91ma3rUzh06/pUe9m6vukf6V4OEuv61v5BO+jnW9c7+4fNtp9vXZ9qgGZd3z9yBs4gu/J9b36ZWviYxGbt/am/tMn9k64+qqzg15hfJJVahLXulgL5bsHx2zWFRCmBTGefe8PmJZnNXLOodVoTwjE67F3MR1WKTuHS9150df7pzXjUXcTDQ/sz+LLgaxL1HqJHKHdv7/r6unHdbkzn53stoOMR/qFFx1HvoXPQfGhxg/jj8WdMGFuh33s4wc/g5WLeX4yCySCwyGDn4XAOa8IbfTsNo+AhuTn4ch78dQEQy97DZuPooTVZjL8DkKsAWtF6aEVxGA8uXoYj/OaPh3uImoz2nnvxPETmY7TAfHTTQ/82jixL+2gZX4fqq9Fs71tNxLfHTX/8GWoBqh7CfLf2tU6Kb4btPcTR/LjVntQIA4zN44ox7XwGi6M4K8tMW5NWYO68itW0MUJnTD+iqOnAD83AdIJHyDmVZGua7gbBKLt+XBkYYIyDeThwRSZ+R+tPVhxkr+9JHRZkh7tJ4qZLWw+OpFswSgRKCRdWs9pRs5s8UJbNrUz7pXzAi8W4rwMWt8/YkaNw5iaScuXrAllZ8nZhFdkKFzQhQ9hl7/Rb1A/1sQUbGn5mXy7Zy0nH39F2tQTldlopyeO01c48Y+r4i0hLJuXq0tJL2sg7qvellSpSj+UAzJvP4ZzR9KwKcKm3U77e67gsDU3sLivZQheGUse2qykWqWvW1LGg2nwvugiyTU/ko6nV1MpDSHdgObMjpISZkuKSLC+cSAgz5cVFWl4+kQ5mytNlux2fNgul74ciX1AZYT6K6ItEf3n6hpmx7maVsLuoJtqBLPkcjNN4AN/p1yPDzcg+nAup27ibZyT9W8n9W4ZW/ipaTmLvxq18UukKm8vIHXqjKCgosdy5xNvtS7C+/47wO7QopdtbXLArvMKxvToctSlEaY3W22PSNQi3xkJHSRqTeDHbfjTkk9MO40eiu+07Kx0V5uPhrbhr7ezRT5X6DHcd7B8gIB9vaA5Z1aRw6kLf9+9X1lXl9r3LurG8qz4mrix32LuJ8PquhuU2zcgI39+/MYl/zh1HQ/fdmUyNCDW667bPuPzcumPoGrQM212NkyHBer+TCZ8kth/s5MFi+zLyOWP7EuqxY/siyVPILgsYH0q2hxeyvB0ORfHuskMJfJXZHlx7s7lVoV23fMZ5610dg2nPryVNAv4ZVz26tv5s7wLuVfqFUgyLvLD3KtrTbUV4wAaaAIUNFfLhDSw4fDMCLPf4N4AoHo8e/+b/A/wnNrMTIQYA"; diff --git a/apps/pythinker-code/src/generated/vis-web-asset.d.ts b/apps/pythinker-code/src/generated/vis-web-asset.d.ts new file mode 100644 index 00000000..9b87ddcf --- /dev/null +++ b/apps/pythinker-code/src/generated/vis-web-asset.d.ts @@ -0,0 +1 @@ +export declare const VIS_WEB_GZIP_B64: string; diff --git a/apps/pythinker-code/src/launcher.ts b/apps/pythinker-code/src/launcher.ts deleted file mode 100644 index 9877878f..00000000 --- a/apps/pythinker-code/src/launcher.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { spawn } from 'node:child_process'; - -const FFI_FLAG = '--experimental-ffi'; -const FFI_WARNING_FLAG = '--disable-warning=ExperimentalWarning'; -const FFI_CHILD_ENV = 'PYTHINKER_CODE_FFI_CHILD'; -// Local on purpose: the FFI launcher test executes this file standalone, so it must stay import-free beyond node builtins. -const REQUIRED_RUNTIME = 'Node.js 20 or newer'; -const MINIMUM_NODE = [20, 0, 0] as const; -const FFI_NODE = [26, 4, 0] as const; -const NATIVE_INSTALL_HINT = - 'Alternatively, use the native installer (no Node.js required): https://code.pythinker.com'; - -/** - * Node versions before 26.4 do not support `--experimental-ffi`, so run the - * app directly instead of re-execing with an unsupported flag. npm installs - * the package on any Node version (engines is only a warning for consumers), - * so guard the actual runtime floor here with an actionable message. - */ -function isVersionBelow( - version: string, - minimum: readonly [number, number, number], -): boolean { - const parts = version.split('.').map(Number); - const [major = 0, minor = 0, patch = 0] = parts; - const [reqMajor, reqMinor, reqPatch] = minimum; - if (major !== reqMajor) return major < reqMajor; - if (minor !== reqMinor) return minor < reqMinor; - return patch < reqPatch; -} - -function isFfiProcess(): boolean { - // Only execArgv decides: a stale env marker must never bypass the FFI re-exec. - return process.execArgv.includes(FFI_FLAG); -} - -/** - * Windows-only fallback for platforms without `process.execve`. Older Node - * releases do not ship it on win32. Re-exec via spawn instead. - */ -function launchWindowsFallback( - nodeArguments: readonly string[], - environment: NodeJS.ProcessEnv, -): void { - let childStarted = false; - let receivedSigint = false; - let forceTermination = false; - const child = spawn(process.execPath, nodeArguments, { - env: environment, - stdio: 'inherit', - }); - - const removeSignalHandler = (): void => { - process.off('SIGINT', handleSigint); - }; - const forceChildTermination = (): void => { - forceTermination = true; - if (childStarted) child.kill('SIGKILL'); - }; - // Console Ctrl+C reaches both processes; child.kill('SIGINT') would force termination. - const handleSigint = (): void => { - if (!receivedSigint) { - receivedSigint = true; - return; - } - forceChildTermination(); - }; - process.on('SIGINT', handleSigint); - - child.on('spawn', () => { - childStarted = true; - if (forceTermination) forceChildTermination(); - }); - child.on('error', (error) => { - removeSignalHandler(); - process.stderr.write( - `Failed to start Pythinker Code: ${error.message}. This CLI requires ${REQUIRED_RUNTIME}.\n`, - ); - process.exitCode = 1; - }); - child.on('exit', (code, signal) => { - removeSignalHandler(); - if (signal !== null) { - process.kill(process.pid, signal); - return; - } - // 130 = 128 + SIGINT, matching the conventional shell exit code. - process.exitCode = receivedSigint ? 130 : (code ?? 1); - }); -} - -async function launch(): Promise<void> { - if (isVersionBelow(process.versions.node, MINIMUM_NODE)) { - process.stderr.write( - `Pythinker Code requires ${REQUIRED_RUNTIME}; you are running Node.js ${process.versions.node}.\n` + - `${NATIVE_INSTALL_HINT}\n`, - ); - process.exitCode = 1; - return; - } - - if (isVersionBelow(process.versions.node, FFI_NODE)) { - await import(new URL('./main.mjs', import.meta.url).href); - return; - } - - if (isFfiProcess()) { - await import(new URL('./main.mjs', import.meta.url).href); - return; - } - - const launcherPath = process.argv[1]; - if (launcherPath === undefined) { - process.stderr.write(`Pythinker Code requires ${REQUIRED_RUNTIME}.\n`); - process.exitCode = 1; - return; - } - - const nodeArguments = [ - FFI_FLAG, - FFI_WARNING_FLAG, - ...process.execArgv, - launcherPath, - ...process.argv.slice(2), - ]; - const environment: NodeJS.ProcessEnv = { - ...process.env, - [FFI_CHILD_ENV]: '1', - }; - // On Windows, process.execve either does not exist or exists but throws - // ERR_FEATURE_UNAVAILABLE_ON_PLATFORM when called — checking for undefined - // is not enough, so always take the spawn fallback there. - if (process.platform === 'win32') { - launchWindowsFallback(nodeArguments, environment); - return; - } - - // execve keeps the same pid, process group, session, and controlling - // terminal, so Ctrl+C and job-control signals keep flowing to the app and - // the child's process group stays the terminal's foreground group. - if (process.execve === undefined) { - throw new Error('process.execve is unavailable on this platform'); - } - process.execve(process.execPath, [process.execPath, ...nodeArguments], environment); -} - -void launch().catch((error: unknown) => { - const detail = error instanceof Error ? error.message : String(error); - process.stderr.write(`Failed to start Pythinker Code: ${detail}\n`); - process.exitCode = 1; -}); diff --git a/apps/pythinker-code/src/main.ts b/apps/pythinker-code/src/main.ts index bae15388..6982e3a8 100644 --- a/apps/pythinker-code/src/main.ts +++ b/apps/pythinker-code/src/main.ts @@ -23,88 +23,76 @@ import { } from '@pymodel/pythinker-telemetry'; import { createProgram } from './cli/commands'; +import { finalizeHeadlessRun } from './cli/headless-exit'; +import { startupTrace } from './utils/startup-trace'; import type { CLIOptions } from './cli/options'; import { OptionConflictError, validateOptions } from './cli/options'; -import { drainWritable, writeAndDrain } from './cli/output'; import { runPrompt } from './cli/run-prompt'; import { runShell } from './cli/run-shell'; import { formatStartupError } from './cli/startup-error'; import { runPluginNodeEntry } from './cli/sub/plugin-run-node'; import { handleUpgrade } from './cli/sub/upgrade'; import { createCliTelemetryBootstrap, initializeCliTelemetry } from './cli/telemetry'; -import { activatePendingUpdate } from './cli/update/activation'; -import { - isAutoUpdateDisabledByEnv, - runUpdatePreflight, - shouldAutoInstallUpdates, -} from './cli/update/preflight'; -import { dispatchUpdateHelperIfRequested } from './cli/update/update-helper'; +import { runUpdatePreflight } from './cli/update/preflight'; import { createPythinkerCodeHostIdentity, getVersion } from './cli/version'; import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, PROCESS_NAME } from './constant/app'; -import { cleanupStaleNativeCacheForCurrent, cleanupStaleUpdateBackup } from './native/native-assets'; +import { cleanupStaleNativeCacheForCurrent } from './native/native-assets'; +import { installMinidbTextBuildWorker } from './native/minidb-worker'; +import { installKapSearchWorker } from './native/search-worker'; import { installNativeModuleHook } from './native/module-hook'; import { runNativeAssetSmokeIfRequested } from './native/smoke'; -export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> { +/** + * Outcome of a CLI command run, reported back to the process entrypoint. + * + * `handleMainCommand` is a reusable, unit-tested handler — it must not terminate + * the process itself. It reports here whether a headless (`pythinker -p`) run + * completed so the entrypoint (the only place that owns the process) can arm the + * force-exit fallback. + */ +export interface MainCommandOutcome { + readonly headlessCompleted: boolean; +} + +export async function handleMainCommand( + opts: CLIOptions, + version: string, +): Promise<MainCommandOutcome> { let validated: ReturnType<typeof validateOptions>; + startupTrace('main:enter'); try { validated = validateOptions(opts); } catch (error) { if (error instanceof OptionConflictError) { - await writeAndDrain(process.stderr, `error: ${error.message}\n`); + process.stderr.write(`error: ${error.message}\n`); process.exit(1); } throw error; } - const interactiveShell = - validated.uiMode === 'shell' && - validated.options.initOnly !== true && - process.stdin.isTTY && - process.stdout.isTTY; - const activation = await activatePendingUpdate(version, { - enabled: interactiveShell, - automaticEnabled: - interactiveShell && - !isAutoUpdateDisabledByEnv() && - await shouldAutoInstallUpdates(), - }).catch(async (error: unknown) => { - await writeAndDrain( - process.stderr, - `warning: unable to process a pending Pythinker Code update: ${ - error instanceof Error ? error.message : String(error) - }\n`, - ).catch(() => {}); - return { status: 'none' as const }; - }); - if (activation.status === 'failed') { - await writeAndDrain( - process.stderr, - `warning: failed to activate Pythinker Code ${activation.version}: ${activation.message}\n`, - ); - } else if (activation.status === 'activated') { - await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); - relaunchUpdatedCli(activation.executable); - } - + startupTrace('preflight:begin'); const preflightResult = await runUpdatePreflight( version, - validated.uiMode === 'print' || validated.options.initOnly === true - ? { track, isTTY: false } - : { track }, + validated.uiMode === 'print' ? { track, isTTY: false } : { track }, ); + startupTrace('preflight:end'); if (preflightResult === 'exit') { - // The preflight may have printed an update notice; flush it before exiting. - await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); process.exit(0); } - if (validated.uiMode === 'print' && validated.options.initOnly !== true) { + if (validated.uiMode === 'print') { await runPrompt(validated.options, version); - return; + return { headlessCompleted: true }; } + startupTrace('runShell:begin'); await runShell(validated.options, version); + return { headlessCompleted: false }; +} + +/** `pythinker migrate`: launch the migration screen only, then exit. */ +async function handleMigrateCommand(version: string): Promise<void> { + await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true }); } export async function handleUpgradeCommand(version: string): Promise<void> { @@ -135,11 +123,24 @@ export async function handleUpgradeCommand(version: string): Promise<void> { await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); await harness.close().catch(() => {}); } - // Flush upgrade output before exiting, or the terminal may lose it. - await Promise.all([drainWritable(process.stdout), drainWritable(process.stderr)]); process.exit(exitCode); } +/** A neutral CLIOptions value — `pythinker migrate` never opens a chat session. */ +const MIGRATE_CLI_OPTIONS: CLIOptions = { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], +}; + export function main(): void { process.title = PROCESS_NAME; installCrashHandlers(); @@ -148,7 +149,27 @@ export function main(): void { // invalid proxy URL is reported and ignored rather than aborting startup. installGlobalProxyDispatcher(); installNativeModuleHook(); - if (dispatchUpdateHelperIfRequested()) return; + // Best-effort SEA worker installation. Diagnostics are trace-only and avoid + // exposing the user's cache path; failure keeps MiniDb's bounded inline mode. + const workerInstall = installMinidbTextBuildWorker(); + startupTrace( + workerInstall.status === 'installed' + ? `minidb-worker:installed basename=${workerInstall.basename} sha256=${workerInstall.assetSha256}` + : workerInstall.status === 'failed' + ? `minidb-worker:failed code=${workerInstall.errorCode} sha256=${workerInstall.assetSha256 ?? 'unknown'}` + : `minidb-worker:${workerInstall.status}`, + ); + // Same pattern for the global-search worker: extracted from the SEA blob so + // the search index runs off the main thread; a failure leaves the search + // surface degraded (the `search_worker` flag restores the inline host). + const searchWorkerInstall = installKapSearchWorker(); + startupTrace( + searchWorkerInstall.status === 'installed' + ? `search-worker:installed basename=${searchWorkerInstall.basename} sha256=${searchWorkerInstall.assetSha256}` + : searchWorkerInstall.status === 'failed' + ? `search-worker:failed code=${searchWorkerInstall.errorCode} sha256=${searchWorkerInstall.assetSha256 ?? 'unknown'}` + : `search-worker:${searchWorkerInstall.status}`, + ); if (runNativeAssetSmokeIfRequested()) return; // Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw. @@ -158,8 +179,6 @@ export function main(): void { } catch { // ignore: cache GC must never affect process startup } - // Sweep a leftover `pythinker.exe.old` from a prior Windows native update. - cleanupStaleUpdateBackup(); }); const version = getVersion(); @@ -167,40 +186,63 @@ export function main(): void { const program = createProgram( version, (opts) => { - void handleMainCommand(opts, version).catch(async (error: unknown) => { - const operation = - opts.rewindFiles !== undefined - ? 'rewind files' - : opts.prompt !== undefined - ? 'run prompt' - : 'start shell'; - await logStartupFailure(operation, error); - await writeAndDrain( - process.stderr, - formatStartupError(error, { operation }) - + `See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`, - ); + void handleMainCommand(opts, version) + .then(async (outcome) => { + // Only the process entrypoint disposes of the process. Print mode + // relies on the event loop draining to exit; flush any buffered output + // and then arm an unref'd fallback so a stray ref'd handle left over + // from the run can't wedge a completed `pythinker -p` until an external + // timeout. A healthy run drains and exits before the fallback fires. + if (outcome.headlessCompleted) { + await finalizeHeadlessRun( + process, + [process.stdout, process.stderr], + () => Number(process.exitCode) || 0, + ); + } + }) + .catch(async (error: unknown) => { + // Set the failure exit code synchronously, before any `await`. The + // terminal `process.exit(1)` below is our intended exit, but it sits + // behind `await logStartupFailure(...)`; by the time we reach that + // await, the failed run's `finally` cleanup has already torn down its + // ref'd handles (sockets, timers, background tasks). If the event loop + // drains during the await, Node exits on its own with the DEFAULT code + // 0 and `process.exit(1)` never runs — headless (`pythinker -p`) failures + // would then exit 0 nondeterministically. Setting `process.exitCode` + // up front makes that drain-exit report failure too. + process.exitCode = 1; + const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell'; + await logStartupFailure(operation, error); + process.stderr.write( + formatStartupError(error, { + operation, + }), + ); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`); + process.exit(1); + }); + }, + () => { + void handleMigrateCommand(version).catch(async (error: unknown) => { + await logStartupFailure('run migration', error); + process.stderr.write(formatStartupError(error, { operation: 'run migration' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`); process.exit(1); }); }, (entry, args) => { void runPluginNodeEntry(entry, args).catch(async (error: unknown) => { await logStartupFailure('run plugin node entry', error); - await writeAndDrain( - process.stderr, - `${error instanceof Error ? error.message : String(error)}\n`, - ); + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exit(1); }); }, () => { void handleUpgradeCommand(version).catch(async (error: unknown) => { await logStartupFailure('upgrade', error); - await writeAndDrain( - process.stderr, - formatStartupError(error, { operation: 'upgrade' }) - + `See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`, - ); + process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); + process.stderr.write(`See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`); process.exit(1); }); }, @@ -209,32 +251,7 @@ export function main(): void { program.parse(process.argv); } -if (process.env['PYTHINKER_CODE_OPENTUI_SMOKE'] === '1') { - // Keep this promise-based because the native build emits this entry as CommonJS. - // oxlint-disable-next-line unicorn/prefer-top-level-await - void import('./tui/runtime/open-tui-probe') - .then(({ runOpenTuiProbe }) => runOpenTuiProbe()) - .catch((error: unknown) => { - process.stderr.write( - `OpenTUI lifecycle probe failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - }); -} else { - main(); -} - -function relaunchUpdatedCli(executable: string): never { - if (process.execve === undefined) { - throw new Error('process.execve is unavailable for update relaunch'); - } - process.execve( - executable, - [executable, ...process.argv.slice(2)], - process.env, - ); - throw new Error('update relaunch returned unexpectedly'); -} +main(); async function logStartupFailure(operation: string, error: unknown): Promise<void> { log.error('startup failed', { operation, error }); diff --git a/apps/pythinker-code/src/migration/badge.ts b/apps/pythinker-code/src/migration/badge.ts new file mode 100644 index 00000000..43399979 --- /dev/null +++ b/apps/pythinker-code/src/migration/badge.ts @@ -0,0 +1,27 @@ +/** + * Pure helpers for composing session labels in the session picker. + * + * Detection rule for the `[imported]` badge: `metadata.imported_from_pythinker_cli` + * is strictly the boolean `true`. This mirrors the value written by + * `migration-legacy` into the session's `state.json` `custom` block. + */ + +const IMPORTED_BADGE = '[imported]'; +const IMPORTED_FLAG_KEY = 'imported_from_pythinker_cli'; + +export interface SessionLabelInput { + readonly title: string; + readonly metadata?: Readonly<Record<string, unknown>> | undefined; +} + +export function isImportedSession( + metadata: Readonly<Record<string, unknown>> | undefined, +): boolean { + if (metadata === undefined) return false; + return metadata[IMPORTED_FLAG_KEY] === true; +} + +export function formatSessionLabel(input: SessionLabelInput): string { + const prefix = isImportedSession(input.metadata) ? `${IMPORTED_BADGE} ` : ''; + return `${prefix}${input.title}`; +} diff --git a/apps/pythinker-code/src/migration/command.ts b/apps/pythinker-code/src/migration/command.ts new file mode 100644 index 00000000..a55ae1db --- /dev/null +++ b/apps/pythinker-code/src/migration/command.ts @@ -0,0 +1,19 @@ +/** + * `pythinker migrate` sub-command. + * + * A bare, flagless subcommand: it launches the native pi-tui migration screen + * (the same one shown on first launch), then exits. The screen collects the + * migration scope interactively, so there are no CLI options. The actual + * launch is delegated to a host-provided handler. + */ + +import type { Command } from 'commander'; + +export function registerMigrateCommand(parent: Command, onMigrate: () => void): void { + parent + .command('migrate') + .description('Migrate data from a legacy pythinker-cli installation into pythinker-code.') + .action(() => { + onMigrate(); + }); +} diff --git a/apps/pythinker-code/src/migration/detect-pending.ts b/apps/pythinker-code/src/migration/detect-pending.ts new file mode 100644 index 00000000..044245d5 --- /dev/null +++ b/apps/pythinker-code/src/migration/detect-pending.ts @@ -0,0 +1,57 @@ +/** + * Pre-TUI detection: decide whether a first-launch migration screen should be + * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to + * drive the screen, or null when there is nothing to offer. + */ +import { existsSync } from 'node:fs'; + +import { + detectMigration, + shouldSuppressMigration, + type MigrationPlan, +} from '@pymodel/migration-legacy'; + +export interface DetectPendingInput { + readonly sourceHome: string; + readonly targetHome: string; + /** + * When true, skip the marker-based suppression (`.migrated-to-pythinker-code` / + * `.skip-migration-from-pythinker-cli`). The explicit `pythinker migrate` command sets + * this so a deliberate invocation always runs regardless of prior runs. + */ + readonly ignoreMarker?: boolean; +} + +export async function detectPendingMigration( + input: DetectPendingInput, +): Promise<MigrationPlan | null> { + const { sourceHome, targetHome } = input; + if (!existsSync(sourceHome)) return null; + if ( + input.ignoreMarker !== true && + shouldSuppressMigration({ sourceHome, targetHome }) + ) { + return null; + } + + let plan: MigrationPlan; + try { + plan = await detectMigration({ sourcePath: sourceHome }); + } catch { + // Detection failure must never block startup; skip the screen. + return null; + } + + // OAuth credentials are deliberately not migrated, so an install whose + // only data is `credentials/*.json` has nothing to offer — pythinker-code's own + // /login flow will pick up the auth conversation when the user first uses + // the app. Treat oauth-only as "nothing to migrate". + const nothingToMigrate = + plan.totalSessions === 0 && + !plan.hasConfig && + !plan.hasMcp && + !plan.hasUserHistory; + if (nothingToMigrate) return null; + + return plan; +} diff --git a/apps/pythinker-code/src/migration/index.ts b/apps/pythinker-code/src/migration/index.ts new file mode 100644 index 00000000..565b77c7 --- /dev/null +++ b/apps/pythinker-code/src/migration/index.ts @@ -0,0 +1,12 @@ +/** + * pythinker-cli → pythinker-code migration: host integration surface. + * + * Removable glue: the `pythinker migrate` sub-command, the first-launch detection, + * the native pi-tui migration screen, and the session-picker `[imported]` + * badge helper. Migration logic itself lives in + * `@pymodel/migration-legacy`. + */ +export { registerMigrateCommand } from './command'; +export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge'; +export { detectPendingMigration } from './detect-pending'; +export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen'; diff --git a/apps/pythinker-code/src/migration/migration-screen.ts b/apps/pythinker-code/src/migration/migration-screen.ts new file mode 100644 index 00000000..24b9c63d --- /dev/null +++ b/apps/pythinker-code/src/migration/migration-screen.ts @@ -0,0 +1,571 @@ +/** + * MigrationScreenComponent — native pi-tui first-launch migration experience. + * + * A single mounted Container & Focusable that runs a 3-phase state machine: + * ask (2-step choice wizard) -> progress -> result + * + * Pure decision mapping (choices -> MigrationScope) is delegated to the + * package's `resolveMigrationScope`. Rendering follows the `ChoicePicker` + * conventions in `apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts`. + * + * This file implements the ask, progress, and result phases. `beginMigration` + * drives the real runMigration flow (injectable for tests). + */ +import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@pymodel/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { + resolveMigrationScope, + runMigration as realRunMigration, + type AnyChoice, + type MigrationPlan, + type MigrationPromptResult, + type MigrationReport, + type MigrationScope, + type Prompt1Choice, + type Prompt2Choice, + type RunMigrationInput, +} from '@pymodel/migration-legacy'; + +type Phase = 'ask1' | 'ask2' | 'progress' | 'result'; + +const SPINNER_FRAMES = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'] as const; + +/** Spinner frame cadence — one full braille cycle every ~800ms. */ +const SPINNER_INTERVAL_MS = 80; + +const STEP_LABELS: ReadonlyArray<readonly [string, string]> = [ + ['config', 'Config'], + ['mcp', 'MCP'], + ['user-history', 'REPL history'], + ['sessions', 'Sessions'], +]; + +export interface MigrationScreenOptions { + readonly plan: MigrationPlan; + readonly sourceHome: string; + readonly targetHome: string; + readonly colors?: ColorPalette; + /** Called once the screen is finished; the host then restores the editor. */ + readonly onComplete: (result: MigrationScreenResult) => void; + /** Triggers a re-render; the host wires this to `ui.requestRender()`. */ + readonly requestRender?: () => void; + /** Injectable for tests; defaults to the package's runMigration. */ + readonly runMigration?: (input: RunMigrationInput) => Promise<MigrationReport>; + /** + * When true, the screen starts at the scope question and skips the + * now/later/never gate — used by the explicit `pythinker migrate` command, where + * invoking the command is itself the decision to migrate. + */ + readonly skipDecisionStep?: boolean; +} + +/** What the screen reports back to the host when finished. */ +export interface MigrationScreenResult { + readonly decision: 'now' | 'later' | 'never'; + /** Resolved migration scope; present only when decision === 'now'. */ + readonly scope?: MigrationScope; + // present only when decision === 'now' and migration ran + readonly migrated?: boolean; +} + +interface StepDef { + readonly title: string; + readonly options: ReadonlyArray<{ readonly label: string; readonly value: AnyChoice }>; +} + +export class MigrationScreenComponent extends Container implements Focusable { + focused = false; + private readonly opts: MigrationScreenOptions; + private phase: Phase = 'ask1'; + private selectedIndex = 0; + private readonly choices: AnyChoice[] = []; + private progressDone = 0; + private progressTotal = 0; + private readonly stepStatus = new Map<string, 'pending' | 'done'>([ + ['config', 'pending'], + ['mcp', 'pending'], + ['user-history', 'pending'], + ['sessions', 'pending'], + ]); + private spinnerFrame = 0; + private spinnerTimer: ReturnType<typeof setInterval> | undefined; + private report: MigrationReport | undefined; + private migrationFailed = false; + private migrationFailureReason: string | undefined; + + constructor(opts: MigrationScreenOptions) { + super(); + this.opts = opts; + if (opts.skipDecisionStep === true) { + // Explicit `pythinker migrate`: the now/later/never gate is meaningless, so + // start at the scope question with the decision already fixed to 'now'. + this.phase = 'ask2'; + this.choices.push('now'); + } + } + + /** Host calls this once runMigration resolves. */ + showResult(report: MigrationReport): void { + this.report = report; + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this if runMigration threw. */ + showFailure(error?: unknown): void { + this.migrationFailed = true; + this.migrationFailureReason = formatMigrationFailureReason(error); + this.phase = 'result'; + this.stopSpinner(); + } + + /** Host calls this when migration starts. */ + enterProgress(): void { + this.phase = 'progress'; + } + + /** Host wires this to runMigration's onProgress (step-level messages). */ + reportStep(msg: string): void { + // msg is like 'config done', 'mcp done', 'sessions done' + const key = msg.replace(/ done$/, ''); + if (this.stepStatus.has(key)) this.stepStatus.set(key, 'done'); + } + + /** Host wires this to runMigration's onSessionProgress. */ + reportSessionProgress(done: number, total: number): void { + this.progressDone = done; + this.progressTotal = total; + } + + // The braille spinner advances on its own timer so the progress screen stays + // visibly alive even while a single step (e.g. session translation) runs for + // a while without emitting progress events. Runs only for the progress + // phase: started on entering it, stopped the moment it ends. + private startSpinner(): void { + this.stopSpinner(); + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.opts.requestRender?.(); + }, SPINNER_INTERVAL_MS); + // A decorative timer must never keep the process alive on its own. + this.spinnerTimer.unref(); + } + + private stopSpinner(): void { + if (this.spinnerTimer !== undefined) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; + } + } + + // test hooks (thin aliases so tests don't depend on host wiring) + _testEnterProgress(): void { + this.enterProgress(); + } + _testUpdateStep(msg: string): void { + this.reportStep(msg); + } + _testUpdateSessionProgress(done: number, total: number): void { + this.reportSessionProgress(done, total); + } + _testShowResult(report: MigrationReport): void { + this.showResult(report); + } + + handleInput(data: string): void { + if (this.phase === 'ask1' || this.phase === 'ask2') { + this.handleAskInput(data); + return; + } + if (this.phase === 'result') { + if (matchesKey(data, Key.enter)) { + this.opts.onComplete({ decision: 'now', migrated: !this.migrationFailed }); + } + return; + } + // progress phase: ignore input + } + + private currentStep(): StepDef { + return stepFor(this.phase, this.opts.plan); + } + + private handleAskInput(data: string): void { + const step = this.currentStep(); + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(step.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.escape)) { + // Esc anywhere in ask == "later" + this.opts.onComplete({ decision: 'later' }); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = step.options[this.selectedIndex]; + if (chosen === undefined) return; + this.advance(chosen.value); + return; + } + } + + /** Apply a chosen value and move the state machine forward. */ + private advance(value: AnyChoice): void { + this.choices.push(value); + this.selectedIndex = 0; + + const result: MigrationPromptResult = resolveMigrationScope(this.choices); + if (this.phase === 'ask1') { + if (value === 'now') { + this.phase = 'ask2'; + return; + } + // 'later' | 'never' + this.opts.onComplete({ decision: value as 'later' | 'never' }); + return; + } + // ask2 — either choice resolves the full scope; run migration immediately. + this.beginMigration(result); + } + + /** Enter the progress phase and run the migration to completion. */ + private beginMigration(result: MigrationPromptResult): void { + if (result.decision !== 'now' || result.scope === undefined) { + this.opts.onComplete({ decision: 'later' }); + return; + } + this.enterProgress(); + this.startSpinner(); + this.opts.requestRender?.(); + const run = this.opts.runMigration ?? realRunMigration; + void run({ + plan: this.opts.plan, + scope: result.scope, + source: this.opts.sourceHome, + target: this.opts.targetHome, + onProgress: (msg) => { + this.reportStep(msg); + this.opts.requestRender?.(); + }, + onSessionProgress: (done, total) => { + this.reportSessionProgress(done, total); + this.opts.requestRender?.(); + }, + }).then( + (report) => { + this.showResult(report); + this.opts.requestRender?.(); + }, + (error) => { + this.showFailure(error); + this.opts.requestRender?.(); + }, + ); + } + + override render(width: number): string[] { + if (this.phase === 'ask1' || this.phase === 'ask2') { + return this.renderAsk(width); + } + if (this.phase === 'progress') return this.renderProgress(width); + return this.renderResult(width); + } + + private renderResult(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))]; + if (this.migrationFailed) { + lines.push(chalk.hex(colors.error).bold(' Migration failed')); + if (this.migrationFailureReason !== undefined) { + lines.push(''); + lines.push(chalk.hex(colors.text)(` Reason: ${this.migrationFailureReason}`)); + } + lines.push(''); + lines.push(chalk.hex(colors.text)(' You can retry later by running "pythinker migrate".')); + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to pythinker-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + const r = this.report; + lines.push(chalk.hex(colors.primary).bold(' Migration complete')); + lines.push(''); + if (r !== undefined) { + const sum = r.summary; + if (sum.sessions.sessionsMigrated > 0) { + lines.push( + chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`), + ); + } + // Only claim a data class was migrated when the summary says it was — + // a skipped/failed step (e.g. malformed config.toml) must not show ✓. + const migratedKinds: string[] = []; + if (sum.config.migrated) migratedKinds.push('config'); + if (sum.config.migratedHooks > 0) migratedKinds.push('hooks'); + if (sum.mcp.mergedServers.length > 0) migratedKinds.push('MCP'); + if (sum.userHistory.copied > 0) migratedKinds.push('REPL history'); + if (sum.skills.copied > 0) migratedKinds.push('skills'); + if (migratedKinds.length > 0) { + lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); + } + if (sum.sessions.sessionsMigrated === 0 && migratedKinds.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.')); + } + if (r.notices.detectedPlugins.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.detectedPlugins.length} pythinker-cli plugins — not yet supported for migration`, + ), + ); + } + // OAuth credentials are deliberately not migrated (refresh tokens cannot + // safely be held by two installs at once). pythinker-code's normal auth flow + // will prompt for /login when the user first picks a model — surfacing a + // separate notice here reads as a migration limitation, which it is not. + if (sum.config.droppedHooks > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.droppedHooks} hooks dropped (incompatible)`, + ), + ); + } + // Conflicts and partial failures: the report records them, so surface + // them here too — otherwise "✓ config / MCP" hides that the data only + // landed in a *.migrated-from-pythinker-cli.* sibling or that sessions failed. + if (sum.config.configConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.config.configConflicts.length} config conflicts kept yours: ${sum.config.configConflicts.join(' · ')}`, + ), + ); + } + if (sum.config.wroteSiblingDueToConflict) { + // Sibling mode: the live config.toml could not be parsed, so the + // migrated content went to `config.migrated-from-pythinker-cli.toml` and + // the user must merge it by hand. Show the enumeration of contents + // on a SEPARATE line below — a single-line message with the contents + // appended would overflow 80 columns and be truncated, silently + // hiding the very info we want users to see. + // The filename no longer fits beside the message at 80 columns, so + // it gets its own line rather than being truncated away. + lines.push( + chalk.hex(colors.warning)(' ⚠ config.toml could not be parsed — review'), + chalk.hex(colors.warning)(' config.migrated-from-pythinker-cli.toml'), + ); + const sc = sum.config.siblingContents; + const items: string[] = []; + if (sc.providers.length > 0) { + items.push(`${sc.providers.length} provider${sc.providers.length === 1 ? '' : 's'}`); + } + if (sc.models.length > 0) { + items.push(`${sc.models.length} model${sc.models.length === 1 ? '' : 's'}`); + } + if (sc.hooks > 0) { + items.push(`${sc.hooks} hook${sc.hooks === 1 ? '' : 's'}`); + } + if (items.length > 0) { + lines.push(chalk.hex(colors.warning)(` contains: ${items.join(', ')}`)); + } + } + if (sum.config.wroteTuiSibling) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ tui.toml conflicted — review tui.migrated-from-pythinker-cli.toml', + ), + ); + } + if (sum.mcp.wroteSiblingDueToConflict) { + lines.push( + chalk.hex(colors.warning)( + ' ⚠ mcp.json unreadable — review mcp.migrated-from-pythinker-cli.json', + ), + ); + } + if (r.notices.mcpOauthServersRequiringReauth.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${r.notices.mcpOauthServersRequiringReauth.length} MCP servers need re-authentication`, + ), + ); + } + if (sum.sessions.sessionsFailed.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsFailed.length} sessions failed to migrate`, + ), + ); + } + if (sum.sessions.sessionsConflicts.length > 0) { + lines.push( + chalk.hex(colors.warning)( + ` ⚠ ${sum.sessions.sessionsConflicts.length} sessions skipped (target already occupied)`, + ), + ); + } + // Empty / user-cleared sessions carry no conversation — neutral info, + // not a failure, so it is shown muted rather than as a ⚠ warning. + if (sum.sessions.sessionsSkippedEmpty > 0) { + lines.push( + chalk.hex(colors.textMuted)( + ` ${sum.sessions.sessionsSkippedEmpty} empty sessions skipped`, + ), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)(' Old data kept at ~/.pythinker/ — pythinker-cli still works.'), + ); + } + lines.push(''); + lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to pythinker-code')); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderProgress(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0]; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrating from pythinker-cli'), + '', + ]; + if (this.progressTotal > 0) { + lines.push( + chalk.hex(colors.accent)(` ${spinner} `) + + chalk.hex(colors.text)( + `Translating sessions… ${this.progressDone} / ${this.progressTotal}`, + ), + ); + lines.push(''); + } + for (const [key, label] of STEP_LABELS) { + const status = this.stepStatus.get(key) ?? 'pending'; + const mark = + status === 'done' + ? chalk.hex(colors.success)('✓') + : chalk.hex(colors.textDim)('◐'); + lines.push(` ${mark} ${chalk.hex(colors.text)(label)}`); + } + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } + + private renderAsk(width: number): string[] { + const colors = this.opts.colors ?? currentTheme.palette; + const step = this.currentStep(); + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Migrate from pythinker-cli'), + '', + ]; + if (this.phase === 'ask1') { + lines.push(chalk.hex(colors.text)(' Found an existing pythinker-cli installation:')); + lines.push(chalk.hex(colors.textMuted)(` ${summarizePlan(this.opts.plan)}`)); + lines.push(''); + } + lines.push(chalk.hex(colors.text)(` ${step.title}`)); + lines.push(''); + for (let i = 0; i < step.options.length; i++) { + const opt = step.options[i]!; + const isSel = i === this.selectedIndex; + const pointer = isSel ? '❯' : ' '; + const labelStyle = isSel ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + lines.push( + chalk.hex(isSel ? colors.primary : colors.textDim)(` ${pointer} `) + + labelStyle(opt.label), + ); + } + lines.push(''); + lines.push( + chalk.hex(colors.textMuted)( + ` ↑/↓ move · ⏎ select · esc ${this.opts.skipDecisionStep === true ? 'cancel' : 'later'}`, + ), + ); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((l) => truncateToWidth(l, width)); + } +} + +function formatMigrationFailureReason(error: unknown): string | undefined { + let reason: string | undefined; + if (error instanceof Error) { + reason = error.message !== '' ? error.message : error.name; + } else if (typeof error === 'string') { + reason = error; + } else if (typeof error === 'object' && error !== null) { + const maybeMessage = (error as { readonly message?: unknown }).message; + if (typeof maybeMessage === 'string' && maybeMessage !== '') { + reason = maybeMessage; + } + } + if (reason === undefined) { + switch (typeof error) { + case 'number': + case 'boolean': + case 'bigint': + reason = `${error}`; + break; + case 'symbol': + reason = + error.description !== undefined ? `Symbol(${error.description})` : 'Symbol rejection'; + break; + case 'function': + reason = error.name !== '' ? `Function ${error.name}` : 'Function rejection'; + break; + case 'object': + if (error !== null) reason = 'Object rejection'; + break; + case 'undefined': + break; + case 'string': + break; + } + } + const trimmed = reason?.trim(); + return trimmed === undefined || trimmed === '' ? undefined : trimmed; +} + +function summarizePlan(plan: MigrationPlan): string { + const parts: string[] = []; + if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`); + if (plan.hasConfig) parts.push('config.toml'); + if (plan.hasMcp) parts.push('mcp.json'); + if (plan.hasUserHistory) parts.push('REPL history'); + return parts.join(' · '); +} + +function stepFor(phase: Phase, plan: MigrationPlan): StepDef { + if (phase === 'ask1') { + return { + title: 'Migrate this data to pythinker-code?', + options: [ + { label: 'Migrate now', value: 'now' satisfies Prompt1Choice }, + { label: 'Ask me later', value: 'later' satisfies Prompt1Choice }, + { label: 'Never ask again', value: 'never' satisfies Prompt1Choice }, + ], + }; + } + // ask2 — the second option carries the actual session count so users can see + // the cost they are signing up for. Falls back to the singular "sessions" + // word only (no count) when no sessions were detected. + const sessionsLabel = + plan.totalSessions > 0 + ? `Config + ${plan.totalSessions} sessions` + : 'Config + all sessions'; + return { + title: 'Migrate chat sessions too? (they are bulky and slower)', + options: [ + { label: 'Config only', value: 'config-only' satisfies Prompt2Choice }, + { label: sessionsLabel, value: 'all-sessions' satisfies Prompt2Choice }, + ], + }; +} diff --git a/apps/pythinker-code/src/native/minidb-worker.ts b/apps/pythinker-code/src/native/minidb-worker.ts new file mode 100644 index 00000000..c49e6e03 --- /dev/null +++ b/apps/pythinker-code/src/native/minidb-worker.ts @@ -0,0 +1,69 @@ +import { basename } from 'node:path'; + +import { + configureTextBuildWorkerRuntime, + getTextBuildWorkerRuntimeState, +} from '@pymodel/minidb/worker-runtime'; + +import { MINIDB_TEXT_BUILD_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getMinidbTextBuildWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type MinidbTextBuildWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** Install the SEA-bundled worker without making optional extraction fatal. */ +export function installMinidbTextBuildWorker( + options: NativeAssetOptions = {}, +): MinidbTextBuildWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find( + (entry) => entry.key === MINIDB_TEXT_BUILD_WORKER_ASSET.key, + ); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getMinidbTextBuildWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureTextBuildWorkerRuntime(workerPath); + const runtime = getTextBuildWorkerRuntimeState(); + if (!runtime.configured) throw new Error('MiniDb worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/pythinker-code/src/native/module-hook.ts b/apps/pythinker-code/src/native/module-hook.ts index bbef5d4c..227cab81 100644 --- a/apps/pythinker-code/src/native/module-hook.ts +++ b/apps/pythinker-code/src/native/module-hook.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { join } from 'node:path'; -import { loadNativePackage } from './native-require'; +import { getNativePackageRoot } from './native-assets'; type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown; @@ -10,7 +12,16 @@ interface ModuleWithLoad { const nodeRequire = createRequire(import.meta.url); let installed = false; -let loadingNativePackage = false; + +// pi-tui loads its platform-specific native helpers via an absolute-path +// require() computed from import.meta.url / process.execPath +// (see pi-tui dist/terminal.js and dist/native-modifiers.js). In a SEA binary +// those .node files live in the native-asset cache, so redirect any absolute +// require of a pi-tui native helper to the cached copy. +// +// Path shape: native/<darwin|win32>/prebuilds/<arch>/<file>.node — note the +// two path segments after "prebuilds", so ".+" (not "[^/]+") is required. +const PI_TUI_NATIVE_PATTERN = /native[\\/](?:win32|darwin)[\\/]prebuilds[\\/].+\.node$/; export function installNativeModuleHook(): void { if (installed) return; @@ -26,13 +37,18 @@ export function installNativeModuleHook(): void { parent: unknown, isMain: boolean, ): unknown { - if (request === 'koffi' && !loadingNativePackage) { - loadingNativePackage = true; - try { - const pkg = loadNativePackage<unknown>('koffi'); - if (pkg !== null) return pkg; - } finally { - loadingNativePackage = false; + if ( + typeof request === 'string' && + PI_TUI_NATIVE_PATTERN.test(request) && + !existsSync(request) + ) { + const pkgRoot = getNativePackageRoot('@pymodel/pi-tui'); + if (pkgRoot !== null) { + const match = request.match(PI_TUI_NATIVE_PATTERN); + if (match !== null) { + const redirected = join(pkgRoot, match[0]); + return originalLoad.call(this, redirected, parent, isMain); + } } } return originalLoad.call(this, request, parent, isMain); diff --git a/apps/pythinker-code/src/native/native-assets.ts b/apps/pythinker-code/src/native/native-assets.ts index 5c6110ec..6727b36b 100644 --- a/apps/pythinker-code/src/native/native-assets.ts +++ b/apps/pythinker-code/src/native/native-assets.ts @@ -11,10 +11,16 @@ import { } from 'node:fs'; import { createRequire } from 'node:module'; import { homedir } from 'node:os'; -import { dirname, join, win32 as pathWin32 } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve, win32 as pathWin32 } from 'node:path'; +import { join as joinPosix } from 'pathe'; import { PYTHINKER_BUILD_INFO } from '#/cli/build-info'; -import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs'; +import { + KAP_SEARCH_WORKER_ASSET, + MINIDB_TEXT_BUILD_WORKER_ASSET, + NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, + buildManifestKey, +} from '../../scripts/native/manifest.mjs'; export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION; @@ -31,10 +37,15 @@ export interface NativeAssetPackage { readonly files: readonly NativeAssetFile[]; } +export interface NativeRuntimeAssetFile extends NativeAssetFile { + readonly key: string; +} + export interface NativeAssetManifest { readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION; readonly target: string; readonly packages: readonly NativeAssetPackage[]; + readonly runtimeFiles: readonly NativeRuntimeAssetFile[]; } export interface NativeAssetSource { @@ -52,10 +63,6 @@ export interface NativeAssetOptions { readonly version?: string; } -type RawNativeAssetManifest = Omit<NativeAssetManifest, 'version'> & { - readonly version: number; -}; - interface NodeSeaModule { isSea(): boolean; getAssetKeys(): string[]; @@ -96,6 +103,149 @@ function sha256(bytes: Buffer | Uint8Array | string): string { return createHash('sha256').update(bytes).digest('hex'); } +function manifestObject(value: unknown, label: string): Record<string, unknown> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Invalid native asset manifest: ${label} must be an object`); + } + return value as Record<string, unknown>; +} + +function manifestString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid native asset manifest: ${label} must be a non-empty string`); + } + return value; +} + +function validateRelativePath(value: unknown, label: string): string { + const path = manifestString(value, label); + const segments = path.split(/[\\/]/); + if ( + isAbsolute(path) || + /^[a-zA-Z]:/.test(path) || + path.startsWith('\\\\') || + segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + throw new Error(`Invalid native asset manifest: ${label} must be a safe relative path`); + } + return path; +} + +function validateAssetFile( + value: unknown, + label: string, + assetKeys: Set<string>, + relativePaths: Set<string>, +): NativeAssetFile { + const file = manifestObject(value, label); + const assetKey = manifestString(file['assetKey'], `${label}.assetKey`); + if (assetKeys.has(assetKey)) { + throw new Error(`Invalid native asset manifest: duplicate assetKey ${assetKey}`); + } + assetKeys.add(assetKey); + const relativePath = validateRelativePath(file['relativePath'], `${label}.relativePath`); + const portableRelativePath = relativePath.replaceAll('\\', '/'); + if (relativePaths.has(portableRelativePath)) { + throw new Error(`Invalid native asset manifest: duplicate relativePath ${relativePath}`); + } + relativePaths.add(portableRelativePath); + const fileSha256 = file['sha256']; + if (typeof fileSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(fileSha256)) { + throw new Error(`Invalid native asset manifest: ${label}.sha256 must be 64 lowercase hex characters`); + } + const mode = file['mode']; + if ( + mode !== undefined && + (!Number.isInteger(mode) || (mode as number) < 0 || (mode as number) > 0o777) + ) { + throw new Error(`Invalid native asset manifest: ${label}.mode must be an integer between 0 and 0777`); + } + return { + assetKey, + relativePath, + sha256: fileSha256, + mode: mode as number | undefined, + }; +} + +export function validateNativeAssetManifest( + value: unknown, + expectedTarget?: string, +): NativeAssetManifest { + const manifest = manifestObject(value, 'root'); + if (manifest['version'] !== NATIVE_ASSET_MANIFEST_VERSION) { + throw new Error(`Unsupported native asset manifest version: ${String(manifest['version'])}`); + } + const target = manifestString(manifest['target'], 'target'); + if (expectedTarget !== undefined && target !== expectedTarget) { + throw new Error(`Native asset manifest target mismatch: ${target} !== ${expectedTarget}`); + } + const manifestPackages = manifest['packages']; + if (!Array.isArray(manifestPackages)) { + throw new TypeError('Invalid native asset manifest: packages must be an array'); + } + const manifestRuntimeFiles = manifest['runtimeFiles']; + if (!Array.isArray(manifestRuntimeFiles)) { + throw new TypeError('Invalid native asset manifest: runtimeFiles must be an array'); + } + + const assetKeys = new Set<string>(); + const relativePaths = new Set<string>(); + const packageNames = new Set<string>(); + const packages = manifestPackages.map((value, packageIndex): NativeAssetPackage => { + const label = `packages[${packageIndex}]`; + const pkg = manifestObject(value, label); + const name = manifestString(pkg['name'], `${label}.name`); + if (packageNames.has(name)) { + throw new Error(`Invalid native asset manifest: duplicate package name ${name}`); + } + packageNames.add(name); + const root = validateRelativePath(pkg['root'], `${label}.root`); + const packageFiles = pkg['files']; + if (!Array.isArray(packageFiles)) { + throw new TypeError(`Invalid native asset manifest: ${label}.files must be an array`); + } + return { + name, + root, + files: packageFiles.map((file, fileIndex) => + validateAssetFile(file, `${label}.files[${fileIndex}]`, assetKeys, relativePaths), + ), + }; + }); + + const runtimeKeys = new Set<string>(); + const runtimeFiles = manifestRuntimeFiles.map((value, index): NativeRuntimeAssetFile => { + const label = `runtimeFiles[${index}]`; + const raw = manifestObject(value, label); + const key = manifestString(raw['key'], `${label}.key`); + if (runtimeKeys.has(key)) { + throw new Error(`Invalid native asset manifest: duplicate runtime key ${key}`); + } + runtimeKeys.add(key); + return { + ...validateAssetFile(raw, label, assetKeys, relativePaths), + key, + }; + }); + + return { + version: NATIVE_ASSET_MANIFEST_VERSION, + target, + packages, + runtimeFiles, + }; +} + +function resolveAssetPath(cacheRoot: string, relativePath: string): string { + const path = resolve(cacheRoot, ...relativePath.split(/[\\/]/)); + const fromRoot = relative(cacheRoot, path); + if (fromRoot === '..' || fromRoot.startsWith('../') || fromRoot.startsWith('..\\') || isAbsolute(fromRoot)) { + throw new Error(`Native asset path escapes cache root: ${relativePath}`); + } + return path; +} + function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null { const value = env[key]; return typeof value === 'string' && value.length > 0 ? value : null; @@ -123,14 +273,9 @@ export function getEmbeddedNativeAssetManifest( const key = nativeAssetManifestKey(target); if (!source.getAssetKeys().includes(key)) return null; const raw = source.getRawAsset(key); - const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawNativeAssetManifest; - if (manifest.version !== NATIVE_ASSET_MANIFEST_VERSION) { - throw new Error(`Unsupported native asset manifest version: ${manifest.version}`); - } - if (manifest.target !== target) { - throw new Error(`Native asset manifest target mismatch: ${manifest.target} !== ${target}`); - } - return manifest as NativeAssetManifest; + const parsed: unknown = JSON.parse(toBuffer(raw).toString('utf-8')); + validateNativeAssetManifest(parsed, target); + return parsed as NativeAssetManifest; } export function getNativeCacheBase(options: NativeAssetOptions = {}): string { @@ -143,7 +288,7 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { const cacheDirEnv = optionalEnvValue(env, 'PYTHINKER_CODE_CACHE_DIR'); if (cacheDirEnv !== null) return cacheDirEnv; - if (platform === 'darwin') return join(home, 'Library', 'Caches', 'pythinker-code'); + if (platform === 'darwin') return joinPosix(home, 'Library', 'Caches', 'pythinker-code'); if (platform === 'win32') { const localAppData = optionalEnvValue(env, 'LOCALAPPDATA'); return localAppData !== null @@ -151,20 +296,21 @@ export function getNativeCacheBase(options: NativeAssetOptions = {}): string { : pathWin32.join(home, 'AppData', 'Local', 'pythinker-code', 'Cache'); } - return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'pythinker-code'); + return joinPosix(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? joinPosix(home, '.cache'), 'pythinker-code'); } export function getNativeAssetCacheRoot( manifest: NativeAssetManifest, options: NativeAssetOptions = {}, ): string { + const validated = validateNativeAssetManifest(manifest); const version = sanitizeSegment(options.version ?? PYTHINKER_BUILD_INFO.version ?? 'dev'); const manifestHash = sha256(JSON.stringify(manifest)); return join( getNativeCacheBase(options), 'native', version, - sanitizeSegment(manifest.target), + sanitizeSegment(validated.target), manifestHash, ); } @@ -218,52 +364,80 @@ export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; - - const cacheRoot = getNativeAssetCacheRoot(manifest, options); - for (const pkg of manifest.packages) { - for (const file of pkg.files) { - const bytes = toBuffer(source.getRawAsset(file.assetKey)); - const actualSha256 = sha256(bytes); - if (actualSha256 !== file.sha256) { - throw new Error( - `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, - ); - } - ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const cacheRoot = getNativeAssetCacheRoot(rawManifest, options); + const sourceKeys = new Set(source.getAssetKeys()); + const files = [ + ...manifest.packages.flatMap((pkg) => pkg.files), + ...manifest.runtimeFiles, + ]; + for (const file of files) { + if (!sourceKeys.has(file.assetKey)) { + throw new Error(`Native asset is missing: ${file.assetKey}`); + } + const bytes = toBuffer(source.getRawAsset(file.assetKey)); + const actualSha256 = sha256(bytes); + if (actualSha256 !== file.sha256) { + throw new Error( + `Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`, + ); } + ensureFile(resolveAssetPath(cacheRoot, file.relativePath), bytes, file.sha256, file.mode); } ensureEntryFile(cacheRoot); return cacheRoot; } -export function getNativePackageRoot( - packageName: string, +export function getNativeRuntimeFile( + key: string, options: NativeAssetOptions = {}, ): string | null { const source = options.source ?? getSeaAssetSource(); if (source === null) return null; - const manifest = + const rawManifest = options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); - if (manifest === null) return null; + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); - const pkg = manifest.packages.find((entry) => entry.name === packageName); - if (pkg === undefined) return null; + const file = manifest.runtimeFiles.find((entry) => entry.key === key); + if (file === undefined) return null; - const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest }); - return cacheRoot === null ? null : join(cacheRoot, pkg.root); + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, file.relativePath); } -export function getNativeAssetFilePath( +export function getMinidbTextBuildWorkerFile( + options: NativeAssetOptions = {}, +): string | null { + return getNativeRuntimeFile(MINIDB_TEXT_BUILD_WORKER_ASSET.key, options); +} + +export function getKapSearchWorkerFile(options: NativeAssetOptions = {}): string | null { + return getNativeRuntimeFile(KAP_SEARCH_WORKER_ASSET.key, options); +} + +export function getNativePackageRoot( packageName: string, - packageRelativePath: string, options: NativeAssetOptions = {}, ): string | null { - const packageRoot = getNativePackageRoot(packageName, options); - return packageRoot === null ? null : join(packageRoot, packageRelativePath); + const source = options.source ?? getSeaAssetSource(); + if (source === null) return null; + + const rawManifest = + options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget()); + if (rawManifest === null) return null; + const manifest = validateNativeAssetManifest(rawManifest); + + const pkg = manifest.packages.find((entry) => entry.name === packageName); + if (pkg === undefined) return null; + + const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest: rawManifest }); + return cacheRoot === null ? null : resolveAssetPath(cacheRoot, pkg.root); } export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean { @@ -378,24 +552,3 @@ export function cleanupStaleNativeCacheForCurrent( currentRoot, }); } - -/** - * Windows native installs can't overwrite a running exe, so the updater - * renames the old one aside to `pythinker.exe.old` before writing the - * replacement. Best-effort cleanup at the next startup; the file may still - * be locked (AV scan, slow parent exit) — ignore and retry next launch. - */ -export function cleanupStaleUpdateBackup( - options: { readonly execPath?: string; readonly platform?: NodeJS.Platform; readonly isSea?: boolean } = {}, -): void { - const platform = options.platform ?? process.platform; - if (platform !== 'win32') return; - const isSea = options.isSea ?? getSeaAssetSource() !== null; - if (!isSea) return; - const execPath = options.execPath ?? process.execPath; - try { - rmSync(`${execPath}.old`, { force: true }); - } catch { - // Locked or absent; the next startup retries. - } -} diff --git a/apps/pythinker-code/src/native/opentui-library.ts b/apps/pythinker-code/src/native/opentui-library.ts deleted file mode 100644 index 7e0db2a4..00000000 --- a/apps/pythinker-code/src/native/opentui-library.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { resolveOpenTuiTarget } from '../../scripts/native/opentui-target.mjs'; -import { getNativeAssetFilePath, type NativeAssetOptions } from './native-assets'; - -function runtimeTarget(): string { - if (process.env['OPENTUI_LIBC'] === 'musl') { - return `linux-${process.arch}-musl`; - } - return `${process.platform}-${process.arch}`; -} - -export function getOpenTuiLibraryPath(options: NativeAssetOptions = {}): string { - const target = resolveOpenTuiTarget(runtimeTarget()); - const path = getNativeAssetFilePath(target.packageName, target.libraryFile, options); - if (path === null) { - throw new Error(`OpenTUI native library is not available: ${target.packageName}`); - } - return path; -} - -export function getOpenTuiAssetPath( - packageRelativePath: string, - options: NativeAssetOptions = {}, -): string { - const path = getNativeAssetFilePath('@opentui/core', packageRelativePath, options); - if (path === null) { - throw new Error(`OpenTUI asset is not available: ${packageRelativePath}`); - } - return path; -} diff --git a/apps/pythinker-code/src/native/opentui-native-shim.ts b/apps/pythinker-code/src/native/opentui-native-shim.ts deleted file mode 100644 index f2a1ddba..00000000 --- a/apps/pythinker-code/src/native/opentui-native-shim.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getOpenTuiLibraryPath } from './opentui-library'; - -export default getOpenTuiLibraryPath(); diff --git a/apps/pythinker-code/src/native/search-worker.ts b/apps/pythinker-code/src/native/search-worker.ts new file mode 100644 index 00000000..2d1ccee6 --- /dev/null +++ b/apps/pythinker-code/src/native/search-worker.ts @@ -0,0 +1,72 @@ +import { basename } from 'node:path'; + +import { + configureSearchWorkerRuntime, + getSearchWorkerRuntimeState, +} from '@pymodel/kap-server/search-worker-runtime'; + +import { KAP_SEARCH_WORKER_ASSET } from '../../scripts/native/manifest.mjs'; +import { + getEmbeddedNativeAssetManifest, + getKapSearchWorkerFile, + getSeaAssetSource, + type NativeAssetOptions, +} from './native-assets'; + +export type KapSearchWorkerInstallStatus = + | { readonly status: 'not-sea' } + | { readonly status: 'asset-missing' } + | { + readonly status: 'installed'; + readonly assetSha256: string; + readonly basename: string; + } + | { + readonly status: 'failed'; + readonly errorCode: string; + readonly assetSha256?: string; + }; + +function errorCode(error: unknown): string { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (typeof code === 'string' && code.length > 0) return code; + return error instanceof Error ? error.name : 'UNKNOWN'; +} + +/** + * Install the SEA-bundled global-search worker without making optional + * extraction fatal. Without it the search service resolves no worker entry + * inside the single-file binary and reports the index as degraded; the + * `search_worker` experimental flag restores the in-process host. + */ +export function installKapSearchWorker( + options: NativeAssetOptions = {}, +): KapSearchWorkerInstallStatus { + const source = options.source ?? getSeaAssetSource(); + if (source === null) return { status: 'not-sea' }; + + let assetSha256: string | undefined; + try { + const manifest = options.manifest ?? getEmbeddedNativeAssetManifest(source); + const file = manifest?.runtimeFiles.find((entry) => entry.key === KAP_SEARCH_WORKER_ASSET.key); + if (manifest === null || file === undefined) return { status: 'asset-missing' }; + assetSha256 = file.sha256; + + const workerPath = getKapSearchWorkerFile({ ...options, source, manifest }); + if (workerPath === null) return { status: 'asset-missing' }; + configureSearchWorkerRuntime(workerPath); + const runtime = getSearchWorkerRuntimeState(); + if (!runtime.configured) throw new Error('search worker runtime was not configured'); + return { + status: 'installed', + assetSha256, + basename: basename(workerPath), + }; + } catch (error) { + return { + status: 'failed', + errorCode: errorCode(error), + assetSha256, + }; + } +} diff --git a/apps/pythinker-code/src/native/smoke.ts b/apps/pythinker-code/src/native/smoke.ts index 86aa9c82..9017da7f 100644 --- a/apps/pythinker-code/src/native/smoke.ts +++ b/apps/pythinker-code/src/native/smoke.ts @@ -1,36 +1,135 @@ -import { existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { once } from 'node:events'; +import { dirname, join } from 'node:path'; +import { Worker } from 'node:worker_threads'; + +import { MiniDb } from '@pymodel/minidb'; +import { getSearchWorkerRuntimeState } from '@pymodel/kap-server/search-worker-runtime'; import { getEmbeddedNativeAssetManifest, + getNativeCacheBase, getNativePackageRoot, } from './native-assets'; -import { getOpenTuiLibraryPath } from './opentui-library'; -const smokePackages = ['@mariozechner/clipboard', 'koffi', '@opentui/core']; +const smokePackages = ['@mariozechner/clipboard', '@pymodel/pi-tui']; -export function runNativeAssetSmokeIfRequested(): boolean { - if (process.env['PYTHINKER_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; +function smokePiTuiNativeLoad(): void { + const platform = process.platform; + const arch = process.arch; + let rel: string | undefined; + if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'darwin', 'prebuilds', `darwin-${arch}`, 'darwin-modifiers.node'); + } else if (platform === 'win32' && (arch === 'x64' || arch === 'arm64')) { + rel = join('native', 'win32', 'prebuilds', `win32-${arch}`, 'win32-console-mode.node'); + } + if (rel === undefined) return; + const req = createRequire(import.meta.url); + const helper = req(join(dirname(process.execPath), rel)) as { + isModifierPressed?: unknown; + enableVirtualTerminalInput?: unknown; + }; + if ( + typeof helper.isModifierPressed !== 'function' && + typeof helper.enableVirtualTerminalInput !== 'function' + ) { + throw new TypeError(`pi-tui native helper exports are unexpected: ${rel}`); + } +} + +async function smokeMinidbWorker(): Promise<void> { + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-minidb-smoke-')); + let db: MiniDb<Record<string, unknown>> | null = null; try { - const manifest = getEmbeddedNativeAssetManifest(); - if (manifest === null) { - throw new Error('Native asset manifest is not available.'); + db = await MiniDb.open<Record<string, unknown>>({ dir, valueCodec: 'json' }); + const total = 4_200; + for (let base = 0; base < total; base += 500) { + await db.batch( + Array.from({ length: Math.min(500, total - base) }, (_, offset) => { + const id = base + offset; + return { + op: 'set' as const, + key: `doc-${id}`, + value: { text: `sea worker searchable document ${id}` }, + }; + }), + ); + } + await db.createTextIndex('smoke', { fields: ['text'] }); + if (db.stats.textWorkerBuilds < 1) { + throw new Error(`MiniDb worker did not run: ${JSON.stringify(db.stats)}`); + } + if (db.stats.textWorkerFallbacks !== 0) { + throw new Error( + `MiniDb worker unexpectedly fell back: ${db.stats.lastTextWorkerFallback ?? 'unknown'}`, + ); } - for (const packageName of smokePackages) { - const packageRoot = getNativePackageRoot(packageName, { manifest }); - if (packageRoot === null) { - throw new Error(`Native package is not available: ${packageName}`); - } + if (!db.search('smoke', 'searchable').some((hit) => hit.key === 'doc-0')) { + throw new Error('MiniDb worker-built text index returned an incorrect search result'); + } + } finally { + await db?.close().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function smokeSearchWorker(): Promise<void> { + // The SEA-extracted global-search worker entry must boot from disk and + // complete the versioned ready handshake. + const runtime = getSearchWorkerRuntimeState(); + if (!runtime.configured) { + throw new Error('search worker runtime was not configured'); + } + const cacheBase = getNativeCacheBase(); + mkdirSync(cacheBase, { recursive: true }); + const dir = mkdtempSync(join(cacheBase, 'sea-search-worker-')); + const worker = new Worker(runtime.path, { + workerData: { dir, bootSalt: 'sea-smoke' }, + }); + try { + const ready = once(worker, 'message', { + signal: AbortSignal.timeout(15_000), + }) as Promise<unknown[]>; + const [event] = await ready; + const v = (event as { type?: string; v?: number }).v; + if ((event as { type?: string }).type !== 'ready' || typeof v !== 'number') { + throw new Error(`search worker handshake is unexpected: ${JSON.stringify(event)}`); } - const openTuiLibraryPath = getOpenTuiLibraryPath({ manifest }); - if (!existsSync(openTuiLibraryPath)) { - throw new Error(`OpenTUI native library is not available: ${openTuiLibraryPath}`); + } finally { + await worker.terminate().catch(() => {}); + rmSync(dir, { recursive: true, force: true }); + } +} + +async function runSmoke(): Promise<void> { + const manifest = getEmbeddedNativeAssetManifest(); + if (manifest === null) throw new Error('Native asset manifest is not available.'); + for (const packageName of smokePackages) { + if (getNativePackageRoot(packageName, { manifest }) === null) { + throw new Error(`Native package is not available: ${packageName}`); } - process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`); - process.exit(0); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`Native asset smoke failed: ${message}\n`); - process.exit(1); } + smokePiTuiNativeLoad(); + await smokeMinidbWorker(); + await smokeSearchWorker(); + process.stdout.write( + `Native asset smoke passed: ${manifest.target}; MiniDb worker build passed; search worker ready\n`, + ); +} + +export function runNativeAssetSmokeIfRequested(): boolean { + if (process.env['PYTHINKER_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false; + void runSmoke().then( + () => process.exit(0), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`Native asset smoke failed: ${message}\n`); + process.exit(1); + }, + ); + return true; } diff --git a/apps/pythinker-code/src/tui/banner/banner-provider.ts b/apps/pythinker-code/src/tui/banner/banner-provider.ts index bec141c6..0c96d55c 100644 --- a/apps/pythinker-code/src/tui/banner/banner-provider.ts +++ b/apps/pythinker-code/src/tui/banner/banner-provider.ts @@ -1,24 +1,29 @@ import { createHash } from 'node:crypto'; -import { gte, valid } from 'semver'; +import { eq, gte, lt, valid } from 'semver'; import { PYTHINKER_CODE_TIPS_BANNER_URL } from '#/constant/app'; import type { BannerDisplay, BannerState } from '#/tui/types'; import type { BannerDisplayState } from './state'; -interface TipsBannerFallbackItem { +interface BannerVersionFields { + banner_min_version?: string | null; + banner_max_version?: string | null; + banner_version?: string | null; +} + +interface TipsBannerFallbackItem extends BannerVersionFields { banner_id?: string | null; enabled?: boolean; banner_title?: string | null; banner_maintext?: string; banner_subtext?: string | null; - banner_min_version?: string | null; banner_display?: unknown; banner_display_ttl_hours?: unknown; } -interface TipsBannerJson { +interface TipsBannerJson extends BannerVersionFields { banner_id?: string | null; banner_enabled?: boolean; banner_title?: string | null; @@ -26,7 +31,6 @@ interface TipsBannerJson { banner_subtext?: string | null; banner_start_time?: string | null; banner_end_time?: string | null; - banner_min_version?: string | null; banner_display?: unknown; banner_display_ttl_hours?: unknown; banner_fallback_enabled?: boolean; @@ -102,13 +106,27 @@ function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolea return true; } -function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { - if (minVersion === undefined || minVersion === null) return true; - if (typeof minVersion !== 'string' || minVersion.length === 0) return true; - const min = valid(minVersion); +type VersionConstraintCompare = (current: string, target: string) => boolean; + +function meetsVersionConstraint( + constraint: unknown, + clientVersion: string, + compare: VersionConstraintCompare, +): boolean { + if (constraint === undefined || constraint === null) return true; + if (typeof constraint !== 'string' || constraint.length === 0) return true; + const target = valid(constraint); const current = valid(clientVersion); - if (min === null || current === null) return false; - return gte(current, min); + if (target === null || current === null) return false; + return compare(current, target); +} + +function meetsVersion(banner: BannerVersionFields, clientVersion: string): boolean { + return ( + meetsVersionConstraint(banner.banner_min_version, clientVersion, gte) && + meetsVersionConstraint(banner.banner_max_version, clientVersion, lt) && + meetsVersionConstraint(banner.banner_version, clientVersion, eq) + ); } function parseBannerDisplay(value: unknown): BannerDisplay { @@ -179,7 +197,7 @@ function pickActiveBanner( now: Date, ): BannerState | null { if (json.banner_enabled !== true) return null; - if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + if (!meetsVersion(json, clientVersion)) return null; const start = parseDate(json.banner_start_time); const end = parseDate(json.banner_end_time); if (!isWithinWindow(start, end, now)) return null; @@ -209,7 +227,7 @@ function pickFallbackCandidates( if (typeof raw !== 'object' || raw === null) continue; const item = raw as TipsBannerFallbackItem; if (item.enabled !== true) continue; - if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + if (!meetsVersion(item, clientVersion)) continue; const mainText = normalizeText(item.banner_maintext); if (mainText === null) continue; const display = parseBannerDisplay(item.banner_display); diff --git a/apps/pythinker-code/src/tui/commands/add-dir.ts b/apps/pythinker-code/src/tui/commands/add-dir.ts index 28ddfcc3..cea1baee 100644 --- a/apps/pythinker-code/src/tui/commands/add-dir.ts +++ b/apps/pythinker-code/src/tui/commands/add-dir.ts @@ -1,120 +1,111 @@ -import { ApiKeyInputDialogComponent } from '../components/dialogs/api-key-input-dialog'; -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; -import { formatErrorMessage } from '../utils/event-payload'; +import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import type { SlashCommandHost } from './dispatch'; +import { slashBusyMessage, slashCommandBusyReason } from './resolve'; -export async function handleAddDirCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } +type AddDirChoice = 'session' | 'remember' | 'cancel'; - const path = args.trim(); - if (path.length === 0) { - showDirectoryInput(host); +export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> { + const input = args.trim(); + let session = host.session; + + if (input.length === 0 || input.toLowerCase() === 'list') { + // With no session yet (v2 session-less startup) the pending startup + // directories live in appState and will be passed to the lazy-created + // session; reflect them instead of reporting an empty list. + const additionalDirs = session?.summary?.additionalDirs ?? host.state.appState.additionalDirs; + if (additionalDirs.length === 0) { + host.showStatus('No additional directories configured.'); + return; + } + host.showStatus(formatAdditionalDirsStatus(additionalDirs)); return; } - showDirectoryScopePicker(host, path); -} -export function showDirectoryInput(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ApiKeyInputDialogComponent( - 'working directory', - ['Enter a directory to add to the current workspace.'], - (result) => { - host.restoreEditor(); - if (result.kind === 'ok') showDirectoryScopePicker(host, result.value); - }, - { - title: 'Add working directory', - secret: false, - emptyMessage: 'Directory path cannot be empty.', - }, - ), - ); -} + if (session === undefined) { + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // The path-adding form needs a live session; lazy-create it on first use + // (the read-only `list`/bare forms above tolerate a missing session). + session = await host.ensureSession(); + if (session === undefined) return; + // A first prompt may have started a turn during the await; /add-dir is + // idle-only, so re-check the busy gate resolved before it. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage('add-dir', busyReason)); + return; + } + } -function showDirectoryScopePicker(host: SlashCommandHost, path: string): void { host.mountEditorReplacement( new ChoicePickerComponent({ - title: 'Add working directory?', - notice: path, + title: `Add directory to workspace: ${input}`, + hint: '↑↓ navigate · Enter confirm · Esc cancel', options: [ { value: 'session', label: 'Yes, for this session', - description: 'Allow file tools to use this directory in the active session.', }, { value: 'remember', label: 'Yes, and remember this directory', - description: 'Also save it to user configuration for future sessions.', }, - { value: 'cancel', label: 'No' }, + { + value: 'cancel', + label: 'No', + }, ], onSelect: (value) => { - host.restoreEditor(); - if (value === 'cancel') { - host.showNotice(`Did not add ${path} as a working directory.`); - return; - } - void addDirectory(host, path, value === 'remember'); + void handleAddDirChoice(host, session.id, input, value as AddDirChoice); }, onCancel: () => { host.restoreEditor(); - host.showNotice(`Did not add ${path} as a working directory.`); + host.showStatus(`Did not add ${input} as a working directory.`); }, }), ); } -async function addDirectory( +function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string { + return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n'); +} + +async function handleAddDirChoice( host: SlashCommandHost, + sessionId: string, path: string, - remember: boolean, + choice: AddDirChoice, ): Promise<void> { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } + host.restoreEditor(); - let directory: string; - try { - directory = (await session.addWorkspaceDirectory(path)).path; - } catch (error) { - host.showError(`Failed to add working directory: ${formatErrorMessage(error)}`); + if (choice === 'cancel') { + host.showStatus(`Did not add ${path} as a working directory.`); return; } - if (!remember) { - host.track('workspace_directory_added', { remembered: false }); - host.showNotice( - `Added ${directory} as a working directory for this session`, - '/permissions to manage', - ); + const session = host.session; + if (session === undefined || session.id !== sessionId) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } try { - const config = await host.harness.getConfig({ reload: true }); - await host.harness.setConfig({ - additionalDirs: [...new Set([...(config.additionalDirs ?? []), directory])], - }); - host.track('workspace_directory_added', { remembered: true }); - host.showNotice( - `Added ${directory} as a working directory and saved to user settings`, - '/permissions to manage', + const result = await session.addAdditionalDir(path, { persist: choice === 'remember' }); + host.setAppState({ additionalDirs: result.additionalDirs }); + host.refreshSlashCommandAutocomplete(); + host.showStatus( + choice === 'remember' + ? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}` + : `Added workspace directory:\n ${path}\n For this session only`, + 'success', ); } catch (error) { - host.showNotice( - `Added ${directory} as a working directory for this session`, - `Failed to save user settings: ${formatErrorMessage(error)}`, - ); + host.showError(error instanceof Error ? error.message : String(error)); } } diff --git a/apps/pythinker-code/src/tui/commands/advisor.ts b/apps/pythinker-code/src/tui/commands/advisor.ts deleted file mode 100644 index 4c5ecbd4..00000000 --- a/apps/pythinker-code/src/tui/commands/advisor.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { formatErrorMessage, type AdvisorStatusSnapshot } from '@pymodel/pythinker-code-sdk'; -import type { SlashCommandHost } from './dispatch'; - -const ADVISOR_STATUS_GLYPHS: Record<string, string> = { - running: '●', - paused: '○', - no_model: '○', - quota_exhausted: '✕', - error: '✕', -}; - -const ADVISOR_STATUS_LABELS: Record<string, string> = { - running: 'running', - paused: 'off', - no_model: 'no model', - quota_exhausted: 'quota exhausted', - error: 'error', -}; - -export async function handleAdvisorCommand(host: SlashCommandHost, args: string): Promise<void> { - const parts = args.trim().split(/\s+/u).filter(Boolean); - const verb = parts[0] ?? 'status'; - const advisorId = parts[1]; - if (parts.length > 2 || !['on', 'off', 'reload', 'status', 'toggle'].includes(verb)) { - host.showError('Usage: /advisor [on|off|status|reload|toggle] [advisor-id]'); - return; - } - if (host.session === undefined) { - host.showError('No active session.'); - return; - } - - if (verb === 'status') { - host.showNotice('Advisor status', formatAdvisorStatuses(await host.session.advisor.status())); - return; - } - if (verb === 'reload') { - await host.session.advisor.reload(); - host.showStatus('Advisor configuration reloaded.'); - return; - } - - const statuses = await host.session.advisor.status(); - if (advisorId !== undefined && !statuses.some((status) => status.id === advisorId)) { - host.showError( - `Unknown advisor: ${advisorId}. Run /advisor status to list configured advisors.`, - ); - return; - } - const enabled = - verb === 'toggle' - ? !( - statuses.find((status) => - advisorId === undefined ? true : status.id === advisorId, - )?.enabled ?? false - ) - : verb === 'on'; - let updatedStatuses: readonly AdvisorStatusSnapshot[]; - try { - updatedStatuses = await host.session.advisor.setEnabled(enabled, advisorId); - } catch (error) { - host.showError(formatErrorMessage(error)); - return; - } - const target = advisorId === undefined ? 'Advisor' : `Advisor ${advisorId}`; - const applied = - advisorId === undefined - ? updatedStatuses.length > 0 && - updatedStatuses.every((status) => status.enabled === enabled) - : updatedStatuses.find((status) => status.id === advisorId)?.enabled === enabled; - if (!applied) { - host.showError(`${target} remains ${enabled ? 'disabled' : 'enabled'}.`); - return; - } - host.showStatus(`${target} ${enabled ? 'enabled' : 'disabled'}.`); -} - -function formatAdvisorStatuses(statuses: readonly AdvisorStatusSnapshot[]): string { - if (statuses.length === 0) return 'Advisor is disabled.'; - return statuses - .map((advisor) => { - const glyph = ADVISOR_STATUS_GLYPHS[advisor.status] ?? '?'; - const label = ADVISOR_STATUS_LABELS[advisor.status] ?? advisor.status; - const model = advisor.model === undefined ? '' : `\n Model: ${advisor.model}`; - const details = `\n ${advisor.notes} notes · $${advisor.costUsd.toFixed(4)} · ${advisor.failures} failures`; - const message = advisor.message === undefined ? '' : `\n ${advisor.message}`; - return `${glyph} ${advisor.name} [${label}]${advisor.enabled ? '' : ' (disabled)'}${model}${details}${message}`; - }) - .join('\n\n'); -} diff --git a/apps/pythinker-code/src/tui/commands/agents.ts b/apps/pythinker-code/src/tui/commands/agents.ts deleted file mode 100644 index 3049ecce..00000000 --- a/apps/pythinker-code/src/tui/commands/agents.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { - AgentProfileSummary, -} from '@pymodel/pythinker-code-sdk'; - -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; -import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './dispatch'; - -export async function handleAgentsCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /agents'); - return; - } - - let catalog; - try { - catalog = await host.harness.listAgentProfiles(host.state.appState.workDir); - } catch (error) { - host.showError(`Failed to load agent profiles: ${formatErrorMessage(error)}`); - return; - } - for (const warning of catalog.warnings) { - host.showStatus(`${warning.path}: ${warning.error}`, 'warning'); - } - if (catalog.profiles.length === 0) { - host.showNotice( - 'No agent profiles found', - 'Create profiles in .pythinker-code/agents or ~/.pythinker-code/agents.', - ); - return; - } - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Agent profiles', - options: catalog.profiles.map((profile) => ({ - value: profile.name, - label: profile.name, - description: [ - profile.source, - profile.background === true ? 'background' : 'foreground', - profile.description, - ].filter((part) => part !== undefined && part.length > 0).join(' · '), - })), - searchable: true, - onSelect: (name) => { - host.restoreEditor(); - const profile = catalog.profiles.find((candidate) => candidate.name === name); - if (profile !== undefined) showAgentProfile(host, profile); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -function showAgentProfile(host: SlashCommandHost, profile: AgentProfileSummary): void { - const settings = [ - `source: ${profile.source}`, - `tools: ${profile.tools.length === 0 ? 'all inherited tools' : profile.tools.join(', ')}`, - profile.model === undefined ? undefined : `model: ${profile.model}`, - profile.effort === undefined ? undefined : `effort: ${profile.effort}`, - profile.permissionMode === undefined - ? undefined - : `permission: ${profile.permissionMode}`, - profile.background === undefined - ? undefined - : `execution: ${profile.background ? 'background' : 'foreground'}`, - profile.maxTurns === undefined ? undefined : `max turns: ${String(profile.maxTurns)}`, - profile.isolation === undefined ? undefined : `isolation: ${profile.isolation}`, - profile.memory === undefined ? undefined : `memory: ${profile.memory}`, - profile.subagents.length === 0 - ? undefined - : `subagents: ${profile.subagents.join(', ')}`, - profile.whenToUse === undefined ? undefined : `when to use: ${profile.whenToUse}`, - ].filter((line): line is string => line !== undefined); - host.showNotice(profile.name, [profile.description, ...settings].filter(Boolean).join('\n')); -} diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index 86c795c7..fb69cb5e 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -1,86 +1,227 @@ import { - connectCatalogProvider as connectCatalogProviderFlow, - runLogin, - type CatalogProviderEntry, - type LoginUi, -} from '@pymodel/pythinker-code-sdk'; + applyOpenAICodexOAuthConfig, + applyOpenPlatformConfig, + fetchOpenAICodexModels, + fetchOpenPlatformModels, + filterModelsByPrefix, + getOpenPlatformById, + OpenPlatformApiError, + OPENAI_CODEX_OAUTH_PLATFORM_ID, + OPENAI_CODEX_PROVIDER_ID, + runOpenAICodexOAuthFlow, + type ManagedPythinkerCodeModelInfo, + type ManagedPythinkerConfigShape, + type OpenPlatformDefinition, +} from '@pymodel/pythinker-code-oauth'; import type { ChoiceOption } from '../components/dialogs/choice-picker'; +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '../constant/pythinker-tui'; +import { formatErrorMessage } from '../utils/event-payload'; import { promptApiKey, promptLogoutProviderSelection, - promptModelSelectionForCatalog, + promptModelSelectionForCodex, promptModelSelectionForOpenPlatform, promptPlatformSelection, } from './prompts'; import { openUrl } from '#/utils/open-url'; - import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Auth: login / logout // --------------------------------------------------------------------------- -function loginUiFromHost(host: SlashCommandHost): LoginUi { - return { - harness: host.harness, - sessionId: host.session?.id, - get cancelInFlight() { - return host.cancelInFlight; - }, - set cancelInFlight(cancelInFlight: (() => void) | undefined) { - host.cancelInFlight = cancelInFlight; - }, - openBrowser: (url) => { - openUrl(url); - }, - showStatus: (message) => { - host.showStatus(message); - }, - showError: (message) => { - host.showError(message); - }, - showLoginProgressSpinner: (label) => host.showLoginProgressSpinner(label), - promptPlatformSelection: () => promptPlatformSelection(host), - promptApiKey: (platformName, subtitleLines, options) => - options === undefined - ? promptApiKey(host, platformName, subtitleLines) - : promptApiKey(host, platformName, subtitleLines, options), - promptModelSelectionForOpenPlatform: (models, platform) => - promptModelSelectionForOpenPlatform(host, models, platform), - promptModelSelectionForCatalog: (providerId, models) => - promptModelSelectionForCatalog(host, providerId, models), - refreshConfigAfterLogin: () => host.authFlow.refreshConfigAfterLogin(), - track: (event, props) => { - host.track(event, props); - }, - }; +export async function handleLoginCommand(host: SlashCommandHost): Promise<void> { + const platformId = await promptPlatformSelection(host); + if (platformId === undefined) return; + + if (platformId === OPENAI_CODEX_OAUTH_PLATFORM_ID) { + await handleOpenAICodexLogin(host); + return; + } + + const platform = getOpenPlatformById(platformId); + if (platform === undefined) return; + await handleOpenPlatformLogin(host, platform); } -export async function handleLoginCommand(host: SlashCommandHost): Promise<void> { - await runLogin(loginUiFromHost(host)); +async function handleOpenAICodexLogin(host: SlashCommandHost): Promise<void> { + const controller = new AbortController(); + let committing = false; + const cancelLogin = (): void => { + if (!committing) controller.abort(); + }; + host.cancelInFlight = cancelLogin; + + try { + const tokens = await runOpenAICodexOAuthFlow({ + signal: controller.signal, + openBrowser: openUrl, + onManualInput: () => + promptApiKey( + host, + 'OpenAI Codex', + ['Paste the redirected localhost URL from your browser.'], + { + title: 'Paste OpenAI Codex redirect URL', + mask: false, + emptyHint: 'Redirect URL cannot be empty.', + }, + ), + }); + const models = await fetchOpenAICodexModels({ + accessToken: tokens.accessToken, + accountId: tokens.accountId, + signal: controller.signal, + }); + if (models.length === 0) { + host.showError('No models available for OpenAI Codex.'); + return; + } + + const selection = await promptModelSelectionForCodex(host, models); + if (selection === undefined) return; + + controller.signal.throwIfAborted(); + const current = await host.harness.getConfig({ reload: true }); + controller.signal.throwIfAborted(); + const next = { + ...current, + providers: { ...current.providers }, + models: { ...current.models }, + }; + applyOpenAICodexOAuthConfig(next, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + accountId: tokens.accountId, + models, + selectedModel: selection.model, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, + }); + committing = true; + await host.harness.replaceConfigSections({ + providers: next.providers, + models: next.models, + defaultModel: next.defaultModel, + thinking: next.thinking, + }); + await host.authFlow.refreshConfigAfterLogin(); + host.track('login', { provider: OPENAI_CODEX_PROVIDER_ID, method: 'oauth' }); + host.showStatus(`Setup complete: OpenAI Codex · ${selection.model.id}`); + } catch (error) { + if (!controller.signal.aborted) { + host.showError(`OpenAI Codex login failed: ${formatErrorMessage(error)}`); + } + } finally { + if (host.cancelInFlight === cancelLogin) host.cancelInFlight = undefined; + } } -export async function connectCatalogProvider( +async function handleOpenPlatformLogin( host: SlashCommandHost, - providerId: string, - selectedCatalogEntry?: CatalogProviderEntry, - displayName?: string, + platform: OpenPlatformDefinition, ): Promise<void> { - await connectCatalogProviderFlow( - loginUiFromHost(host), - providerId, - selectedCatalogEntry, - displayName, - ); + const consoleHost = platform.consoleUrl?.replace(/^https?:\/\//, '') ?? ''; + const platformName = consoleHost.length > 0 ? `Kimi Platform (${consoleHost})` : 'Kimi Platform'; + const subtitleLines = [ + `${'base_url'.padEnd(12)}${platform.baseUrl}`, + `${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`, + ]; + const apiKey = await promptApiKey(host, platformName, subtitleLines); + if (apiKey === undefined) return; + + const controller = new AbortController(); + const cancelLogin = (): void => { + controller.abort(); + }; + host.cancelInFlight = cancelLogin; + + let models: ManagedPythinkerCodeModelInfo[]; + try { + models = await fetchOpenPlatformModels(platform, apiKey, fetch, controller.signal); + models = filterModelsByPrefix(models, platform); + } catch (error) { + if (controller.signal.aborted) return; + const msg = formatErrorMessage(error); + host.showError(`Failed to verify API key: ${msg}`); + if ( + error instanceof OpenPlatformApiError && + error.status === 401 + ) { + host.showStatus( + 'Hint: If your API key was obtained from Pythinker Code, please select "Pythinker Code" instead.', + ); + } + return; + } finally { + if (host.cancelInFlight === cancelLogin) { + host.cancelInFlight = undefined; + } + } + + if (models.length === 0) { + host.showError('No models available for this platform.'); + return; + } + + const selection = await promptModelSelectionForOpenPlatform(host, models, platform); + if (selection === undefined) return; + + const existingConfig = await host.harness.getConfig(); + if (existingConfig.providers[platform.id] !== undefined) { + await host.harness.removeProvider(platform.id); + } + + const config = await host.harness.getConfig(); + applyOpenPlatformConfig(config as ManagedPythinkerConfigShape, { + platform, + models, + selectedModel: selection.model, + thinking: selection.thinking !== 'off', + effort: + selection.thinking !== 'off' && selection.thinking !== 'on' + ? selection.thinking + : undefined, + apiKey, + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + defaultModel: config.defaultModel, + thinking: config.thinking, + }); + + await host.authFlow.refreshConfigAfterLogin(); + host.track('login', { provider: platform.id, method: 'api_key' }); + host.showStatus(`Setup complete: ${platform.name} · ${selection.model.id}`); } export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> { + const oauthStatus = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + const hasOAuthToken = oauthStatus.providers.some( + (p) => p.providerName === DEFAULT_OAUTH_PROVIDER_NAME && p.hasToken, + ); const config = await host.harness.getConfig(); - const providerIds = Object.keys(config.providers ?? {}).toSorted(); + const hasManagedRemnant = + hasOAuthToken || config.providers[DEFAULT_OAUTH_PROVIDER_NAME] !== undefined; + const apiKeyProviderIds = Object.keys(config.providers ?? {}) + .filter((id) => id !== DEFAULT_OAUTH_PROVIDER_NAME) + .toSorted(); const options: ChoiceOption[] = []; - for (const id of providerIds) { + if (hasManagedRemnant) { + options.push({ + value: DEFAULT_OAUTH_PROVIDER_NAME, + label: PRODUCT_NAME, + description: 'OAuth login', + }); + } + for (const id of apiKeyProviderIds) { const baseUrl = config.providers[id]?.baseUrl; options.push({ value: id, @@ -100,7 +241,11 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> const target = await promptLogoutProviderSelection(host, options, currentProvider); if (target === undefined) return; - await host.harness.removeProvider(target); + if (target === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + } else { + await host.harness.removeProvider(target); + } if (target === currentProvider) { await host.authFlow.refreshConfigAfterLogout(); @@ -114,5 +259,6 @@ export async function handleLogoutCommand(host: SlashCommandHost): Promise<void> } host.track('logout', { provider: target }); - host.showStatus(`Logged out from ${target}.`); + const label = target === DEFAULT_OAUTH_PROVIDER_NAME ? PRODUCT_NAME : target; + host.showStatus(`Logged out from ${label}.`); } diff --git a/apps/pythinker-code/src/tui/commands/btw.ts b/apps/pythinker-code/src/tui/commands/btw.ts index 77df9adb..151dbdb5 100644 --- a/apps/pythinker-code/src/tui/commands/btw.ts +++ b/apps/pythinker-code/src/tui/commands/btw.ts @@ -1,5 +1,6 @@ import { LLM_NOT_SET_MESSAGE } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; import type { SlashCommandHost } from './dispatch'; export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise<void> { @@ -13,7 +14,14 @@ export async function handleBtwCommand(host: SlashCommandHost, args: string): Pr try { const agentId = await session.startBtw(); - host.btwPanelController.open(agentId, prompt); + const activations = host.engineV2 + ? extractInlineSkillActivations(prompt, host.skillCommandMap, { includeLeading: true }) + : []; + host.btwPanelController.open( + agentId, + prompt, + activations.length > 0 ? activations : undefined, + ); } catch (error) { host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); } diff --git a/apps/pythinker-code/src/tui/commands/complete-args.ts b/apps/pythinker-code/src/tui/commands/complete-args.ts index 7dcf25c2..0cbf67b8 100644 --- a/apps/pythinker-code/src/tui/commands/complete-args.ts +++ b/apps/pythinker-code/src/tui/commands/complete-args.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import type { AutocompleteItem } from '@pymodel/pi-tui'; /** * A completable token (subcommand or flag) for a slash command's argument diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index 7352f4c7..fa4807d6 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -1,62 +1,77 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join, resolve } from 'node:path'; - -import type { - ExperimentalFeatureState, - FlagId, - OutputStyleCatalog, - PermissionMode, - PythinkerConfig, - Session, - WorkspaceDirectory, +import { + effectiveModelAlias, + PRIMARY_SUBAGENT_MODEL_CHOICE, + SECONDARY_DERIVED_MODEL_ALIAS, + type ExperimentalFeatureState, + type ModelAlias, + type PermissionMode, + type Session, + type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; -import { coerceEffortForModel, effortLevelsForModel } from '@pymodel/pythinker-code-sdk'; -import { disableTelemetry } from '@pymodel/pythinker-telemetry'; -import { ApiKeyInputDialogComponent } from '../components/dialogs/api-key-input-dialog'; -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '../components/dialogs/experiments-selector'; -import { - modelDisplayName, - modelIdentity, - normalizeModelChoices, - resolveNormalizedModelAlias, -} from '../components/dialogs/model-selector'; +import { modelDisplayName, segmentsFor } from '../components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; import { PermissionSelectorComponent } from '../components/dialogs/permission-selector'; import { SettingsSelectorComponent, type SettingsSelection } from '../components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; -import { saveTuiConfig } from '../config'; -import { generateKeybindingsTemplate } from '../keybindings'; -import { persistDefaultModelSelection } from '../utils/persist-effort'; +import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; -import { - openFileInExternalEditor, - resolveEditorCommand, -} from '#/utils/process/external-editor'; -import { - BUILT_IN_MODEL_ROLES, - LLM_NOT_SET_MESSAGE, - NO_ACTIVE_SESSION_MESSAGE, -} from '#/tui/constant/pythinker-tui'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; -import { showDirectoryInput } from './add-dir'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- // Plan / Config commands // --------------------------------------------------------------------------- +const MODEL_PICKER_REFRESH_TIMEOUT_MS = 2_000; + +const MODEL_SWITCH_CACHE_WARNING = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; +const EFFORT_SWITCH_CACHE_WARNING = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + +/** True once the conversation has at least one user message: a switch from + * then on resends the accumulated context, losing the cache. Shell-command + * echoes are also 'user' transcript entries but carry an empty `bullet`, so + * they're excluded. */ +function hasConversationHistory(host: SlashCommandHost): boolean { + return host.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.bullet !== '', + ); +} + +export function currentTuiConfig(host: Pick<SlashCommandHost, 'state'>): TuiConfig { + return { + theme: host.state.appState.theme, + editorCommand: host.state.appState.editorCommand, + disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, + cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, + notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, + statusLine: host.state.appState.statusLine ?? DEFAULT_TUI_CONFIG.statusLine, + }; +} + +export function effectiveModelForHost(host: SlashCommandHost, model: ModelAlias): ModelAlias { + const providerType = host.state.appState.availableProviders[model.provider]?.type; + // Flat models (no named provider, e.g. inline base_url served by a v2 + // backend) have no provider entry to look up; their own protocol declaration + // plays the provider-identity role, mirroring the resolver. + return effectiveModelAlias(model, providerType ?? model.protocol); +} export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> { const session = host.session; @@ -81,6 +96,13 @@ export async function handlePlanCommand(host: SlashCommandHost, args: string): P return; } + // The session may already be in the requested mode (e.g. it was created + // with config.defaultPlanMode applied), and re-entering plan mode throws. + if (host.state.appState.planMode === enabled) { + host.showNotice(`Plan mode is already ${enabled ? 'on' : 'off'}`); + return; + } + await applyPlanMode(host, session, enabled); } @@ -105,10 +127,12 @@ async function applyPlanMode(host: SlashCommandHost, session: Session, enabled: export async function handleYoloCommand(host: SlashCommandHost, args: string): Promise<void> { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -118,7 +142,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already on'); return; } - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); return; @@ -129,7 +153,7 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P host.showNotice('YOLO mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); return; @@ -137,11 +161,11 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'yolo') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('YOLO mode: OFF'); } else { - await session.setPermission('yolo'); + await session?.setPermission('yolo'); host.setAppState({ permissionMode: 'yolo' }); host.showNotice('YOLO mode: ON', 'Tool actions auto-approved; the agent may still ask you questions.'); } @@ -149,10 +173,12 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P export async function handleAutoCommand(host: SlashCommandHost, args: string): Promise<void> { const session = host.session; - if (session === undefined) { + if (session === undefined && !host.engineV2) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } + // v2 session-less: the chosen mode is recorded in appState and passed to the + // lazy-created session; apply the runtime permission only when one exists. const subcmd = args.trim().toLowerCase(); const currentMode = host.state.appState.permissionMode; @@ -162,7 +188,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already on'); return; } - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); return; @@ -173,7 +199,7 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P host.showNotice('Auto mode is already off'); return; } - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); return; @@ -181,11 +207,11 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P // toggle if (currentMode === 'auto') { - await session.setPermission('manual'); + await session?.setPermission('manual'); host.setAppState({ permissionMode: 'manual' }); host.showNotice('Auto mode: OFF'); } else { - await session.setPermission('auto'); + await session?.setPermission('auto'); host.setAppState({ permissionMode: 'auto' }); host.showNotice('Auto mode: ON', 'All actions auto-approved; the agent will not ask you questions.'); } @@ -210,69 +236,6 @@ export async function handleEditorCommand(host: SlashCommandHost, args: string): await applyEditorChoice(host, command); } -export async function handleKeybindingsCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /keybindings'); - return; - } - - const path = join(host.harness.homeDir, 'keybindings.json'); - await mkdir(host.harness.homeDir, { recursive: true }); - let created = true; - try { - await writeFile(path, generateKeybindingsTemplate(), { encoding: 'utf8', flag: 'wx' }); - } catch (error) { - if (!isFileExists(error)) throw error; - created = false; - } - - const command = resolveEditorCommand(host.state.appState.editorCommand); - if (command === undefined) { - host.reloadKeybindings?.(); - host.showNotice( - `${created ? 'Created' : 'Keybindings file'}: ${path}`, - 'No editor configured. Set $VISUAL / $EDITOR, or run /editor <command>.', - ); - return; - } - - const opened = await openFileWithTuiSuspended(host, path, command); - - const warnings = host.reloadKeybindings?.() ?? []; - if (!opened) { - host.showError(`Editor exited before saving ${path}.`); - return; - } - host.showNotice( - `${created ? 'Created' : 'Opened'} ${path} in your editor.`, - warnings.length === 0 ? 'Keybindings reloaded.' : warnings.join(' '), - ); -} - -export async function openFileWithTuiSuspended( - host: SlashCommandHost, - path: string, - command: string, -): Promise<boolean> { - host.setExternalEditorRunning?.(true); - host.state.ui.stop(); - await new Promise<void>((resolve) => { - setImmediate(resolve); - }); - try { - return await openFileInExternalEditor(path, command); - } finally { - if (typeof process.stdin.pause === 'function') process.stdin.pause(); - host.state.ui.start(); - host.state.ui.setFocus(host.state.editor); - host.state.ui.requestRender(true); - host.setExternalEditorRunning?.(false); - } -} - export async function handleThemeCommand(host: SlashCommandHost, args: string): Promise<void> { const theme = args.trim(); if (theme.length === 0) { @@ -289,147 +252,103 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): await applyThemeChoice(host, theme); } -export async function handleOutputStyleCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - let catalog: OutputStyleCatalog; - try { - catalog = await host.harness.listOutputStyles(host.state.appState.workDir); - } catch (error) { - host.showError(`Failed to load output styles: ${formatErrorMessage(error)}`); +export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = args.trim(); + await refreshModelsForPicker(host); + if (alias.length === 0) { + showModelPicker(host); + return; + } + if (host.state.appState.availableModels[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); return; } + showModelPicker(host, alias); +} - const requested = args.trim(); - if (requested.length === 0) { - showOutputStylePicker(host, catalog); +export async function handleSecondaryModelCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = args.trim(); + await refreshModelsForPicker(host); + const models = pickerModelsForHost(host); + // The pool reserves `primary` as the symbolic "caller's own model" choice — + // a user alias with that name can never be the subagent default. + delete models[PRIMARY_SUBAGENT_MODEL_CHOICE]; + if (alias === PRIMARY_SUBAGENT_MODEL_CHOICE) { + host.showError( + `"${PRIMARY_SUBAGENT_MODEL_CHOICE}" is reserved by the subagent model pool (it always binds the caller's own model) — rename the [models] alias to use it here.`, + ); + return; + } + if (Object.keys(models).length === 0) { + host.showNotice( + 'No models configured', + 'Run /login to sign in to Pythinker, or /provider to add another provider from a model catalog.', + ); return; } - if (!catalog.styles.some((style) => style.name === requested)) { - host.showError(`Unknown output style: ${requested}`); + if (alias.length > 0 && models[alias] === undefined) { + host.showError(`Unknown model alias: ${alias}`); return; } - await applyOutputStyleChoice(host, catalog, requested); + const secondary = (await host.harness.getConfig()).secondaryModel; + // The v2 engine honors a lone legacy `model` key as the fallback pool + // default — reflect it as the picker's current value. + const current = secondary?.defaultModel ?? secondary?.model ?? ''; + showSecondaryModelPicker(host, models, current, alias.length > 0 ? alias : undefined); } -type PermissionRule = NonNullable< - NonNullable<PythinkerConfig['permission']>['rules'] ->[number]; -type PermissionRuleDecision = PermissionRule['decision']; - -export async function handlePermissionsCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /permissions'); +export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> { + const alias = host.state.appState.model; + const model = host.state.appState.availableModels[alias]; + if (model === undefined) { + host.showError('No model selected. Run /model to select one first.'); return; } - - let rules: readonly PermissionRule[]; - let directories: readonly WorkspaceDirectory[]; - try { - const config = await host.harness.getConfig({ reload: true }); - rules = config.permission?.rules ?? []; - directories = - host.session === undefined - ? (config.additionalDirs ?? []).map((path) => ({ path, source: 'user' as const })) - : await host.session.listWorkspaceDirectories(); - } catch (error) { - host.showError(`Failed to load permissions: ${formatErrorMessage(error)}`); + const effective = effectiveModelForHost(host, model); + const segments = segmentsFor(effective); + const arg = args.trim().toLowerCase(); + if (arg.length === 0) { + showEffortPicker(host, effective, segments); return; } - - const addOptions = (['allow', 'ask', 'deny'] as const).map((decision) => ({ - value: `add:${decision}`, - label: `Add ${decision} rule`, - description: 'Save a user-level tool permission rule.', - })); - const ruleOptions = rules.map((rule, index) => ({ - value: `rule:${String(index)}`, - label: `${rule.decision} · ${rule.pattern}`, - description: `${rule.scope}${rule.reason === undefined ? '' : ` · ${rule.reason}`}`, - tone: rule.decision === 'deny' ? ('danger' as const) : undefined, - })); - const directoryOptions = directories.map((directory, index) => ({ - value: `directory:${String(index)}`, - label: directory.path, - description: - directory.source === 'user' - ? 'Working directory · saved in user settings' - : 'Working directory · this session', - })); - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Manage permission rules', - options: [ - ...addOptions, - ...ruleOptions, - { - value: 'add-directory', - label: 'Add working directory', - description: 'Allow file tools to use another directory.', - }, - ...directoryOptions, - ], - searchable: true, - onSelect: (value) => { - host.restoreEditor(); - if (value.startsWith('add:')) { - showPermissionRuleInput(host, value.slice(4) as PermissionRuleDecision); - return; - } - if (value === 'add-directory') { - if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - } else { - showDirectoryInput(host); - } - return; - } - if (value.startsWith('directory:')) { - const directory = directories[Number(value.slice(10))]; - if (directory !== undefined) { - showWorkspaceDirectoryDeleteConfirmation(host, directory); - } - return; - } - const rule = rules[Number(value.slice(5))]; - if (rule !== undefined) showPermissionRuleDeleteConfirmation(host, rule); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); + if (!segments.includes(arg)) { + const providerType = host.state.appState.availableProviders[effective.provider]?.type; + const protocol = effective.protocol ?? providerType; + if (protocol !== 'anthropic') { + host.showError( + `Unsupported thinking effort "${arg}" for ${alias}. Available: ${segments.join(', ')}`, + ); + return; + } + const knownEfforts = effective.supportEfforts?.join(', ') ?? 'none declared'; + host.showStatus( + `Thinking effort "${arg}" is not listed for ${alias} (known: ${knownEfforts}). Sending "${arg}" unchanged; the configured provider will validate it.`, + 'warning', + ); + } + await performModelSwitch(host, alias, arg, true); } -function showWorkspaceDirectoryDeleteConfirmation( +function showEffortPicker( host: SlashCommandHost, - directory: WorkspaceDirectory, + model: ModelAlias, + segments: readonly string[], ): void { + const liveEffort = host.state.appState.thinkingEffort; + const currentValue = segments.includes(liveEffort) ? liveEffort : (segments[0] ?? 'off'); + const alias = host.state.appState.model; host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Remove working directory?', - notice: directory.path, - currentValue: 'cancel', - options: [ - { - value: 'remove', - label: 'Remove directory', - description: - directory.source === 'user' - ? 'Remove it from this session and user settings.' - : 'Remove it from this session.', - tone: 'danger', - }, - { value: 'cancel', label: 'Keep directory' }, - ], - onSelect: (value) => { + new EffortSelectorComponent({ + efforts: segments, + currentValue, + warning: hasConversationHistory(host) ? EFFORT_SWITCH_CACHE_WARNING : undefined, + onSelect: (effort) => { host.restoreEditor(); - if (value === 'remove') void removeWorkspaceDirectory(host, directory); + void performModelSwitch(host, alias, effort, true); + }, + onSessionOnlySelect: (effort) => { + host.restoreEditor(); + void performModelSwitch(host, alias, effort, false); }, onCancel: () => { host.restoreEditor(); @@ -438,108 +357,6 @@ function showWorkspaceDirectoryDeleteConfirmation( ); } -async function removeWorkspaceDirectory( - host: SlashCommandHost, - directory: WorkspaceDirectory, -): Promise<void> { - try { - await host.session?.removeWorkspaceDirectory(directory.path); - } catch (error) { - host.showError(`Failed to remove working directory: ${formatErrorMessage(error)}`); - return; - } - - if (directory.source === 'session') { - host.showNotice(`Removed working directory ${directory.path}.`); - return; - } - - try { - const config = await host.harness.getConfig({ reload: true }); - const workDir = host.state.appState.workDir; - await host.harness.setConfig({ - additionalDirs: (config.additionalDirs ?? []).filter( - (candidate) => - resolveWorkspaceConfigPath(candidate, workDir) !== - resolveWorkspaceConfigPath(directory.path, workDir), - ), - }); - host.showNotice(`Removed working directory ${directory.path} from user settings.`); - } catch (error) { - host.showNotice( - `Removed working directory ${directory.path} from this session.`, - `Failed to save user settings: ${formatErrorMessage(error)}`, - ); - } -} - -function resolveWorkspaceConfigPath(input: string, workDir: string): string { - const expanded = - input === '~' - ? homedir() - : input.startsWith('~/') || input.startsWith('~\\') - ? join(homedir(), input.slice(2)) - : input; - return resolve(workDir, expanded); -} - -export async function handleModelCommand(host: SlashCommandHost, args: string): Promise<void> { - const requestedAlias = args.trim(); - const tokens = requestedAlias.split(/\s+/u).filter(Boolean); - const config = await host.harness.getConfig({ reload: true }); - const roles = [...new Set([...BUILT_IN_MODEL_ROLES, ...Object.keys(config.modelRoles ?? {})])] - .filter((role) => role.length > 0 && role !== 'default'); - - if (tokens.length === 1 && tokens[0] === 'roles') { - host.showNotice( - 'Model roles', - roles - .map((role) => `${role}: ${config.modelRoles?.[role]?.trim() || '(not set)'}`) - .join('\n'), - ); - return; - } - - const role = tokens[0]; - if (role !== undefined && roles.includes(role)) { - if (tokens.length === 2 && (tokens[1] === 'clear' || tokens[1] === 'none')) { - await host.harness.setConfig({ modelRoles: { [role]: '' } }); - host.showStatus(`Cleared the ${role} model role.`, 'success'); - return; - } - if (tokens.length === 1) { - const picker = showModelPicker(host, config.modelRoles?.[role], undefined, { - assignToRole: role, - }); - if (picker !== undefined) { - void refreshModelsForOpenPicker(host, picker, config.modelRoles?.[role], { - assignToRole: role, - }); - } - return; - } - } - - const normalized = normalizeModelChoices(host.state.appState.availableModels); - const selectedValue = - requestedAlias.length === 0 - ? undefined - : resolveNormalizedModelAlias( - normalized, - requestedAlias, - host.state.appState.availableModels[requestedAlias], - ); - if (requestedAlias.length > 0 && selectedValue === undefined) { - host.showError(`Unknown model alias: ${requestedAlias}`); - return; - } - - const picker = showModelPicker(host, selectedValue); - if (picker !== undefined) { - void refreshModelsForOpenPicker(host, picker, selectedValue); - } -} - // --------------------------------------------------------------------------- // Pickers & config apply // --------------------------------------------------------------------------- @@ -560,62 +377,35 @@ function showEditorPicker(host: SlashCommandHost): void { ); } -async function refreshModelsForOpenPicker( - host: SlashCommandHost, - picker: TabbedModelSelectorComponent, - selectedValue: string | undefined, - options?: { assignToRole?: string }, -): Promise<void> { - const availableModels = host.state.appState.availableModels; - const normalized = normalizeModelChoices(availableModels); - const currentModel = availableModels[host.state.appState.model]; - +async function refreshModelsForPicker(host: SlashCommandHost): Promise<void> { try { - const result = await host.authFlow.refreshProviderModels(); + const result = await withTimeout( + host.authFlow.refreshOAuthProviderModels(), + MODEL_PICKER_REFRESH_TIMEOUT_MS, + ); + if (result === undefined) return; for (const f of result.failed) { host.showStatus(`Skipped refreshing ${f.provider}: ${f.reason}`, 'warning'); } } catch (error) { host.showStatus(`Skipped refreshing models: ${formatErrorMessage(error)}`, 'warning'); - return; - } - - if (host.state.editorContainer.children[0] !== picker) return; - - const liveSelectedAlias = picker.selectedAlias() ?? selectedValue; - const selectedModel = - liveSelectedAlias === undefined - ? undefined - : normalized.models[liveSelectedAlias] ?? availableModels[liveSelectedAlias]; - const activeTabId = picker.activeTabId(); - - const refreshed = normalizeModelChoices(host.state.appState.availableModels); - if (currentModel !== undefined) { - const refreshedCurrent = resolveNormalizedModelAlias( - refreshed, - host.state.appState.model, - currentModel, - ); - if (refreshedCurrent === undefined) return; - if (modelIdentity(refreshed.models[refreshedCurrent]) !== modelIdentity(currentModel)) { - return; - } } +} - let refreshedSelected = liveSelectedAlias; - if (selectedModel !== undefined) { - refreshedSelected = resolveNormalizedModelAlias( - refreshed, - liveSelectedAlias ?? '', - selectedModel, - ); - if (refreshedSelected === undefined) return; - if (modelIdentity(refreshed.models[refreshedSelected]) !== modelIdentity(selectedModel)) { - return; - } +async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> { + let timeout: ReturnType<typeof setTimeout> | undefined; + try { + return await Promise.race([ + promise, + new Promise<undefined>((resolve) => { + timeout = setTimeout(() => { + resolve(undefined); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); } - - showModelPicker(host, refreshedSelected, activeTabId, options); } async function applyEditorChoice(host: SlashCommandHost, value: string): Promise<void> { @@ -628,13 +418,8 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise const editorCommand = value.length > 0 ? value : null; try { await saveTuiConfig({ - theme: host.state.appState.theme, - layout: host.state.layout, + ...currentTuiConfig(host), editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, - statusLine: host.state.appState.statusLine, - copyFullResponse: host.state.copyFullResponse, }); } catch (error) { host.showStatus( @@ -652,79 +437,79 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise ); } -export function showModelPicker( - host: SlashCommandHost, - selectedValue?: string, - initialTabId?: string, - options?: { assignToRole?: string }, -): TabbedModelSelectorComponent | undefined { - const normalized = normalizeModelChoices(host.state.appState.availableModels); - const entries = Object.entries(normalized.models); +/** + * The models a picker may offer: the user's configured aliases with + * host-effective provider resolution applied, minus the synthesized + * `__secondary__` derived entry — a runtime artifact of the v1 engine's + * `[secondary_model]` recipe that must never be selectable as a model. + */ +function pickerModelsForHost(host: SlashCommandHost): Record<string, ModelAlias> { + return Object.fromEntries( + Object.entries(host.state.appState.availableModels) + .filter(([alias]) => alias !== SECONDARY_DERIVED_MODEL_ALIAS) + .map(([alias, model]) => [alias, effectiveModelForHost(host, model)]), + ); +} + +export function showModelPicker(host: SlashCommandHost, selectedValue: string = host.state.appState.model): void { + const models = pickerModelsForHost(host); + const entries = Object.entries(models); if (entries.length === 0) { host.showNotice( 'No models configured', 'Run /login to sign in to Pythinker, or /provider to add another provider from a model catalog.', ); - return undefined; - } - const currentValue = - resolveNormalizedModelAlias( - normalized, - host.state.appState.model, - host.state.appState.availableModels[host.state.appState.model], - ) ?? host.state.appState.model; - const selectedCandidate = selectedValue ?? host.state.appState.model; - const resolvedSelectedValue = - resolveNormalizedModelAlias( - normalized, - selectedCandidate, - host.state.appState.availableModels[selectedCandidate], - ) ?? currentValue; - const picker = new TabbedModelSelectorComponent({ - models: normalized.models, - currentValue, - selectedValue: resolvedSelectedValue, - currentEffort: host.state.appState.thinkingLevel, - initialTabId, - onSelect: ({ alias, effort }) => { - host.restoreEditor(); - if (options?.assignToRole !== undefined) { - void assignModelRole(host, options.assignToRole, alias); - return; - } - void performModelSwitch(host, alias, effort); - }, - onCancel: () => { - host.restoreEditor(); - }, - }); - host.mountEditorReplacement(picker); - return picker; -} - -async function assignModelRole(host: SlashCommandHost, role: string, alias: string): Promise<void> { - // Model roles store aliases only; thinking effort stays with the active model. - try { - await host.harness.setConfig({ modelRoles: { [role]: alias } }); - } catch (error) { - host.showError(`Failed to lock the ${role} model: ${formatErrorMessage(error)}`); return; } - host.showStatus(`Locked ${alias} as the ${role} model.`, 'success'); + host.mountEditorReplacement( + new TabbedModelSelectorComponent({ + models, + currentValue: host.state.appState.model, + selectedValue, + currentThinkingEffort: host.state.appState.thinkingEffort, + warning: hasConversationHistory(host) ? MODEL_SWITCH_CACHE_WARNING : undefined, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, true); + }, + onSessionOnlySelect: ({ alias, thinking }) => { + host.restoreEditor(); + void performModelSwitch(host, alias, thinking, false); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); } -async function performModelSwitch(host: SlashCommandHost, alias: string, effort: string): Promise<void> { +async function performModelSwitch( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + persist: boolean, +): Promise<void> { + let session = host.session; + if (session === undefined && host.engineV2) { + // A first prompt may still be inside lazy creation: wait it out so the + // switch lands on the new session instead of being overwritten by its + // assembly. + await host.waitForLazyCreation(); + session = host.session; + } if (host.state.appState.streamingPhase !== 'idle') { host.showError('Cannot switch models while streaming — press Esc or Ctrl-C first.'); return; } - effort = coerceEffortForModel(host.state.appState.availableModels[alias], effort); const prevModel = host.state.appState.model; - const prevEffort = host.state.appState.thinkingLevel; - const runtimeChanged = alias !== prevModel || effort !== prevEffort; + const prevEffort = host.state.appState.thinkingEffort; + const modelChanged = alias !== prevModel; + const effortChanged = effort !== prevEffort; + const runtimeChanged = modelChanged || effortChanged; + let effectiveAlias = alias; + let effectiveEffort = effort; - const session = host.session; try { if (session === undefined && runtimeChanged) { await host.authFlow.activateModelAfterLogin(alias, effort); @@ -735,6 +520,9 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, effort: if (effort !== prevEffort) { await session.setThinking(effort); } + const status = await session.getStatus(); + effectiveAlias = status.model ?? alias; + effectiveEffort = status.thinkingEffort; } } catch (error) { const msg = formatErrorMessage(error); @@ -742,75 +530,115 @@ async function performModelSwitch(host: SlashCommandHost, alias: string, effort: return; } - host.setAppState({ model: alias, thinkingLevel: effort }); + if (session === undefined) { + effectiveAlias = host.state.appState.model; + effectiveEffort = host.state.appState.thinkingEffort; + } + const effectiveModelChanged = effectiveAlias !== prevModel; + const effectiveEffortChanged = effectiveEffort !== prevEffort; + const displayName = modelDisplayName( + effectiveAlias, + host.state.appState.availableModels[effectiveAlias], + ); + host.setAppState({ model: effectiveAlias, thinkingEffort: effectiveEffort }); if (session === undefined && runtimeChanged) { - if (alias !== prevModel) { - host.track('model_switch', { model: alias }); + if (effectiveModelChanged) { + host.track('model_switch', { model: effectiveAlias }); } - if (effort !== prevEffort) { - host.track('thinking_toggle', { enabled: effort !== 'off', effort }); + if (effectiveEffortChanged) { + host.track('thinking_toggle', { + enabled: effectiveEffort !== 'off', + effort: effectiveEffort, + from: prevEffort, + }); } } let persisted = false; - try { - persisted = await persistModelSelection(host, alias, effort); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Switched to ${alias}, but failed to save default: ${msg}`); - return; + if (persist) { + try { + persisted = await persistModelSelection( + host, + effectiveAlias, + effectiveEffort, + effectiveEffortChanged, + ); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Switched to ${displayName}, but failed to save default: ${msg}`); + return; + } } - const status = runtimeChanged - ? `Switched to ${alias} with thinking ${effort}.` - : persisted - ? `Saved ${alias} with thinking ${effort} as default.` - : `Already using ${alias} with thinking ${effort}.`; + let status: string; + if (effectiveModelChanged) { + status = persist + ? `Switched to ${displayName} with thinking ${effectiveEffort}.` + : `Switched to ${displayName} with thinking ${effectiveEffort} for this session only.`; + } else if (effectiveEffortChanged) { + status = persist + ? `Thinking set to ${effectiveEffort}.` + : `Thinking set to ${effectiveEffort} for this session only.`; + } else if (persist && persisted) { + status = `Saved ${displayName} with thinking ${effectiveEffort} as default.`; + } else { + status = `Already using ${displayName} with thinking ${effectiveEffort}.`; + } host.showStatus(status, 'success'); } -async function persistModelSelection(host: SlashCommandHost, alias: string, effort: string): Promise<boolean> { - return persistDefaultModelSelection(host.harness, alias, effort); +async function persistModelSelection( + host: SlashCommandHost, + alias: string, + effort: ThinkingEffort, + effortChanged: boolean, +): Promise<boolean> { + const config = await host.harness.getConfig({ reload: true }); + const model = host.state.appState.availableModels[alias]; + const full = thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + ); + // Re-confirming the effort shown when the picker opened is not an explicit + // choice — persist the model but leave the stored effort preference alone. + const patch = effortChanged ? full : { enabled: full.enabled }; + if ( + config.defaultModel === alias && + config.thinking?.enabled === patch.enabled && + (!effortChanged || config.thinking?.effort === patch.effort) + ) { + return false; + } + await host.harness.setConfig({ + defaultModel: alias, + thinking: patch, + }); + return true; } // --------------------------------------------------------------------------- -// /effort — thinking effort for the current model +// Secondary model (`/secondary-model`) — persists `[secondary_model] default_model` // --------------------------------------------------------------------------- -export async function handleEffortCommand(host: SlashCommandHost, args: string): Promise<void> { - const modelAlias = host.state.appState.model; - if (modelAlias.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - const model = host.state.appState.availableModels[modelAlias]; - const levels = effortLevelsForModel(model); - - const requested = args.trim().toLowerCase(); - if (requested.length > 0) { - if (!levels.includes(requested)) { - host.showError( - `Unknown thinking effort "${requested}" for ${modelAlias}. Valid levels: ${levels.join(', ')}.`, - ); - return; - } - await applyEffortSelection(host, requested); - return; - } - - if (levels.length <= 1) { - host.showStatus(`${modelAlias} does not offer selectable thinking effort levels.`); - return; - } - +function showSecondaryModelPicker( + host: SlashCommandHost, + models: Record<string, ModelAlias>, + currentValue: string, + selectedValue?: string, +): void { host.mountEditorReplacement( - new EffortSelectorComponent({ - levels, - currentValue: coerceEffortForModel(model, host.state.appState.thinkingLevel), - modelName: modelDisplayName(modelAlias, model), - onSelect: (effort) => { + new TabbedModelSelectorComponent({ + models, + currentValue, + selectedValue, + currentThinkingEffort: 'off', + // Subagent pool bindings carry no explicit thinking level, so the picker + // hides the Thinking footer instead of offering a no-op choice. + thinkingControl: false, + title: ' Select a secondary model (subagents)', + onSelect: ({ alias }) => { host.restoreEditor(); - void applyEffortSelection(host, effort); + void performSecondaryModelSave(host, alias); }, onCancel: () => { host.restoreEditor(); @@ -819,27 +647,35 @@ export async function handleEffortCommand(host: SlashCommandHost, args: string): ); } -async function applyEffortSelection(host: SlashCommandHost, effort: string): Promise<void> { - const session = host.session; +/** + * Persists `[secondary_model] default_model`. When a + * `[secondary_model.models]` pool exists and does not list the alias yet, the + * alias is added with an empty description — the engine requires the default + * to be a pool key. Without a pool the default alone forms an implicit + * single-entry pool, so nothing else is written. No live-apply step: the + * engine resolves the pool per spawn, so the next subagent dispatch picks the + * new value up on its own. + */ +async function performSecondaryModelSave(host: SlashCommandHost, alias: string): Promise<void> { + const displayName = modelDisplayName(alias, host.state.appState.availableModels[alias]); try { - if (session !== undefined) { - await session.setThinking(effort); + const config = await host.harness.getConfig({ reload: true }); + const existing = config.secondaryModel?.models; + const patch: { defaultModel: string; models?: Record<string, string> } = { + defaultModel: alias, + }; + if (existing !== undefined) { + patch.models = { ...existing, [alias]: existing[alias] ?? '' }; } + await host.harness.setConfig({ secondaryModel: patch }); } catch (error) { - host.showError(`Failed to set thinking effort: ${formatErrorMessage(error)}`); + host.showError(`Failed to save secondary model: ${formatErrorMessage(error)}`); return; } - - host.setAppState({ thinkingLevel: effort }); - host.track('thinking_toggle', { enabled: effort !== 'off', effort }); - - try { - await persistModelSelection(host, host.state.appState.model, effort); - } catch (error) { - host.showError(`Thinking effort set to ${effort}, but failed to save default: ${formatErrorMessage(error)}`); - return; - } - host.showNotice(`Thinking effort: ${effort}`); + host.showStatus( + `Secondary model set to ${displayName}. Newly spawned subagents will use it by default.`, + 'success', + ); } function showThemePicker(host: SlashCommandHost): void { @@ -877,13 +713,8 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi try { await saveTuiConfig({ + ...currentTuiConfig(host), theme, - layout: host.state.layout, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, - statusLine: host.state.appState.statusLine, - copyFullResponse: host.state.copyFullResponse, }); } catch (error) { host.showStatus( @@ -903,49 +734,6 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi host.showStatus(`Theme set to "${theme}"${detail}.`); } -function showOutputStylePicker(host: SlashCommandHost, catalog: OutputStyleCatalog): void { - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Select output style', - options: catalog.styles.map((style) => ({ - value: style.name, - label: style.name, - description: `${style.description} (${style.source}${style.forced === true ? ', forced' : ''})`, - })), - currentValue: catalog.active, - searchable: true, - onSelect: (value) => { - host.restoreEditor(); - void applyOutputStyleChoice(host, catalog, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function applyOutputStyleChoice( - host: SlashCommandHost, - catalog: OutputStyleCatalog, - name: string, -): Promise<void> { - try { - await host.harness.setConfig({ outputStyle: name }); - } catch (error) { - host.showError(`Failed to save output style: ${formatErrorMessage(error)}`); - return; - } - - const forced = catalog.styles.find((style) => style.active && style.forced === true); - host.showNotice( - `Output style saved: ${name}`, - forced !== undefined && forced.name !== name - ? `${forced.name} remains active while its plugin forces that style.` - : 'Applies to new sessions.', - ); -} - export function showPermissionPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new PermissionSelectorComponent({ @@ -961,137 +749,6 @@ export function showPermissionPicker(host: SlashCommandHost): void { ); } -function showPermissionRuleInput( - host: SlashCommandHost, - decision: PermissionRuleDecision, -): void { - host.mountEditorReplacement( - new ApiKeyInputDialogComponent( - 'permission rule', - [ - 'Enter a tool name, optionally followed by a matcher.', - 'Examples: WebFetch or Bash(git *)', - ], - (result) => { - host.restoreEditor(); - if (result.kind === 'ok') void addPermissionRule(host, decision, result.value); - }, - { - title: `Add ${decision} permission rule`, - secret: false, - emptyMessage: 'Permission rule cannot be empty.', - }, - ), - ); -} - -function showPermissionRuleDeleteConfirmation( - host: SlashCommandHost, - rule: PermissionRule, -): void { - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Delete permission rule?', - notice: `${rule.decision} · ${rule.pattern}`, - currentValue: 'cancel', - options: [ - { - value: 'delete', - label: 'Delete rule', - description: 'Remove this rule from user configuration.', - tone: 'danger', - }, - { value: 'cancel', label: 'Keep rule' }, - ], - onSelect: (value) => { - host.restoreEditor(); - if (value === 'delete') void deletePermissionRule(host, rule); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function addPermissionRule( - host: SlashCommandHost, - decision: PermissionRuleDecision, - pattern: string, -): Promise<void> { - let current: readonly PermissionRule[]; - try { - current = (await host.harness.getConfig({ reload: true })).permission?.rules ?? []; - } catch (error) { - host.showError(`Failed to load permission rules: ${formatErrorMessage(error)}`); - return; - } - await savePermissionRules( - host, - [...current, { decision, scope: 'user', pattern }], - `Added ${decision} rule ${pattern}.`, - ); -} - -async function deletePermissionRule( - host: SlashCommandHost, - selected: PermissionRule, -): Promise<void> { - let current: readonly PermissionRule[]; - try { - current = (await host.harness.getConfig({ reload: true })).permission?.rules ?? []; - } catch (error) { - host.showError(`Failed to load permission rules: ${formatErrorMessage(error)}`); - return; - } - const index = current.findIndex((rule) => samePermissionRule(rule, selected)); - if (index < 0) { - host.showError('Permission rule changed before it could be deleted.'); - return; - } - await savePermissionRules( - host, - current.filter((_, ruleIndex) => ruleIndex !== index), - `Deleted ${selected.decision} rule ${selected.pattern}.`, - ); -} - -async function savePermissionRules( - host: SlashCommandHost, - rules: readonly PermissionRule[], - message: string, -): Promise<void> { - try { - await host.harness.setConfig({ permission: { rules: [...rules] } }); - } catch (error) { - host.showError(`Failed to save permission rules: ${formatErrorMessage(error)}`); - return; - } - - const session = host.session; - if (session === undefined) { - host.showNotice(message, 'Applies to new sessions.'); - return; - } - try { - await session.reloadSession(); - await host.reloadCurrentSessionView(session, message); - } catch (error) { - host.showError( - `Permission rules were saved, but the active session could not reload: ${formatErrorMessage(error)}`, - ); - } -} - -function samePermissionRule(left: PermissionRule, right: PermissionRule): boolean { - return ( - left.decision === right.decision && - left.scope === right.scope && - left.pattern === right.pattern && - left.reason === right.reason - ); -} - export function showUpdatePreferencePicker(host: SlashCommandHost): void { host.mountEditorReplacement( new UpdatePreferenceSelectorComponent({ @@ -1107,34 +764,6 @@ export function showUpdatePreferencePicker(host: SlashCommandHost): void { ); } -export function showCopyPreferencePicker(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Copy responses', - currentValue: host.state.copyFullResponse ? 'on' : 'off', - options: [ - { - value: 'off', - label: 'Choose each time', - description: 'Offer full-response and code-block choices.', - }, - { - value: 'on', - label: 'Always copy full response', - description: 'Skip the picker when code blocks are present.', - }, - ], - onSelect: (value) => { - host.restoreEditor(); - void applyCopyPreferenceChoice(host, value === 'on'); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - export async function showExperimentsPanel(host: SlashCommandHost): Promise<void> { let features: readonly ExperimentalFeatureState[]; try { @@ -1146,80 +775,6 @@ export async function showExperimentsPanel(host: SlashCommandHost): Promise<void mountExperimentsPanel(host, features); } -export async function handlePrivacySettingsCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - const value = args.trim().toLowerCase(); - if (value === 'on' || value === 'off') { - await applyPrivacyPreferenceChoice(host, value === 'on'); - return; - } - if (value.length > 0) { - host.showError('Usage: /privacy-settings [on|off]'); - return; - } - - let enabled: boolean; - try { - enabled = (await host.harness.getConfig({ reload: true })).telemetry !== false; - } catch (error) { - host.showError(`Failed to load privacy settings: ${formatErrorMessage(error)}`); - return; - } - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Telemetry', - currentValue: enabled ? 'on' : 'off', - options: [ - { - value: 'off', - label: 'Disabled', - description: 'Do not send product telemetry.', - }, - { - value: 'on', - label: 'Enabled', - description: 'Send product telemetry to help improve Pythinker Code.', - }, - ], - onSelect: (selection) => { - host.restoreEditor(); - void applyPrivacyPreferenceChoice(host, selection === 'on'); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -export async function applyPrivacyPreferenceChoice( - host: SlashCommandHost, - enabled: boolean, -): Promise<void> { - try { - await host.harness.setConfig({ telemetry: enabled }); - } catch (error) { - host.showError(`Failed to update privacy settings: ${formatErrorMessage(error)}`); - return; - } - - if (!enabled) { - disableTelemetry(); - host.showNotice( - 'Telemetry disabled', - 'Applied immediately and saved for future launches.', - ); - return; - } - host.showNotice( - 'Telemetry enabled', - 'Saved for future launches. Restart Pythinker Code to apply.', - ); -} - export async function applyExperimentalFeatureChanges( host: SlashCommandHost, changes: readonly ExperimentalFeatureDraftChange[], @@ -1232,7 +787,7 @@ export async function applyExperimentalFeatureChanges( return; } - const experimental: Partial<Record<FlagId, boolean>> = {}; + const experimental: Record<string, boolean> = {}; for (const change of changes) { experimental[change.id] = change.enabled; } @@ -1241,18 +796,15 @@ export async function applyExperimentalFeatureChanges( await host.harness.setConfig({ experimental }); const features = await host.harness.getExperimentalFeatures(); setExperimentalFeatures(features); + host.refreshSlashCommandAutocomplete(); host.restoreEditor(); if (host.session !== undefined) { await host.session.reloadSession(); - // After the reload, never before: a flag can gate which skills exist, so - // rebuilding first read the registry the reload was about to replace. - await host.refreshSkillCommands(host.session); await host.reloadCurrentSessionView( host.session, 'Experimental features updated. Session reloaded.', ); } else { - await host.refreshSkillCommands(undefined); host.showStatus('Experimental features updated.', 'success'); } host.track('experimental_features_apply', { changed: changes.length }); @@ -1280,11 +832,9 @@ function mountExperimentsPanel( type UpdatePreferenceHost = { readonly state: { - readonly copyFullResponse: boolean; - readonly layout: SlashCommandHost['state']['layout']; readonly appState: Pick< SlashCommandHost['state']['appState'], - 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'statusLine' + 'theme' | 'editorCommand' | 'notifications' | 'upgrade' >; }; setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'upgrade'>): void; @@ -1292,49 +842,6 @@ type UpdatePreferenceHost = { track: SlashCommandHost['track']; }; -type CopyPreferenceHost = { - readonly state: { - copyFullResponse: boolean; - readonly layout: SlashCommandHost['state']['layout']; - readonly appState: Pick< - SlashCommandHost['state']['appState'], - 'theme' | 'editorCommand' | 'notifications' | 'upgrade' | 'statusLine' - >; - }; - showStatus(msg: string, color?: string): void; -}; - -export async function applyCopyPreferenceChoice( - host: CopyPreferenceHost, - enabled: boolean, -): Promise<void> { - if (enabled === host.state.copyFullResponse) { - host.showStatus(`Full-response copying already ${enabled ? 'enabled' : 'disabled'}.`); - return; - } - - try { - await saveTuiConfig({ - theme: host.state.appState.theme, - layout: host.state.layout, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, - upgrade: host.state.appState.upgrade, - statusLine: host.state.appState.statusLine, - copyFullResponse: enabled, - }); - } catch (error) { - host.showStatus( - `Failed to save copy preference: ${formatErrorMessage(error)}`, - 'error', - ); - return; - } - - host.state.copyFullResponse = enabled; - host.showStatus(`Full-response copying ${enabled ? 'enabled' : 'disabled'}.`); -} - export async function applyUpdatePreferenceChoice( host: UpdatePreferenceHost, autoInstall: boolean, @@ -1347,13 +854,8 @@ export async function applyUpdatePreferenceChoice( const upgrade = { autoInstall }; try { await saveTuiConfig({ - theme: host.state.appState.theme, - layout: host.state.layout, - editorCommand: host.state.appState.editorCommand, - notifications: host.state.appState.notifications, + ...currentTuiConfig(host as unknown as SlashCommandHost), upgrade, - statusLine: host.state.appState.statusLine, - copyFullResponse: host.state.copyFullResponse, }); } catch (error) { host.showStatus( @@ -1375,7 +877,14 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod } try { - await host.requireSession().setPermission(mode); + if (host.session !== undefined) { + await host.session.setPermission(mode); + } else if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: the chosen mode is recorded in appState and passed to + // the lazy-created session. } catch (error) { const msg = formatErrorMessage(error); host.showError(`Failed to set permission mode: ${msg}`); @@ -1403,22 +912,11 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio host.restoreEditor(); switch (value) { case 'model': showModelPicker(host); return; - case 'output-style': void handleOutputStyleCommand(host, ''); return; case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; case 'editor': showEditorPicker(host); return; case 'experiments': void showExperimentsPanel(host); return; - case 'copy': showCopyPreferencePicker(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; } } - -function isFileExists(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'EEXIST' - ); -} diff --git a/apps/pythinker-code/src/tui/commands/copy.ts b/apps/pythinker-code/src/tui/commands/copy.ts index 2e902a85..bb77b251 100644 --- a/apps/pythinker-code/src/tui/commands/copy.ts +++ b/apps/pythinker-code/src/tui/commands/copy.ts @@ -1,377 +1,41 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { truncateToWidth } from '@earendil-works/pi-tui'; - import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; -import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; import type { TranscriptEntry } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; -import { applyCopyPreferenceChoice } from './config'; import type { SlashCommandHost } from './dispatch'; -const COPY_DIR = join(tmpdir(), 'pythinker'); -const MAX_LOOKBACK = 20; -const MESSAGE_ACTION_KINDS = new Set<TranscriptEntry['kind']>([ - 'user', - 'assistant', - 'tool_call', - 'status', - 'cron', - 'goal', -]); - -export interface FencedCodeBlock { - readonly code: string; - readonly language?: string; -} - -export interface MessageActionChoice extends ChoiceOption { - readonly entry: TranscriptEntry; -} - -export function buildMessageActionChoices( - entries: readonly TranscriptEntry[], -): MessageActionChoice[] { - const choices: MessageActionChoice[] = []; - for (let index = entries.length - 1; index >= 0; index--) { - const entry = entries[index]; - if (entry === undefined || !MESSAGE_ACTION_KINDS.has(entry.kind)) continue; - const text = messageActionText(entry); - if (text.length === 0) continue; - choices.push({ - value: entry.id, - label: messageActionLabel(entry), - description: text.replaceAll(/\s+/gu, ' ').trim(), - entry, - }); - } - return choices; -} - -export function showMessageActions(host: SlashCommandHost): void { - const choices = buildMessageActionChoices(host.state.transcriptEntries); - if (choices.length === 0) { - host.showError('No transcript message to select.'); - return; - } - - const findChoice = (value: string): MessageActionChoice | undefined => - choices.find((choice) => choice.value === value); - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Message actions', - options: choices, - pageSize: 8, - keybindingContext: 'MessageActions', - isUserOption: (choice) => findChoice(choice.value)?.entry.kind === 'user', - onCopy: (value) => { - const choice = findChoice(value); - if (choice !== undefined) void copyMessageAction(host, choice.entry); - }, - onPrimaryInput: (value) => { - const choice = findChoice(value); - if (choice?.entry.kind === 'tool_call') { - void copyMessageAction(host, choice.entry, messageActionText(choice.entry)); - } - }, - onSelect: (value) => { - const choice = findChoice(value); - if (choice === undefined) return; - if (choice.entry.kind === 'user') { - host.restoreInputText(choice.entry.content); - return; - } - void copyMessageAction(host, choice.entry); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -export function collectRecentAssistantTexts( - entries: readonly TranscriptEntry[], -): string[] { - const texts: string[] = []; - for (let index = entries.length - 1; index >= 0 && texts.length < MAX_LOOKBACK; index--) { - const entry = entries[index]; - if (entry?.kind === 'assistant' && entry.content.trim().length > 0) { - texts.push(entry.content); - } - } - return texts; -} - -export function extractFencedCodeBlocks(markdown: string): FencedCodeBlock[] { - const blocks: FencedCodeBlock[] = []; - const pattern = - /(?:^|\r?\n)[ \t]{0,3}(`{3,}|~{3,})[ \t]*([^\r\n]*)\r?\n([\s\S]*?)(?:\r?\n[ \t]{0,3}\1[ \t]*(?=\r?\n|$))/gu; - - for (const match of markdown.matchAll(pattern)) { - const rawLanguage = match[2]?.trim().split(/\s+/u)[0] ?? ''; - const language = rawLanguage.replaceAll(/[^a-zA-Z0-9]/gu, ''); - blocks.push({ - code: match[3] ?? '', - language: language.length > 0 && language !== 'plaintext' ? language : undefined, - }); - } - return blocks; -} - -export async function handleCopyCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - const texts = collectRecentAssistantTexts(host.state.transcriptEntries); - if (texts.length === 0) { - host.showError('No assistant message to copy.'); +/** + * Visible text of the last assistant transcript entry, newest first; empty + * string when none. Sourced from the rendered transcript rather than the + * model context so it survives compaction and session resume: after + * `/compact` the context keeps user messages plus a user-role summary only, + * while the last reply is still on screen. Only entries tagged `modelText` + * count — hook-result and goal-completion cards share kind 'assistant' but + * are not replies. + */ +export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue; + if (entry.content.trim().length > 0) return entry.content; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise<void> { + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); return; } - const requested = parseMessageNumber(host, args, texts.length); - if (requested === undefined) return; - - const text = texts[requested - 1]!; - const blocks = extractFencedCodeBlocks(text); - if (blocks.length === 0 || host.state.copyFullResponse) { - await copyAndWrite(host, text, 'response.md', requested, blocks.length); - return; - } - - const options: ChoiceOption[] = [ - { - value: 'full', - label: 'Full response', - description: describeText(text), - }, - ...blocks.map((block, index) => ({ - value: `block:${String(index)}`, - label: truncateToWidth(block.code.split(/\r?\n/u)[0] ?? '', 60, '…'), - description: [block.language, describeLineCount(block.code)].filter(Boolean).join(' · '), - })), - { - value: 'always', - label: 'Always copy full responses', - description: 'Save this preference and skip this picker next time.', - }, - ]; - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Copy response', - options, - secondaryAction: { - key: 'w', - label: 'write to file', - onSelect: (value) => { - host.restoreEditor(); - void writeSelection(host, value, text, blocks); - }, - }, - onSelect: (value) => { - host.restoreEditor(); - void copySelection(host, value, text, blocks, requested); - }, - onCancel: () => { - host.restoreEditor(); - host.showStatus('Copy cancelled.'); - }, - }), - ); -} - -function parseMessageNumber( - host: SlashCommandHost, - args: string, - available: number, -): number | undefined { - const value = args.trim(); - if (value.length === 0) return 1; - - const requested = Number(value); - if (!Number.isInteger(requested) || requested < 1) { - host.showError(`Usage: /copy [N] where N is 1 (latest), 2, 3, … Got: ${value}`); - return undefined; - } - if (requested > available) { - host.showError( - `Only ${String(available)} assistant ${available === 1 ? 'message' : 'messages'} available to copy.`, - ); - return undefined; - } - return requested; -} - -async function copySelection( - host: SlashCommandHost, - value: string, - fullText: string, - blocks: readonly FencedCodeBlock[], - requested: number, -): Promise<void> { - if (value === 'always') { - await applyCopyPreferenceChoice(host, true); - } - const selection = selectionContent(value, fullText, blocks); - await copyAndWrite(host, selection.text, selection.filename, requested, blocks.length); -} - -async function writeSelection( - host: SlashCommandHost, - value: string, - fullText: string, - blocks: readonly FencedCodeBlock[], -): Promise<void> { - const selection = selectionContent(value, fullText, blocks); - try { - const path = await writeCopyFile(selection.text, selection.filename); - host.showStatus(`Written to ${path}`, 'success'); - } catch (error) { - host.showError(`Failed to write response: ${formatErrorMessage(error)}`); - } -} - -function selectionContent( - value: string, - fullText: string, - blocks: readonly FencedCodeBlock[], -): { readonly text: string; readonly filename: string } { - if (!value.startsWith('block:')) { - return { text: fullText, filename: 'response.md' }; - } - const block = blocks[Number(value.slice('block:'.length))]!; - return { - text: block.code, - filename: `copy.${block.language ?? 'txt'}`, - }; -} - -async function copyAndWrite( - host: SlashCommandHost, - text: string, - filename: string, - requested: number, - blockCount: number, -): Promise<void> { - let clipboardError: unknown; - try { - await copyTextToClipboard(text); - } catch (error) { - clipboardError = error; - } - - let path: string | undefined; try { - path = await writeCopyFile(text, filename); - } catch (error) { - if (clipboardError !== undefined) { - host.showError( - `Copy failed: ${formatErrorMessage(clipboardError)}; fallback file failed: ${formatErrorMessage(error)}`, - ); - return; - } - } - - host.track('copy_response', { - message_age: requested - 1, - block_count: blockCount, - clipboard: clipboardError === undefined, - }); - const summary = `${String(text.length)} characters, ${describeLineCount(text)}`; - if (clipboardError === undefined) { + const method = await copyTextToClipboard(text); host.showStatus( - `Copied to clipboard (${summary})${path === undefined ? '' : `\nAlso written to ${path}`}`, - 'success', + method === 'native' + ? `Copied to clipboard (${String(text.length)} characters).` + : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`, ); - } else { - host.showStatus( - `Clipboard unavailable; written to ${path}\n${formatErrorMessage(clipboardError)}`, - 'warning', - ); - } -} - -async function writeCopyFile(text: string, filename: string): Promise<string> { - const path = join(COPY_DIR, filename); - await mkdir(COPY_DIR, { recursive: true }); - await writeFile(path, text, 'utf8'); - return path; -} - -function describeText(text: string): string { - return `${String(text.length)} characters · ${describeLineCount(text)}`; -} - -function describeLineCount(text: string): string { - const lines = text.split(/\r?\n/u).length; - return `${String(lines)} ${lines === 1 ? 'line' : 'lines'}`; -} - -async function copyMessageAction( - host: SlashCommandHost, - entry: TranscriptEntry, - text = entry.content.trim(), -): Promise<void> { - host.restoreEditor(); - try { - await copyTextToClipboard(text); - host.showStatus(`Copied ${messageActionLabel(entry).toLowerCase()} message.`, 'success'); } catch (error) { - host.showError(`Copy failed: ${formatErrorMessage(error)}`); - } -} - -function messageActionLabel(entry: TranscriptEntry): string { - if (entry.kind === 'tool_call') return entry.toolCallData?.name ?? 'Tool'; - return entry.kind.charAt(0).toUpperCase() + entry.kind.slice(1); -} - -function messageActionText(entry: TranscriptEntry): string { - if (entry.kind !== 'tool_call') return entry.content.trim(); - const data = entry.toolCallData; - if (data === undefined) return entry.content.trim(); - const display = data.display; - if (display !== undefined) { - switch (display.kind) { - case 'command': - return display.command; - case 'file_io': - case 'diff': - return display.path; - case 'search': - return display.query; - case 'url_fetch': - return display.url; - case 'agent_call': - return display.prompt; - case 'skill_call': - return display.args ?? display.skill_name; - case 'background_task': - case 'task_stop': - return display.task_id; - case 'plan_review': - return display.plan; - case 'todo_list': - break; - case 'generic': - return display.summary; - } - } - for (const key of [ - 'command', - 'file_path', - 'notebook_path', - 'path', - 'pattern', - 'url', - 'query', - 'prompt', - ]) { - const value = data.args[key]; - if (typeof value === 'string' && value.length > 0) return value; + host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); } - return entry.content.trim(); } diff --git a/apps/pythinker-code/src/tui/commands/debug.ts b/apps/pythinker-code/src/tui/commands/debug.ts deleted file mode 100644 index 04173a6a..00000000 --- a/apps/pythinker-code/src/tui/commands/debug.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { open, stat } from 'node:fs/promises'; -import { join } from 'node:path'; - -import { - enableDiagnosticDebugLogging, - flushDiagnosticLogs, - resolveGlobalLogPath, -} from '@pymodel/pythinker-code-sdk'; - -import { LLM_NOT_SET_MESSAGE } from '../constant/pythinker-tui'; -import type { SlashCommandHost } from './dispatch'; - -const DEBUG_LINES = 20; -const TAIL_BYTES = 64 * 1024; - -export async function handleDebugCommand( - host: SlashCommandHost, - issueDescription: string, -): Promise<void> { - const session = host.session; - if (session === undefined || host.state.appState.model.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - - const previousLevel = await enableDiagnosticDebugLogging(); - await flushDiagnosticLogs(); - const sessionDir = session.summary?.sessionDir; - const logPath = - previousLevel === 'off' || sessionDir === undefined - ? resolveGlobalLogPath(host.harness.homeDir) - : join(sessionDir, 'logs', 'pythinker-code.log'); - const logInfo = await readLogTail(logPath); - const issue = issueDescription.trim() || 'No specific issue was provided.'; - const loggingStatus = - previousLevel === 'debug' - ? '' - : previousLevel === undefined - ? 'Diagnostic logging is not configured in this runtime.' - : `Debug logging is now enabled. Earlier activity was recorded at the ${previousLevel} level.`; - - host.sendNormalUserInput(`# Debug the current Pythinker session - -Review the issue and the bounded diagnostic-log tail below. Search the full log for related ERROR and WARN entries, stack traces, and repeated failure patterns when needed. Explain the likely cause in plain language and suggest concrete fixes or next steps. - -${loggingStatus} - -Log path: \`${logPath}\` -${logInfo} - -Issue: ${issue}`); -} - -async function readLogTail(logPath: string): Promise<string> { - try { - const stats = await stat(logPath); - const readSize = Math.min(stats.size, TAIL_BYTES); - const file = await open(logPath, 'r'); - try { - const buffer = Buffer.alloc(readSize); - const { bytesRead } = await file.read(buffer, 0, readSize, stats.size - readSize); - const tail = buffer - .toString('utf8', 0, bytesRead) - .split('\n') - .slice(-DEBUG_LINES) - .join('\n'); - return `\nLog size: ${String(stats.size)} bytes\n\nLast ${String(DEBUG_LINES)} lines:\n\`\`\`\n${tail}\n\`\`\``; - } finally { - await file.close(); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return '\nNo diagnostic log exists yet. Reproduce the issue, then run `/debug` again.'; - } - const message = error instanceof Error ? error.message : String(error); - return `\nFailed to read the diagnostic log: ${message}`; - } -} diff --git a/apps/pythinker-code/src/tui/commands/diff.ts b/apps/pythinker-code/src/tui/commands/diff.ts deleted file mode 100644 index 1986f630..00000000 --- a/apps/pythinker-code/src/tui/commands/diff.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { - WorkingTreeChange, - WorkingTreeFileDiff, -} from '@pymodel/pythinker-code-sdk'; - -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; -import { UsagePanelComponent } from '../components/messages/usage-panel'; -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; -import { currentTheme } from '../theme'; -import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './dispatch'; - -export async function handleDiffCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - const path = args.trim(); - if (path.length > 0) { - await showWorkingTreeFileDiff(host, path); - return; - } - - try { - const changes = await session.listWorkingTreeChanges(); - if (changes.files.length === 0) { - host.showNotice('Working tree is clean'); - return; - } - const stats = `+${String(changes.additions)} -${String(changes.deletions)}`; - const notice = [ - changes.branch.length > 0 ? changes.branch : 'detached HEAD', - `${String(changes.files.length)} file${changes.files.length === 1 ? '' : 's'}`, - stats, - changes.truncated ? 'first 500 shown' : '', - ].filter(Boolean).join(' · '); - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Uncommitted changes', - notice, - searchable: true, - options: changes.files.map(changeOption), - onSelect: (value) => { - host.restoreEditor(); - void showWorkingTreeFileDiff(host, value); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); - } catch (error) { - host.showError(`Failed to load working-tree changes: ${formatErrorMessage(error)}`); - } -} - -export function buildWorkingTreeDiffLines( - file: WorkingTreeFileDiff, -): string[] { - const lines = [currentTheme.boldFg('primary', file.path)]; - if (file.diff.length === 0) { - lines.push(currentTheme.fg('textDim', 'No diff content')); - return lines; - } - for (const line of file.diff.split('\n')) { - if (line.startsWith('+') && !line.startsWith('+++')) { - lines.push(currentTheme.fg('diffAdded', line)); - } else if (line.startsWith('-') && !line.startsWith('---')) { - lines.push(currentTheme.fg('diffRemoved', line)); - } else if (line.startsWith('@@')) { - lines.push(currentTheme.fg('diffMeta', line)); - } else { - lines.push(currentTheme.fg('textDim', line)); - } - } - if (file.truncated) { - lines.push('', currentTheme.fg('warning', 'Diff truncated at 1 MiB.')); - } - return lines; -} - -function changeOption(change: WorkingTreeChange): { - readonly value: string; - readonly label: string; - readonly description: string; -} { - const counts = - change.binary - ? 'binary' - : change.additions > 0 || change.deletions > 0 - ? `+${String(change.additions)} -${String(change.deletions)}` - : ''; - return { - value: change.path, - label: change.path, - description: [change.status, counts].filter(Boolean).join(' · '), - }; -} - -async function showWorkingTreeFileDiff( - host: SlashCommandHost, - path: string, -): Promise<void> { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - try { - const file = await session.getWorkingTreeDiff(path); - const panel = new UsagePanelComponent( - () => buildWorkingTreeDiffLines(file), - 'primary', - ' Diff ', - ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); - host.state.ui.requestRender(); - } catch (error) { - host.showError(`Failed to load diff for ${path}: ${formatErrorMessage(error)}`); - } -} diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index ec03a865..7b4aa4fe 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -1,73 +1,66 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@pymodel/pi-tui'; +import type { DeviceAuthorization } from '@pymodel/pythinker-code-oauth'; import type { PythinkerHarness, Session } from '@pymodel/pythinker-code-sdk'; import type { ColorToken, ThemeName } from '#/tui/theme'; -import { performHeapDump } from '#/utils/heap-dump'; import { LLM_NOT_SET_MESSAGE } from '../constant/pythinker-tui'; import type { AuthFlowController } from '../controllers/auth-flow'; import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; -import { handleColorsCommand } from '../easter-eggs/rainbow-colors'; +import { tryHandleDanceCommand } from '../easter-eggs/dance'; import type { ResolvedTheme } from '../theme/colors'; import type { TUIState } from '../tui-state'; import type { AppState, + InlineSkillActivation, LoginProgressSpinnerHandle, QueuedMessage, TranscriptEntry, } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; -import { handleAdvisorCommand } from './advisor'; -import { handleAddDirCommand } from './add-dir'; -import { handleAgentsCommand } from './agents'; +import { + extractInlineSkillActivations, + findInlineSkillTokens, +} from '../utils/inline-skill-tokens'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; +import { handleCopyCommand } from './copy'; import { handleAutoCommand, handleCompactCommand, handleEditorCommand, handleEffortCommand, - handleKeybindingsCommand, handleModelCommand, - handleOutputStyleCommand, - handlePermissionsCommand, handlePlanCommand, - handlePrivacySettingsCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, + showModelPicker, showPermissionPicker, showSettingsSelector, } from './config'; -import { handleCopyCommand } from './copy'; -import { handleDebugCommand } from './debug'; -import { handleDiffCommand } from './diff'; -import { handleFastCommand } from './fast'; import { handleGoalCommand } from './goal'; -import { handleMemoryCommand } from './memory'; -import { - handleDoctorCommand, - handleFeedbackCommand, - handleUpdateCommand, - handleHooksCommand, - showMcpServers, - showContextReport, - showContextFiles, - showCost, - showReleaseNotes, - showStatusReport, - showTerminalSetup, - showUsage, -} from './info'; +import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; +import { handleAddDirCommand } from './add-dir'; import { parseSlashInput } from './parse'; import { handlePluginsCommand } from './plugins'; import { handleProviderCommand } from './provider'; -import type { BuiltinSlashCommandName } from './registry'; +import { + findBuiltInSlashCommand, + resolveSlashCommandAvailability, + type BuiltinSlashCommandName, +} from './registry'; import { handleReloadCommand, handleReloadTuiCommand } from './reload'; -import { resolveSlashCommandInput, slashBusyMessage } from './resolve'; -import { handleSkillsCommand } from './skills'; +import type { SkillListSession } from './skills'; +import { + canRestoreSubmittedInput, + resolveSlashCommandInput, + slashBusyMessage, + slashCommandBusyReason, +} from './resolve'; import { handleExportDebugZipCommand, handleExportMdCommand, @@ -75,31 +68,26 @@ import { handleInitCommand, handleTitleCommand, } from './session'; -import { handleDynamicWorkflowCommand } from './dynamic-workflow'; -import { handleTagCommand } from './tag'; +import { handleDynamicWorkflowCommand } from './dynamic_workflow'; import { handleUndoCommand } from './undo'; -import { handleVimCommand } from './vim'; import { handleWebCommand } from './web'; // --------------------------------------------------------------------------- // Re-exports — keep existing consumers working // --------------------------------------------------------------------------- -export { handleAgentsCommand } from './agents'; -export { handleAddDirCommand } from './add-dir'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; +export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, handleCompactCommand, handleEditorCommand, handleEffortCommand, - handleKeybindingsCommand, handleModelCommand, - handleOutputStyleCommand, - handlePermissionsCommand, handlePlanCommand, - handlePrivacySettingsCommand, + handleSecondaryModelCommand, handleThemeCommand, handleYoloCommand, showModelPicker, @@ -107,30 +95,11 @@ export { showPermissionPicker, showSettingsSelector, } from './config'; -export { handleCopyCommand, showMessageActions } from './copy'; -export { handleDebugCommand } from './debug'; -export { handleDiffCommand } from './diff'; -export { handleDynamicWorkflowCommand } from './dynamic-workflow'; -export { handleFastCommand } from './fast'; -export { - handleDoctorCommand, - handleFeedbackCommand, - handleUpdateCommand, - handleHooksCommand, - showMcpServers, - showContextReport, - showContextFiles, - showCost, - showReleaseNotes, - showStatusReport, - showTerminalSetup, - showUsage, -} from './info'; +export { handleDynamicWorkflowCommand } from './dynamic_workflow'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; -export { handleSkillsCommand } from './skills'; export { handleGoalCommand } from './goal'; -export { handleMemoryCommand } from './memory'; export { handleExportDebugZipCommand, handleExportMdCommand, @@ -149,6 +118,8 @@ export interface SlashCommandHost { state: TUIState; session: Session | undefined; readonly harness: PythinkerHarness; + /** agent-core-v2 engine; enables lazy session creation. */ + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; deferUserMessages: boolean; @@ -162,23 +133,49 @@ export interface SlashCommandHost { mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; - refreshSkillCommands(session?: Session): Promise<void>; - reloadKeybindings?(): readonly string[]; - setExternalEditorRunning?(running: boolean): void; + refreshSlashCommandAutocomplete(): void; + /** + * Rebuild the plugin slash-command list. With no session (v2 session-less + * startup) this reads the app-global plugin commands instead, so `/plugins` + * mutations apply before the first session exists. + */ + refreshPluginCommands(session?: Session): Promise<void>; + /** + * Rebuild the skill slash-command list. With no session (v2 session-less + * startup) this reads the workspace skills instead. + */ + refreshSkillCommands(session?: SkillListSession): Promise<void>; + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap). No-op semantics on a live session path: only /reload calls + * it while still session-less. + */ + hydrateLazyConfigDefaults(): Promise<void>; // Session requireSession(): Session; + /** + * Lazy-create the session on first use (v2 engine). Returns the existing + * session, or undefined (with the error already surfaced) when creation + * fails. + */ + ensureSession(): Promise<Session | undefined>; + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + waitForLazyCreation(): Promise<void>; switchToSession(session: Session, message: string): Promise<void>; reloadCurrentSessionView(session: Session, message: string): Promise<void>; beginSessionRequest(): void; failSessionRequest(message: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; requestQueuedGoalPromotion?(): void; - /** Retires Dynamic Workflow mission controls, e.g. after an undo. */ - clearDynamicWorkflowMissionControls(): void; + /** Reset the client-side cache-break baseline after the context was cut + * (/undo): the next step's cache-read drop is expected, not a break. */ + noteContextCut?(): void; // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle; showProgressSpinner(label: string): LoginProgressSpinnerHandle; // Theme @@ -188,12 +185,32 @@ export interface SlashCommandHost { // Dispatch stop(exitCode?: number): Promise<void>; setExitOpenUrl(url: string): void; + /** + * Register a task that takes over the process after the TUI has shut down + * (instead of exiting): the runner awaits it and only exits when it returns. + * Used by `/web` to keep a freshly started server attached to this terminal + * until Ctrl+C. + */ + setExitForegroundTask(task: (exitCode: number) => Promise<void>): void; showHelpPanel(): void; createNewSession(): Promise<void>; showSessionPicker(): Promise<void>; sendNormalUserInput(text: string): void; + /** + * Submit a prompt that explicitly activates one or more skills inline + * (v2 engine only): all activations ride the same submission as the prompt + * and launch as a single turn. + */ + sendInlineSkillUserInput(text: string, activations: readonly InlineSkillActivation[]): Promise<void>; sendSkillActivation(session: Session, skillName: string, skillArgs: string): void; + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void; readonly skillCommandMap: Map<string, string>; + readonly pluginCommandMap: Map<string, string>; // Controller refs readonly streamingUI: StreamingUIController; @@ -208,17 +225,85 @@ export interface SlashCommandHost { export function dispatchInput(host: SlashCommandHost, text: string): void { if (parseSlashInput(text) !== null) { + // A leading skill command combined with further inline skill tokens + // (`/skill:a args /skill:b`) is one grouped submission on the v2 engine. + if (host.engineV2 && dispatchInlineSkillCombo(host, text)) { + return; + } void executeSlashCommand(host, text); return; } + // Inline skill tokens anywhere in a plain prompt (v2 engine only); on the + // legacy engine they keep their plain-text meaning. + if (host.engineV2) { + const activations = extractInlineSkillActivations(text, host.skillCommandMap); + if (activations.length > 0) { + void host.sendInlineSkillUserInput(text, activations); + return; + } + } host.sendNormalUserInput(text); } +/** + * Handle a leading-slash input that may be a bundled submission. Returns true + * when the input was claimed, false when it should fall through to the + * regular single-skill slash path. + * + * Bundle rule: two or more known skill tokens with the first one leading the + * input make the whole input one bundled prompt in which every token + * activates with NO args — the mention is the whole interface, and args stay + * a standalone-activation concept (`/skill:a some args` with no other tokens + * keeps its single-skill path). Tokenization is whitespace-generic, so + * space- and newline-separated bundles behave identically. A recognized + * builtin or plugin command always keeps its own path, no matter how many + * skill tokens its arguments mention. + */ +function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean { + // The intent is parsed without the busy flags on purpose: submissions + // through sendInlineSkillUserInput queue while busy — only genuine + // single-skill commands reject. + const intent = resolveSlashCommandInput({ + input: text, + skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, + isStreaming: false, + isCompacting: false, + }); + if (intent.kind !== 'skill' && intent.kind !== 'message') return false; + + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => + host.skillCommandMap.has(commandName) || host.skillCommandMap.has(`skill:${commandName}`), + includeLeading: true, + }); + // The 'message' kind joins the bundle rule because parseSlashInput only + // splits on a literal space: a newline after a leading skill resolves to + // 'message' instead of 'skill', and must not silently drop the leading + // activation. + if (tokens.length >= 2 && tokens[0]!.start === 0) { + const activations = extractInlineSkillActivations(text, host.skillCommandMap, { + includeLeading: true, + }); + void host.sendInlineSkillUserInput(text, activations); + return true; + } + + // An unrecognized leading slash token makes the whole input a plain + // message; scan it for inline skills like any other plain prompt. + if (intent.kind !== 'message') return false; + const activations = extractInlineSkillActivations(text, host.skillCommandMap); + if (activations.length === 0) return false; + void host.sendInlineSkillUserInput(text, activations); + return true; +} + async function executeSlashCommand(host: SlashCommandHost, input: string): Promise<void> { const parsedCommand = parseSlashInput(input); const intent = resolveSlashCommandInput({ input, skillCommandMap: host.skillCommandMap, + pluginCommandMap: host.pluginCommandMap, isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, }); @@ -229,6 +314,9 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi case 'blocked': host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); + // The editor buffer was already cleared on submit; give the rejected + // command line back so hand-typed input is not lost. + host.restoreInputText(input); return; case 'invalid': host.track('input_command_invalid', { @@ -238,11 +326,26 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.showError(`Invalid slash command: /${intent.commandName}`); return; case 'skill': { - const session = host.session; - if (host.state.appState.model.trim().length === 0 || session === undefined) { + if (host.state.appState.model.trim().length === 0) { host.showError(LLM_NOT_SET_MESSAGE); return; } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // A first prompt may have started a turn while the session was being + // created; skill commands are always busy-gated, so re-check the gate + // resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } host.track('input_command', { command: intent.commandName, skill_name: intent.skillName, @@ -250,7 +353,37 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.sendSkillActivation(session, intent.skillName, intent.args); return; } + case 'plugin-command': { + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + let session = host.session; + if (session === undefined) { + session = await ensureSessionForCommand(host); + if (session === undefined) return; + // Same busy re-check as the skill path: plugin commands are always + // busy-gated too. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(intent.commandName, busyReason)); + return; + } + } + host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` }); + host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args); + return; + } case 'message': + // Unknown slash command: let /dance claim it before it falls through to + // the model as a normal message. This runs *after* builtin and skill + // resolution, so a real command or a same-named skill always wins. + if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { + return; + } host.sendNormalUserInput(intent.input); return; case 'builtin': @@ -259,7 +392,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('clear'); } try { - await handleBuiltInSlashCommand(host, intent.name, intent.args); + await handleBuiltInSlashCommand(host, intent.name, intent.args, input); } catch (error) { host.showError(formatErrorMessage(error)); } @@ -267,15 +400,70 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi } } +/** + * Lazy-create the session for a slash command that needs one (v2 engine). + * v1 keeps the historical "no active session" error; on v2 a missing session + * means the TUI started session-less, so commands create it on first use. + * Returns undefined (error already shown) when creation fails. + */ +async function ensureSessionForCommand(host: SlashCommandHost): Promise<Session | undefined> { + if (!host.engineV2) { + host.showError(LLM_NOT_SET_MESSAGE); + return undefined; + } + return host.ensureSession(); +} + +/** Builtin commands that need an active session; lazy-created on the v2 engine. */ +const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set([ + 'btw', + 'compact', + 'export-debug-zip', + 'export-md', + 'fork', + 'goal', + 'init', + 'plan', + 'dynamic_workflow', + 'undo', + 'web', +]); + async function handleBuiltInSlashCommand( host: SlashCommandHost, name: BuiltinSlashCommandName, args: string, + input: string, ): Promise<void> { - switch (name) { - case 'colors': - handleColorsCommand(host, args); + if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) { + const session = await ensureSessionForCommand(host); + if (session === undefined) { + // Creation failed after submit cleared the buffer; give the input + // back unless the user moved on — a newer draft or an opened panel. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); return; + } + // A first prompt may have started a turn while the session was being + // created; re-check the availability gate that was resolved before the + // await (idle-only commands are blocked while a turn is active). + const command = findBuiltInSlashCommand(name); + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if ( + busyReason !== undefined && + command !== undefined && + resolveSlashCommandAvailability(command, args) === 'idle-only' + ) { + host.showError(slashBusyMessage(name, busyReason)); + // Same as the dispatch blocked branch: give the cleared input back, + // guarded the same way — session creation awaited above. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); + return; + } + } + switch (name) { case 'exit': void host.stop(); return; @@ -285,10 +473,24 @@ async function handleBuiltInSlashCommand( case 'version': host.showStatus(`Pythinker Code v${host.state.appState.version}`); return; - case 'new': + case 'new': { + // A first-use lazy creation may still be in flight: wait it out so /new + // never races a second createSession against the pending prompt. + await host.waitForLazyCreation(); + // The waited-out prompt may have started a turn meanwhile; /new is + // idle-only, so re-run the busy gate resolved before the await. + const busyReason = slashCommandBusyReason({ + isStreaming: host.state.appState.streamingPhase !== 'idle', + isCompacting: host.state.appState.isCompacting, + }); + if (busyReason !== undefined) { + host.showError(slashBusyMessage(name, busyReason)); + return; + } await host.createNewSession(); host.state.ui.requestRender(); return; + } case 'sessions': void host.showSessionPicker(); return; @@ -298,45 +500,18 @@ async function handleBuiltInSlashCommand( case 'mcp': void showMcpServers(host); return; - case 'files': - await showContextFiles(host, args); - return; - case 'hooks': - await handleHooksCommand(host, args); - return; - case 'doctor': - await handleDoctorCommand(host, args); - return; - case 'update': - await handleUpdateCommand(host, args); - return; - case 'debug': - await handleDebugCommand(host, args); - return; - case 'heapdump': { - host.showStatus('Creating heap dump…'); - const result = await performHeapDump( - host.state.appState.sessionId ?? 'pythinker-code', - host.state.appState.version, - ); - if (!result.success) { - host.showError(`Failed to create heap dump: ${result.error}`); - return; - } - host.showNotice('Heap dump created', `${result.heapPath}\n${result.diagPath}`); - return; - } case 'plugins': - void handlePluginsCommand(host, args); - return; - case 'reload-plugins': - await handlePluginsCommand(host, 'reload'); - return; - case 'skills': - await handleSkillsCommand(host, args); + // `handlePluginsCommand` throws when no session is active (its own + // requireSession), so catch here instead of letting the `void` call + // reject unhandled. + try { + await handlePluginsCommand(host, args); + } catch (error) { + host.showError(formatErrorMessage(error)); + } return; - case 'agents': - await handleAgentsCommand(host, args); + case 'add-dir': + await handleAddDirCommand(host, args); return; case 'experiments': await showExperimentsPanel(host); @@ -347,87 +522,36 @@ async function handleBuiltInSlashCommand( case 'reload-tui': await handleReloadTuiCommand(host); return; - case 'release-notes': - showReleaseNotes(host); - return; - case 'review': - host.sendNormalUserInput(reviewPrompt(args)); - return; - case 'security-review': - host.sendNormalUserInput(securityReviewPrompt()); - return; - case 'pr-comments': - host.sendNormalUserInput(pullRequestCommentsPrompt(args)); - return; - case 'commit': - host.sendNormalUserInput(commitPrompt(args)); - return; - case 'commit-push-pr': - host.sendNormalUserInput(commitPushPullRequestPrompt(args)); - return; case 'editor': await handleEditorCommand(host, args); return; - case 'keybindings': - await handleKeybindingsCommand(host, args); - return; - case 'terminal-setup': - showTerminalSetup(host); - return; case 'theme': await handleThemeCommand(host, args); return; - case 'output-style': - await handleOutputStyleCommand(host, args); - return; case 'model': await handleModelCommand(host, args); return; + case 'secondary-model': + await handleSecondaryModelCommand(host, args); + return; case 'effort': await handleEffortCommand(host, args); return; - case 'fast': - await handleFastCommand(host, args); - return; - case 'advisor': - await handleAdvisorCommand(host, args); - return; case 'provider': await handleProviderCommand(host); return; case 'permission': showPermissionPicker(host); return; - case 'permissions': - await handlePermissionsCommand(host, args); - return; case 'settings': showSettingsSelector(host); return; - case 'privacy-settings': - await handlePrivacySettingsCommand(host, args); - return; case 'usage': void showUsage(host); return; - case 'cost': - showCost(host); - return; - case 'context': - void showContextReport(host, args); - return; - case 'memory': - await handleMemoryCommand(host, args); - return; - case 'diff': - await handleDiffCommand(host, args); - return; case 'status': void showStatusReport(host); return; - case 'tag': - await handleTagCommand(host, args); - return; case 'feedback': await handleFeedbackCommand(host); return; @@ -437,9 +561,6 @@ async function handleBuiltInSlashCommand( case 'title': await handleTitleCommand(host, args); return; - case 'vim': - await handleVimCommand(host); - return; case 'yolo': await handleYoloCommand(host, args); return; @@ -449,27 +570,18 @@ async function handleBuiltInSlashCommand( case 'plan': await handlePlanCommand(host, args); return; - case 'workflow': + case 'dynamic_workflow': await handleDynamicWorkflowCommand(host, args); return; case 'compact': await handleCompactCommand(host, args); return; - case 'copy': - await handleCopyCommand(host, args); - return; - case 'add-dir': - await handleAddDirCommand(host, args); - return; case 'goal': await handleGoalCommand(host, args); return; case 'init': await handleInitCommand(host); return; - case 'init-verifiers': - host.sendNormalUserInput(initVerifiersPrompt()); - return; case 'fork': await handleForkCommand(host, args); return; @@ -479,6 +591,9 @@ async function handleBuiltInSlashCommand( case 'export-debug-zip': await handleExportDebugZipCommand(host); return; + case 'copy': + await handleCopyCommand(host); + return; case 'login': await handleLoginCommand(host); return; @@ -496,35 +611,3 @@ async function handleBuiltInSlashCommand( return; } } - -function reviewPrompt(args: string): string { - const selector = args.trim(); - const target = selector.length === 0 - ? 'a pull request. First run `gh pr list` and ask me which open pull request to review' - : `pull request ${selector}`; - return `Review ${target}. Use \`gh pr view\` for its metadata and \`gh pr diff\` for the complete diff. Report correctness defects, regressions, security risks, performance problems, convention violations, and missing tests with concrete file and line references. Keep the review concise and prioritize findings by severity.`; -} - -function securityReviewPrompt(): string { - return `Perform a focused security review of the pending branch changes. Do not modify the project. Inspect repository security patterns plus \`git status\`, \`git diff --name-only origin/HEAD...\`, \`git log --no-decorate origin/HEAD...\`, and the complete \`git diff origin/HEAD...\`. Report only newly introduced, concretely exploitable high or medium vulnerabilities with at least 80% confidence; exclude denial of service, resource exhaustion, rate limiting, dependency age, documentation, test-only code, and hardening suggestions without an attack path. For each finding, give severity, confidence, category, file and line, exploit scenario, and recommended fix. If no finding survives this filter, say so.`; -} - -function pullRequestCommentsPrompt(args: string): string { - const selector = args.trim(); - const target = selector.length === 0 ? 'the current pull request' : `pull request ${selector}`; - return `Fetch and display comments for ${target}. Use \`gh pr view --json number,headRepository\` to resolve the repository and number, then query \`gh api /repos/{owner}/{repo}/issues/{number}/comments\` and \`gh api /repos/{owner}/{repo}/pulls/{number}/comments\`. Format PR-level and threaded review comments under \`## Comments\`, including author, file, line, diff hunk, and quoted body. Return only the formatted comments; if none exist, return exactly \`No comments found.\``; -} - -function initVerifiersPrompt(): string { - return `Create the smallest useful set of project skills for functional verification. First inspect this project's runnable product surfaces and existing skill conventions. Write each skill to \`.pythinker-code/skills/<verifier-name>/SKILL.md\` with a \`verifier-\` prefixed name, clear applicability, setup, exact read-only probes, environment-variable authentication, pass/fail reporting, and cleanup. Cover real user behavior such as a web UI, CLI, or API; do not duplicate unit tests, typechecks, or linters. Do not install dependencies, modify application code, embed secrets, or run destructive commands. If functional verification needs unavailable tooling or credentials, document that requirement instead of provisioning it.`; -} - -function commitPrompt(args: string): string { - const additional = args.trim(); - return `Create one git commit for the current relevant worktree changes. Inspect git status, staged and unstaged diffs, untracked files, and recent commit style before staging anything. Stage only files that belong together, exclude secrets, run proportionate verification, and write a concise Conventional Commit message focused on why the change exists. Never amend, update git config, skip hooks, use interactive git commands, or add co-author attribution. If there is nothing to commit, say so without creating an empty commit.${additional.length === 0 ? '' : ` Additional instructions: ${additional}`}`; -} - -function commitPushPullRequestPrompt(args: string): string { - const additional = args.trim(); - return `Publish the current relevant work as a pull request. Inspect the current branch, default branch, complete branch diff, commits, and repository instructions. Create a focused branch when currently on the default branch, make the required commit without amending or adding co-author attribution, push the branch, then create or update the pull request. Use a Conventional Commit title under 70 characters and fully complete the repository pull request template with the problem, implementation, edge cases, and verified test results. Never force-push, update git config, skip hooks, include secrets, or use interactive git commands. Return the pull request URL when finished.${additional.length === 0 ? '' : ` Additional instructions: ${additional}`}`; -} diff --git a/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts b/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts deleted file mode 100644 index 35dce5d7..00000000 --- a/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { - savedWorkflowSkillName, - writeSavedWorkflowSkill, - type PermissionMode, - type SavedWorkflowScope, -} from '@pymodel/pythinker-code-sdk'; - -import { getDataDir } from '#/utils/paths'; -import { - DynamicWorkflowStartPermissionPromptComponent, - type DynamicWorkflowStartPermissionChoice, -} from '../components/dialogs/dynamic-workflow-start-permission-prompt'; -import { - DynamicWorkflowModeMarkerComponent, - type DynamicWorkflowModeMarkerState, -} from '../components/messages/dynamic-workflow-markers'; -import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; -import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './dispatch'; -import { currentWorkflowSizeGuideline, isDynamicWorkflowDisabled } from './workflow-availability'; - -export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args: string): Promise<void> { - if (isDynamicWorkflowDisabled()) { - host.showError('Dynamic Workflow is disabled by configuration.'); - return; - } - if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - const prompt = args.trim(); - if (handleModelSubcommand(host, prompt)) return; - if (await handleSaveSubcommand(host, prompt)) return; - - const mode = dynamicWorkflowModeSubcommand(prompt); - if (mode !== undefined) { - await applyDynamicWorkflowMode(host, mode, `/workflow ${prompt}`); - return; - } - - if (prompt.length === 0) { - await applyDynamicWorkflowMode(host, !host.state.appState.dynamicWorkflowMode, '/workflow'); - return; - } - - if (host.state.appState.model.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - - if (host.state.appState.permissionMode === 'manual') { - showDynamicWorkflowStartPermissionPrompt(host, `/workflow ${prompt}`, 'Dynamic Workflow task not started.', (choice) => - startDynamicWorkflowWithPermission(host, prompt, choice), - ); - return; - } - - await startDynamicWorkflowTask(host, prompt); -} - -function showDynamicWorkflowStartPermissionPrompt( - host: SlashCommandHost, - commandText: string, - cancelStatus: string, - onSelect: (choice: DynamicWorkflowStartPermissionChoice) => Promise<void>, -): void { - const cancelStart = (): void => { - host.restoreInputText(commandText); - host.showStatus(cancelStatus); - }; - host.mountEditorReplacement( - new DynamicWorkflowStartPermissionPromptComponent({ - onSelect: (choice) => { - host.restoreEditor(); - void onSelect(choice); - }, - onCancel: cancelStart, - }), - ); -} - -async function startDynamicWorkflowWithPermission( - host: SlashCommandHost, - prompt: string, - choice: DynamicWorkflowStartPermissionChoice, -): Promise<void> { - if (choice === 'auto' || choice === 'yolo') { - if (!(await setPermissionForDynamicWorkflow(host, choice))) return; - } - await startDynamicWorkflowTask(host, prompt); -} - -async function setPermissionForDynamicWorkflow(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> { - try { - await host.requireSession().setPermission(mode); - } catch (error) { - host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); - return false; - } - host.setAppState({ permissionMode: mode }); - return true; -} - -async function startDynamicWorkflowTask(host: SlashCommandHost, prompt: string): Promise<void> { - if (!host.state.appState.dynamicWorkflowMode && !(await setDynamicWorkflowMode(host, true, 'task'))) { - return; - } - renderDynamicWorkflowModeMarker(host, 'active'); - host.sendNormalUserInput(withWorkerModelInstruction(prompt, host.state.appState.dynamicWorkflowModel)); -} - -/** - * `/workflow model <alias>` is a preference, not a hard override: it reaches the - * subagents as an instruction to set DynamicWorkflow's `model` field, so the - * agent can still pick something else when the task plainly calls for it. - */ -function withWorkerModelInstruction(prompt: string, model: string | undefined): string { - return model === undefined - ? prompt - : `${prompt}\n\nUse model "${model}" for the DynamicWorkflow subagents in this task.`; -} - -/** - * `/workflow save <name> [--personal]` writes the last run back out as a - * skill, so a fan-out that worked can be re-run by name instead of - * re-described. Project scope is the default; `--personal` keeps the skill in - * the user's home skills directory instead of the repository. - * - * Returns true when the input was a `save` subcommand and has been handled. - */ -async function handleSaveSubcommand(host: SlashCommandHost, input: string): Promise<boolean> { - const match = /^save(?:\s+(.*))?$/iu.exec(input); - if (match === null) return false; - - const tokens = (match[1] ?? '').split(/\s+/u).filter((token) => token.length > 0); - // A name may contain spaces, so the flag is only recognised at either end. - // Anywhere else — or twice — it is a typo rather than part of the name, and - // folding it in would silently save under a different name and scope. - const personalFirst = tokens[0] === '--personal'; - const personalLast = !personalFirst && tokens.at(-1) === '--personal'; - if (personalFirst) tokens.shift(); - else if (personalLast) tokens.pop(); - const scope: SavedWorkflowScope = personalFirst || personalLast ? 'personal' : 'project'; - const name = tokens.join(' '); - if (name.length === 0 || tokens.includes('--personal')) { - host.showError('Usage: /workflow save <name> [--personal]'); - return true; - } - - const args = host.state.lastDynamicWorkflowArgs; - if (args === undefined) { - host.showError('No Dynamic Workflow has run in this session yet.'); - return true; - } - - const description = stringArg(args, 'description'); - if (description === undefined) { - host.showError('The last Dynamic Workflow has no description to save.'); - return true; - } - - try { - const dir = await writeSavedWorkflowSkill({ - scope, - workDir: host.state.appState.workDir, - brandHomeDir: getDataDir(), - workflow: { - name, - description, - subagentType: stringArg(args, 'subagent_type'), - promptTemplate: stringArg(args, 'prompt_template'), - model: stringArg(args, 'model'), - effort: stringArg(args, 'effort'), - outputSchema: recordArg(args, 'output_schema'), - sizeGuideline: currentWorkflowSizeGuideline(), - }, - }); - // The skill registry is built once when the session opens, so the file just - // written is invisible to it. Re-discover before rebuilding the command set, - // or `/<name>` stays a plain message until the session is reloaded. - const session = host.session; - if (session !== undefined) await session.reloadSkills(); - await host.refreshSkillCommands(session); - host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`); - } catch (error) { - host.showError(`Failed to save workflow: ${formatErrorMessage(error)}`); - } - return true; -} - -function stringArg(args: Record<string, unknown>, key: string): string | undefined { - const value = args[key]; - return typeof value === 'string' && value.trim().length > 0 ? value : undefined; -} - -function recordArg(args: Record<string, unknown>, key: string): Record<string, unknown> | undefined { - const value = args[key]; - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record<string, unknown>) - : undefined; -} - -/** Returns true when the input was a `model` subcommand and has been handled. */ -function handleModelSubcommand(host: SlashCommandHost, input: string): boolean { - const match = /^model(?:\s+(.*))?$/iu.exec(input); - if (match === null) return false; - - const value = match[1]?.trim() ?? ''; - const current = host.state.appState.dynamicWorkflowModel; - if (value.length === 0) { - host.showStatus( - current === undefined - ? 'Dynamic Workflow subagents use this session model. Set another with /workflow model <alias>.' - : `Dynamic Workflow subagents use ${current}. Clear it with /workflow model off.`, - ); - return true; - } - if (value.toLowerCase() === 'off' || value.toLowerCase() === 'clear') { - host.setAppState({ dynamicWorkflowModel: undefined }); - host.showStatus('Dynamic Workflow subagents now use this session model.'); - return true; - } - // An alias the engine cannot resolve falls back to the session model at spawn - // time, so accepting one here would report a routing that never happens. - const configured = host.state.appState.availableModels; - if (Object.keys(configured).length > 0 && !Object.hasOwn(configured, value)) { - host.showError(`Unknown model: ${value}. Run /model to see the configured aliases.`); - return true; - } - host.setAppState({ dynamicWorkflowModel: value }); - host.showStatus(`Dynamic Workflow subagents will use ${value}.`); - return true; -} - -async function applyDynamicWorkflowMode( - host: SlashCommandHost, - enabled: boolean, - commandText: string, -): Promise<void> { - if (enabled && host.state.appState.dynamicWorkflowMode) { - host.showStatus('Dynamic Workflow mode is already on.'); - return; - } - if (!enabled && !host.state.appState.dynamicWorkflowMode) { - host.showStatus('Dynamic Workflow mode is already off.'); - return; - } - if (enabled && host.state.appState.permissionMode === 'manual') { - showDynamicWorkflowStartPermissionPrompt(host, commandText, 'Dynamic Workflow mode not enabled.', async (choice) => { - if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForDynamicWorkflow(host, choice))) { - return; - } - if (!(await setDynamicWorkflowMode(host, true, 'manual'))) return; - renderDynamicWorkflowModeMarker(host, 'active'); - }); - return; - } - if (!(await setDynamicWorkflowMode(host, enabled, 'manual'))) return; - renderDynamicWorkflowModeMarker(host, enabled ? 'active' : 'inactive'); -} - -async function setDynamicWorkflowMode( - host: SlashCommandHost, - enabled: boolean, - trigger: 'manual' | 'task', -): Promise<boolean> { - try { - await host.requireSession().setDynamicWorkflowMode(enabled, trigger); - } catch (error) { - host.showError( - `Failed to ${enabled ? 'enable' : 'disable'} Dynamic Workflow mode: ${formatErrorMessage(error)}`, - ); - return false; - } - host.setAppState({ dynamicWorkflowMode: enabled }); - host.state.dynamicWorkflowModeEntry = enabled ? trigger : undefined; - return true; -} - -function dynamicWorkflowModeSubcommand(input: string): boolean | undefined { - const command = input.toLowerCase(); - if (command === 'on') return true; - if (command === 'off') return false; - return undefined; -} - -function renderDynamicWorkflowModeMarker(host: SlashCommandHost, state: DynamicWorkflowModeMarkerState): void { - host.state.transcriptContainer.addTranscriptChild( - new DynamicWorkflowModeMarkerComponent(state), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, - ); - host.state.ui.requestRender(); -} diff --git a/apps/pythinker-code/src/tui/commands/dynamic_workflow.ts b/apps/pythinker-code/src/tui/commands/dynamic_workflow.ts new file mode 100644 index 00000000..6090b617 --- /dev/null +++ b/apps/pythinker-code/src/tui/commands/dynamic_workflow.ts @@ -0,0 +1,156 @@ +import type { PermissionMode } from '@pymodel/pythinker-code-sdk'; + +import { + DynamicWorkflowStartPermissionPromptComponent, + type DynamicWorkflowStartPermissionChoice, +} from '../components/dialogs/dynamic-workflow-start-permission-prompt'; +import { + DynamicWorkflowModeMarkerComponent, + type DynamicWorkflowModeMarkerState, +} from '../components/messages/dynamic-workflow-markers'; +import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleDynamicWorkflowCommand(host: SlashCommandHost, args: string): Promise<void> { + if (host.session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + const prompt = args.trim(); + const mode = dynamicWorkflowModeSubcommand(prompt); + if (mode !== undefined) { + await applyDynamicWorkflowMode(host, mode, `/dynamic_workflow ${prompt}`); + return; + } + + if (prompt.length === 0) { + await applyDynamicWorkflowMode(host, !host.state.appState.dynamicWorkflowMode, '/dynamic_workflow'); + return; + } + + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + + if (host.state.appState.permissionMode === 'manual') { + showDynamicWorkflowStartPermissionPrompt(host, `/dynamic_workflow ${prompt}`, 'DynamicWorkflow task not started.', (choice) => + startDynamicWorkflowWithPermission(host, prompt, choice), + ); + return; + } + + await startDynamicWorkflowTask(host, prompt); +} + +function showDynamicWorkflowStartPermissionPrompt( + host: SlashCommandHost, + commandText: string, + cancelStatus: string, + onSelect: (choice: DynamicWorkflowStartPermissionChoice) => Promise<void>, +): void { + const cancelStart = (): void => { + host.restoreInputText(commandText); + host.showStatus(cancelStatus); + }; + host.mountEditorReplacement( + new DynamicWorkflowStartPermissionPromptComponent({ + onSelect: (choice) => { + host.restoreEditor(); + void onSelect(choice); + }, + onCancel: cancelStart, + }), + ); +} + +async function startDynamicWorkflowWithPermission( + host: SlashCommandHost, + prompt: string, + choice: DynamicWorkflowStartPermissionChoice, +): Promise<void> { + if (choice === 'auto' || choice === 'yolo') { + if (!(await setPermissionForDynamicWorkflow(host, choice))) return; + } + await startDynamicWorkflowTask(host, prompt); +} + +async function setPermissionForDynamicWorkflow(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> { + try { + await host.requireSession().setPermission(mode); + } catch (error) { + host.showError(`Failed to set permission mode: ${formatErrorMessage(error)}`); + return false; + } + host.setAppState({ permissionMode: mode }); + return true; +} + +async function startDynamicWorkflowTask(host: SlashCommandHost, prompt: string): Promise<void> { + if (!host.state.appState.dynamicWorkflowMode && !(await setDynamicWorkflowMode(host, true, 'task'))) { + return; + } + renderDynamicWorkflowModeMarker(host, 'active'); + host.sendNormalUserInput(prompt); +} + +async function applyDynamicWorkflowMode( + host: SlashCommandHost, + enabled: boolean, + commandText: string, +): Promise<void> { + if (enabled && host.state.appState.dynamicWorkflowMode) { + host.showStatus('DynamicWorkflow mode is already on.'); + return; + } + if (!enabled && !host.state.appState.dynamicWorkflowMode) { + host.showStatus('DynamicWorkflow mode is already off.'); + return; + } + if (enabled && host.state.appState.permissionMode === 'manual') { + showDynamicWorkflowStartPermissionPrompt(host, commandText, 'DynamicWorkflow mode not enabled.', async (choice) => { + if ((choice === 'auto' || choice === 'yolo') && !(await setPermissionForDynamicWorkflow(host, choice))) { + return; + } + if (!(await setDynamicWorkflowMode(host, true, 'manual'))) return; + renderDynamicWorkflowModeMarker(host, 'active'); + }); + return; + } + if (!(await setDynamicWorkflowMode(host, enabled, 'manual'))) return; + renderDynamicWorkflowModeMarker(host, enabled ? 'active' : 'inactive'); +} + +async function setDynamicWorkflowMode( + host: SlashCommandHost, + enabled: boolean, + trigger: 'manual' | 'task', +): Promise<boolean> { + try { + await host.requireSession().setDynamicWorkflowMode(enabled, trigger); + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} dynamic_workflow mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ dynamicWorkflowMode: enabled }); + host.state.dynamicWorkflowModeEntry = enabled ? trigger : undefined; + return true; +} + +function dynamicWorkflowModeSubcommand(input: string): boolean | undefined { + const command = input.toLowerCase(); + if (command === 'on') return true; + if (command === 'off') return false; + return undefined; +} + +function renderDynamicWorkflowModeMarker(host: SlashCommandHost, state: DynamicWorkflowModeMarkerState): void { + host.state.transcriptContainer.addChild( + new DynamicWorkflowModeMarkerComponent(state), + ); + host.state.ui.requestRender(); +} diff --git a/apps/pythinker-code/src/tui/commands/experimental-flags.ts b/apps/pythinker-code/src/tui/commands/experimental-flags.ts index ba54de08..d802bbe8 100644 --- a/apps/pythinker-code/src/tui/commands/experimental-flags.ts +++ b/apps/pythinker-code/src/tui/commands/experimental-flags.ts @@ -5,34 +5,12 @@ import { experimentalFeatureMap } from '#/utils/experimental-features'; // Resolved experimental features, fetched once from the core over RPC at startup and then read // synchronously by the command palette and dispatch. App-local cache, not a source of truth. let snapshot: ExperimentalFlagMap = {}; -const listeners: Array<() => void> = []; /** Replace the cached flag snapshot. Call after fetching via `harness.getExperimentalFeatures()`. */ export function setExperimentalFeatures( features: readonly Pick<ExperimentalFeatureState, 'id' | 'enabled'>[], ): void { snapshot = experimentalFeatureMap(features); - notifyListeners(); -} - -/** - * Override one feature for the current run only. Unlike - * `setExperimentalFeatures`, this never persists: the override lives in the - * in-memory snapshot until the next startup or snapshot replacement. - */ -export function setExperimentalFeatureForRun(flag: string, enabled: boolean): void { - snapshot = { ...snapshot, [flag]: enabled }; - notifyListeners(); -} - -function notifyListeners(): void { - for (const listener of listeners) { - listener(); - } -} - -export function onExperimentalFeaturesChanged(listener: () => void): void { - listeners.push(listener); } /** An `undefined` flag means "not gated" → always enabled, so callers can pass an optional flag id. */ diff --git a/apps/pythinker-code/src/tui/commands/fast.ts b/apps/pythinker-code/src/tui/commands/fast.ts deleted file mode 100644 index 88ccb556..00000000 --- a/apps/pythinker-code/src/tui/commands/fast.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; -import type { SlashCommandHost } from './dispatch'; - -const FAST_MODE_UNAVAILABLE = 'Fast mode is unavailable for the current model and provider.'; - -/** - * Handles `/fast [on|off|status]`: toggles provider-native Fast processing - * for the active session. Bare `/fast` flips the current state; enabling is - * refused when the active model/provider does not support Fast mode. - */ -export async function handleFastCommand(host: SlashCommandHost, rawArgs: string): Promise<void> { - const command = rawArgs.trim().toLowerCase(); - if (command !== '' && command !== 'on' && command !== 'off' && command !== 'status') { - host.showError('Usage: /fast [on|off|status]'); - return; - } - if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - if (host.state.appState.model.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - - const status = await host.session.getStatus(); - const enabled = status.fastMode === true; - const supported = status.fastModeSupported === true; - host.setAppState({ fastMode: enabled, fastModeSupported: supported }); - - if (command === 'status') { - if (!supported) { - host.showStatus(FAST_MODE_UNAVAILABLE, 'warning'); - return; - } - host.showStatus(enabled ? '↯ Fast mode is on.' : 'Fast mode is off.'); - return; - } - - const nextEnabled = command === '' ? !enabled : command === 'on'; - if (nextEnabled && !supported) { - host.showError(FAST_MODE_UNAVAILABLE); - return; - } - if (nextEnabled === enabled) { - host.showStatus(`Fast mode is already ${enabled ? 'on' : 'off'}.`); - return; - } - - await host.session.setFastMode(nextEnabled); - host.setAppState({ fastMode: nextEnabled, fastModeSupported: supported }); - if (nextEnabled) { - host.showNotice( - '↯ Fast mode on', - 'Uses provider-native Fast processing and may consume credits or tokens at premium rates.', - ); - return; - } - host.showStatus('Fast mode off.'); -} diff --git a/apps/pythinker-code/src/tui/commands/goal.ts b/apps/pythinker-code/src/tui/commands/goal.ts index 8dce038c..c16ff193 100644 --- a/apps/pythinker-code/src/tui/commands/goal.ts +++ b/apps/pythinker-code/src/tui/commands/goal.ts @@ -25,6 +25,7 @@ import { type GoalQueueSnapshot, } from '../goal-queue-store'; import { formatErrorMessage } from '../utils/event-payload'; +import { canRestoreSubmittedInput } from './resolve'; import type { SlashCommandHost } from './dispatch'; const MAX_GOAL_OBJECTIVE_LENGTH = 4000; @@ -63,7 +64,13 @@ export type ParsedGoalCommand = } | { readonly kind: 'next-add'; readonly objective: string } | { readonly kind: 'next-manage' } - | { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' }; + | { + readonly kind: 'error'; + readonly message: string; + readonly severity?: 'error' | 'hint'; + /** Restore the typed `/goal ...` line into the editor so the input is not lost. */ + readonly restoreInput?: boolean; + }; const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']); @@ -114,7 +121,8 @@ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { return { kind: 'error', - message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, }; } return { kind: 'create', objective, replace }; @@ -126,6 +134,12 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P case 'error': if (parsed.severity === 'hint') host.showStatus(parsed.message); else host.showError(parsed.message); + // Give rejected input back so a long hand-typed objective is not + // lost — unless the user already moved on (a newer draft or an + // opened panel), which is possible after the async lazy-session + // creation on the v2 engine. + if (parsed.restoreInput === true && canRestoreSubmittedInput(host)) + host.restoreInputText(`/goal ${args}`); return; case 'status': await showGoalStatus(host); @@ -167,12 +181,57 @@ function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { return { kind: 'error', - message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, }; } return { kind: 'next-add', objective }; } +/** + * Live pre-send check for the main editor: when the typed text is a `/goal` + * create/next command whose objective already exceeds the length limit, + * returns a warning to show while typing — before anything is submitted or + * sent to the server. Returns undefined for non-goal input and for control + * forms (`status`/`pause`/`resume`/`cancel`/`next manage`). + */ +export function goalObjectiveLengthWarning(text: string): string | undefined { + // Submitted text is trimmed before dispatch, so match leading whitespace. + const trimmed = text.trimStart(); + if (!trimmed.startsWith('/goal')) return undefined; + const args = trimmed.slice('/goal'.length); + // parseSlashInput splits the command name at a literal space only, so a + // newline/tab boundary (`/goal⏎…`, `/goalfoo`) is not the goal command. + if (args.length > 0 && args.charAt(0) !== ' ') return undefined; + const objective = extractGoalObjective(args); + if (objective === undefined || objective.length <= MAX_GOAL_OBJECTIVE_LENGTH) return undefined; + return `Goal objective is too long (${objective.length}/${MAX_GOAL_OBJECTIVE_LENGTH} characters); put long content in a file and reference the file path.`; +} + +/** + * Mirrors the parse grammar above: strips `next` / `replace` / `--` and + * returns the objective text, or undefined when the args form a control + * command that carries no objective. + */ +function extractGoalObjective(rawArgs: string): string | undefined { + const args = rawArgs.trim(); + if (args.length === 0 || args === 'status') return undefined; + const tokens = args.split(/\s+/); + const first = tokens[0]; + let index = 0; + if (first === 'next') { + if (tokens.length === 2 && tokens[1] === 'manage') return undefined; + index = 1; + } else { + if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { + return undefined; + } + if (tokens[index] === 'replace') index += 1; + } + if (tokens[index] === '--') index += 1; + return tokens.slice(index).join(' ').trim(); +} + async function queueNextGoal( host: SlashCommandHost, parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>, @@ -205,9 +264,8 @@ async function queueNextGoal( } host.track('goal_queue_append'); if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); - host.state.transcriptContainer.addTranscriptChild( + host.state.transcriptContainer.addChild( new UpcomingGoalAddedMessageComponent(), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, ); host.state.ui.requestRender(); } @@ -373,10 +431,19 @@ async function startGoalWithPermission( choice: GoalStartPermissionChoice, options: GoalStartOptions, ): Promise<void> { - if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) { + const previousMode = host.state.appState.permissionMode; + const switched = + choice !== previousMode && (choice === 'auto' || choice === 'yolo'); + if (switched) { if (!(await setPermissionForGoal(host, choice))) return; } - await startGoal(host, parsed, options); + const started = await startGoal(host, parsed, options); + // The permission switch only exists to run this goal. If creation fails + // (e.g. a goal already exists and `replace` was not given), restore the + // previous mode so the session is not left more permissive than before. + if (!started && switched) { + await setPermissionForGoal(host, previousMode); + } } async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> { @@ -413,11 +480,7 @@ async function startGoal( if (options.beforeSend !== undefined && !(await options.beforeSend())) { return false; } - host.track('goal_create', { replace: parsed.replace }); - host.state.transcriptContainer.addTranscriptChild(new GoalSetMessageComponent(), { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(new GoalSetMessageComponent()); host.state.ui.requestRender(); if (options.sendInput !== undefined) { options.sendInput(parsed.objective); @@ -488,10 +551,9 @@ async function showGoalStatus(host: SlashCommandHost): Promise<void> { host.showStatus('No goal set. Start one with `/goal <objective>`.'); return; } - host.state.transcriptContainer.addTranscriptChild(new GoalStatusMessageComponent(goal), { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild( + new GoalStatusMessageComponent(goal), + ); host.state.ui.requestRender(); } diff --git a/apps/pythinker-code/src/tui/commands/index.ts b/apps/pythinker-code/src/tui/commands/index.ts index d9870a56..d8caba10 100644 --- a/apps/pythinker-code/src/tui/commands/index.ts +++ b/apps/pythinker-code/src/tui/commands/index.ts @@ -3,60 +3,33 @@ export * from './parse'; export * from './registry'; export * from './resolve'; export * from './skills'; +export * from './plugin-commands'; export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; -export { handleAddDirCommand, showDirectoryInput } from './add-dir'; -export { handleAgentsCommand } from './agents'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleCompactCommand, - applyCopyPreferenceChoice, handleEditorCommand, - handleKeybindingsCommand, handleModelCommand, - handleOutputStyleCommand, - handlePermissionsCommand, handlePlanCommand, handleThemeCommand, handleYoloCommand, showExperimentsPanel, - showCopyPreferencePicker, showModelPicker, showPermissionPicker, showSettingsSelector, } from './config'; -export { handleCopyCommand, showMessageActions } from './copy'; -export { handleDebugCommand } from './debug'; -export { buildWorkingTreeDiffLines, handleDiffCommand } from './diff'; -export { handleAdvisorCommand } from './advisor'; -export { handleDynamicWorkflowCommand } from './dynamic-workflow'; -export { handleFastCommand } from './fast'; -export { - handleDoctorCommand, - handleFeedbackCommand, - handleHooksCommand, - showMcpServers, - showContextReport, - showContextFiles, - showCost, - showStatusReport, - showUsage, -} from './info'; +export { handleDynamicWorkflowCommand } from './dynamic_workflow'; +export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; -export { handleGoalCommand, parseGoalCommand } from './goal'; -export { handleMemoryCommand, showMemoryPicker } from './memory'; -export { - fastArgumentCompletions, - goalArgumentCompletions, - pluginsArgumentCompletions, -} from './registry'; +export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal'; +export { goalArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; -export { handleTagCommand } from './tag'; export { handleUndoCommand } from './undo'; -export { handleVimCommand } from './vim'; export { handleWebCommand } from './web'; export { promptApiKey, @@ -64,6 +37,7 @@ export { promptFeedbackInput, promptLogoutProviderSelection, promptModelSelectionForCatalog, + promptModelSelectionForCodex, promptModelSelectionForOpenPlatform, promptPlatformSelection, runModelSelector, diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index 2bbfe3d4..1cfaec61 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -1,28 +1,30 @@ -import { join, relative } from 'node:path'; +import { release as osRelease, type as osType } from 'node:os'; -import type { - McpServerInfo, - PythinkerConfig, - SessionStatus, - SessionUsage, -} from '@pymodel/pythinker-code-sdk'; +import type { McpServerInfo, SessionStatus, SessionUsage } from '@pymodel/pythinker-code-sdk'; -import { handleDoctor } from '#/cli/sub/doctor'; -import { startManualUpdate } from '#/cli/update/preflight'; -import { PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; -import { openUrl } from '#/utils/open-url'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; -import { - buildContextUsageReportLines, - buildCostReportLines, - buildUsageReportLines, - UsagePanelComponent, -} from '../components/messages/usage-panel'; +import { buildUsageReportLines, UsagePanelComponent, type ManagedUsageReport } from '../components/messages/usage-panel'; import { FEEDBACK_ISSUE_URL, + FEEDBACK_STATUS_CANCELLED, + FEEDBACK_STATUS_FALLBACK, + FEEDBACK_STATUS_NETWORK_ERROR, + FEEDBACK_STATUS_NOT_SIGNED_IN, + FEEDBACK_STATUS_SUBMITTING, + FEEDBACK_STATUS_SUCCESS, + FEEDBACK_STATUS_UPLOAD_FAILED, + FEEDBACK_TELEMETRY_EVENT, + feedbackIdLine, + feedbackSessionLine, + PYTHINKER_CODE_SIGNUP_URL, + withFeedbackVersionPrefix, } from '../constant/feedback'; +import { DEFAULT_OAUTH_PROVIDER_NAME, isManagedUsageProvider } from '../constant/pythinker-tui'; +import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachments'; import { formatErrorMessage } from '../utils/event-payload'; +import { openUrl } from '#/utils/open-url'; +import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -30,8 +32,97 @@ import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- export async function handleFeedbackCommand(host: SlashCommandHost): Promise<void> { - host.showStatus(FEEDBACK_ISSUE_URL); - openUrl(FEEDBACK_ISSUE_URL); + const fallback = (reason: string): void => { + host.showStatus(reason); + host.showStatus(FEEDBACK_ISSUE_URL); + openUrl(FEEDBACK_ISSUE_URL); + }; + + // Gate on the OAuth token rather than the active model's provider: a + // signed-in user running an API-key model can still submit feedback + // through the authenticated channel. + let signedIn = false; + try { + const status = await host.harness.auth.status(DEFAULT_OAUTH_PROVIDER_NAME); + signedIn = status.providers.some( + (provider) => provider.providerName === DEFAULT_OAUTH_PROVIDER_NAME && provider.hasToken, + ); + } catch { + // The sign-in state is unreadable — keep the feedback entry usable by + // falling back to GitHub Issues instead of failing the command. + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + if (!signedIn) { + host.showStatus(FEEDBACK_STATUS_NOT_SIGNED_IN); + host.showStatus(PYTHINKER_CODE_SIGNUP_URL); + host.showStatus(FEEDBACK_ISSUE_URL); + return; + } + + // Stage 1: collect the free-form feedback text. + const input = await promptFeedbackInput(host); + if (input === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + // Stage 2: ask whether to attach diagnostics (logs / codebase). + const level = await promptFeedbackAttachment(host); + if (level === undefined) { + host.showStatus(FEEDBACK_STATUS_CANCELLED); + return; + } + + const version = withFeedbackVersionPrefix(host.state.appState.version); + const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING); + // Guarantee the spinner's underlying setInterval is always cleared, even when + // submitFeedback throws — otherwise the interval (and its per-frame + // requestRender) leaks for the rest of the session. + let stopped = false; + const stopSpinner = (opts: { ok: boolean; label: string }): void => { + if (stopped) return; + stopped = true; + spinner.stop(opts); + }; + try { + const res = await host.harness.auth.submitFeedback({ + content: input.value, + sessionId: host.state.appState.sessionId, + version, + os: `${osType()} ${osRelease()}`, + model: host.state.appState.model.length > 0 ? host.state.appState.model : null, + }); + + if (res.kind !== 'ok') { + stopSpinner({ ok: false, label: res.message }); + fallback(FEEDBACK_STATUS_FALLBACK); + return; + } + + // Stage 3: prepare and upload each requested attachment independently. + // Attachment failures are non-fatal partial failures — the text feedback + // already exists server-side — so a throw here degrades to the + // partial-failure status, never to the GitHub fallback in the outer catch. + let attachmentFailed = false; + try { + attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level); + } catch { + attachmentFailed = true; + } + + stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS }); + host.showStatus(feedbackSessionLine(host.state.appState.sessionId)); + host.showStatus(feedbackIdLine(res.feedbackId)); + host.track(FEEDBACK_TELEMETRY_EVENT); + if (attachmentFailed) { + host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED); + } + } catch (error) { + stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR }); + fallback(FEEDBACK_STATUS_FALLBACK); + throw error; + } } // --------------------------------------------------------------------------- @@ -48,86 +139,33 @@ interface RuntimeStatusResult { readonly error?: string; } - -export function showCost(host: SlashCommandHost): void { - const { model, modelCostRates, totalCostUsd } = host.state.appState; - const panel = new UsagePanelComponent( - () => buildCostReportLines({ model, modelCostRates, totalCostUsd }), - 'primary', - ' Cost ', - ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); - host.state.ui.requestRender(); +interface ManagedUsageResult { + readonly usage?: ManagedUsageReport; + readonly error?: string; } export async function showUsage(host: SlashCommandHost): Promise<void> { const sessionUsage = await loadSessionUsageReport(host); + const managedUsage = await loadManagedUsageReport(host); const reportArgs = { sessionUsage: sessionUsage.usage, sessionUsageError: sessionUsage.error, contextUsage: host.state.appState.contextUsage, contextTokens: host.state.appState.contextTokens, maxContextTokens: host.state.appState.maxContextTokens, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, }; const panel = new UsagePanelComponent(() => buildUsageReportLines(reportArgs), 'primary'); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } -export function showReleaseNotes(host: SlashCommandHost): void { - host.showNotice('Release notes', PYTHINKER_CODE_CHANGELOG_URL); -} - -export function showTerminalSetup(host: SlashCommandHost): void { - const terminal = `${process.env['TERM_PROGRAM'] ?? ''} ${process.env['TERM'] ?? ''}`.toLowerCase(); - const supportsShiftEnter = [ - 'ghostty', - 'kitty', - 'wezterm', - 'iterm', - 'warp', - ].some((name) => terminal.includes(name)); - host.showNotice( - 'Multiline input is ready', - supportsShiftEnter - ? 'Shift-Enter inserts a newline in this terminal. Ctrl-J is always available as a fallback.' - : 'Ctrl-J inserts a newline. Shift-Enter also works when your terminal emits Kitty/CSI-u keyboard input.', - ); -} - -export async function showContextReport( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /context'); - return; - } - try { - const report = await host.requireSession().getContextUsage(); - const panel = new UsagePanelComponent( - () => buildContextUsageReportLines(report), - 'primary', - ' Context ', - ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); - host.state.ui.requestRender(); - } catch (error) { - host.showError(`Failed to load context usage: ${formatErrorMessage(error)}`); - } -} - export async function showStatusReport(host: SlashCommandHost): Promise<void> { - const runtimeStatus = await loadRuntimeStatusReport(host); + const [runtimeStatus, managedUsage] = await Promise.all([ + loadRuntimeStatusReport(host), + loadManagedUsageReport(host), + ]); const appState = host.state.appState; const reportArgs = { version: appState.version, @@ -135,9 +173,7 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> { workDir: appState.workDir, sessionId: appState.sessionId, sessionTitle: appState.sessionTitle, - thinkingLevel: appState.thinkingLevel, - fastMode: appState.fastMode, - fastModeSupported: appState.fastModeSupported, + thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, contextUsage: appState.contextUsage, @@ -146,19 +182,26 @@ export async function showStatusReport(host: SlashCommandHost): Promise<void> { availableModels: appState.availableModels, status: runtimeStatus.status, statusError: runtimeStatus.error, + managedUsage: managedUsage?.usage, + managedUsageError: managedUsage?.error, }; const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } export async function showMcpServers(host: SlashCommandHost): Promise<void> { let servers: readonly McpServerInfo[]; try { - servers = await host.requireSession().listMcpServers(); + if (host.session !== undefined) { + servers = await host.session.listMcpServers(); + } else if (host.engineV2) { + // v2 session-less: the MCP connection set is workspace-scoped, so it is + // inspectable before the first session exists. + servers = await host.harness.listWorkspaceMcpServers(host.state.appState.workDir); + } else { + servers = await host.requireSession().listMcpServers(); + } } catch (error) { host.showError(`Failed to load MCP servers: ${formatErrorMessage(error)}`); return; @@ -170,281 +213,39 @@ export async function showMcpServers(host: SlashCommandHost): Promise<void> { 'primary', title, ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } -export async function showContextFiles( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /files'); - return; - } - try { - const files = await host.requireSession().listContextFiles(); - if (files.length === 0) { - host.showNotice('No files in context'); - return; - } - host.showNotice( - `Files in context (${String(files.length)})`, - files.map((file) => relative(host.state.appState.workDir, file) || '.').join('\n'), - ); - } catch (error) { - host.showError(`Failed to list files in context: ${formatErrorMessage(error)}`); - } -} - -export async function handleHooksCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /hooks'); - return; - } - - let hooks: NonNullable<PythinkerConfig['hooks']>; +async function loadSessionUsageReport(host: SlashCommandHost): Promise<SessionUsageResult> { try { - hooks = (await host.harness.getConfig({ reload: true })).hooks ?? []; + return { usage: await host.requireSession().getUsage() }; } catch (error) { - host.showError(`Failed to load hooks: ${formatErrorMessage(error)}`); - return; - } - if (hooks.length === 0) { - host.showNotice('No hooks configured', 'Add hooks to config.toml or ask Pythinker to help.'); - return; - } - host.showNotice( - `Hooks (${String(hooks.length)})`, - hooks - .map((hook) => - [ - hook.event, - hook.matcher?.trim() || 'all', - 'command' in hook - ? hook.command - : hook.type === 'http' - ? hook.url - : hook.prompt, - `${String(hook.timeout ?? (hook.type === 'agent' ? 60 : 30))}s`, - hook.once === true ? 'once' : undefined, - hook.async === true ? 'async' : undefined, - hook.statusMessage === undefined ? undefined : `status ${hook.statusMessage}`, - hook.if === undefined ? undefined : `if ${hook.if}`, - 'asyncRewake' in hook && hook.asyncRewake === true - ? 'async rewake' - : undefined, - 'shell' in hook && hook.shell === 'powershell' ? 'powershell' : undefined, - (hook.type === 'prompt' || hook.type === 'agent') && hook.model !== undefined - ? `model ${hook.model}` - : undefined, - ] - .filter((part) => part !== undefined) - .join(' · '), - ) - .join('\n'), - ); -} - -/** The installer's recorded stderr tail can be ~2 KB; show one line of it. */ -const UPDATE_FAILURE_REASON_MAX_CHARS = 160; - -/** - * Collapse the recorded failure reason onto one line; a long tail is cut to - * a single sensible line and marked as truncated instead of flooding the - * transcript with the installer's full stderr. - */ -function renderUpdateFailureReason(message: string): string | undefined { - const singleLine = message.replaceAll(/\s+/gu, ' ').trim(); - if (singleLine.length === 0) return undefined; - if (singleLine.length <= UPDATE_FAILURE_REASON_MAX_CHARS) return singleLine; - return `${singleLine.slice(0, UPDATE_FAILURE_REASON_MAX_CHARS)}… (truncated)`; -} - -export async function handleUpdateCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /update'); - return; - } - host.showStatus('Checking for updates…'); - const currentVersion = host.state.appState.version; - const result = await startManualUpdate(currentVersion); - switch (result.status) { - case 'up-to-date': - host.showNotice('Pythinker Code is up to date', `v${currentVersion}`); - return; - case 'started': - host.showNotice( - `Updating to v${result.version}`, - result.installOnRestart - ? 'Preparing with Homebrew in the background. Once ready, close this terminal and open a new one to install it.' - : 'Installing in the background — close this terminal and open a new one to apply the update.', - ); - return; - case 'in-progress': - // The target is present only when it is newer than the version the - // running install is working on; everything else keeps the old wording. - if (result.targetVersion !== undefined) { - host.showNotice( - `Installing v${result.installingVersion} — v${result.targetVersion} will follow`, - `The running install of v${result.installingVersion} finishes first; ` + - `v${result.targetVersion} installs after the next start.`, - ); - return; - } - host.showNotice( - `Update to v${result.installingVersion} already in progress`, - result.installOnRestart - ? result.readyToInstall - ? 'Close this terminal and open a new one to install it.' - : 'Close this terminal and open a new one after the current update operation finishes.' - : 'Close this terminal and open a new one once it completes.', - ); - return; - case 'manual': - host.showNotice( - `Update available — v${result.version}`, - `Run: ${result.command}`, - ); - return; - case 'failed': { - const reason = - result.message === undefined ? undefined : renderUpdateFailureReason(result.message); - host.showError( - `Update to v${result.version} failed after ${result.attempts} attempts.` + - (reason === undefined ? '' : `\nReason: ${reason}`) + - `\nTo update manually, run: ${result.command}`, - ); - return; - } - case 'check-failed': - host.showError(`Update check failed: ${result.message}`); - return; + return { error: formatErrorMessage(error) }; } } -export async function handleDoctorCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /doctor'); - return; - } - - let report = ''; - const writer = { - write(chunk: string): boolean { - report += chunk; - return true; - }, - }; +async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeStatusResult> { try { - const code = await handleDoctor( - { - cwd: () => host.state.appState.workDir, - defaultConfigPath: () => host.harness.configPath, - defaultTuiConfigPath: () => join(host.harness.homeDir, 'tui.toml'), - stdout: writer, - stderr: writer, - }, - {}, - ); - const keybindingWarnings = host.reloadKeybindings?.() ?? []; - const runtimeWarnings = await collectDoctorRuntimeWarnings(host); - const warnings = [...keybindingWarnings, ...runtimeWarnings]; - host.showNotice( - code !== 0 - ? 'Doctor found issues' - : warnings.length > 0 - ? 'Doctor found warnings' - : 'Doctor checks passed', - [report.trim(), ...warnings].filter((line) => line.length > 0).join('\n'), - ); + return { status: await host.requireSession().getStatus() }; } catch (error) { - host.showError(`Doctor failed: ${formatErrorMessage(error)}`); + return { error: error instanceof Error ? error.message : String(error) }; } } -async function collectDoctorRuntimeWarnings(host: SlashCommandHost): Promise<string[]> { - const warnings: string[] = []; - const [config, agents] = await Promise.allSettled([ - host.harness.getConfigDiagnostics(), - host.harness.listAgentProfiles(host.state.appState.workDir), - ]); - if (config.status === 'fulfilled') warnings.push(...config.value.warnings); - else warnings.push(`Config diagnostics failed: ${formatErrorMessage(config.reason)}`); - if (agents.status === 'fulfilled') { - warnings.push(...agents.value.warnings.map((warning) => `${warning.path}: ${warning.error}`)); - const agentDescriptionTokens = Math.round( - agents.value.profiles - .filter((profile) => profile.source !== 'built-in') - .reduce( - (total, profile) => - total + `${profile.name}: ${profile.whenToUse ?? profile.description ?? ''}`.length, - 0, - ) / 4, - ); - if (agentDescriptionTokens > 15_000) { - warnings.push( - `Large agent descriptions (~${agentDescriptionTokens.toLocaleString()} tokens > 15,000).`, - ); - } - } else { - warnings.push(`Agent profile diagnostics failed: ${formatErrorMessage(agents.reason)}`); - } - - if (host.session === undefined) return warnings; - try { - const usage = await host.session.getContextUsage(); - const mcpTokens = usage.tools - .filter((tool) => tool.source === 'mcp') - .reduce((total, tool) => total + tool.tokens, 0); - if (mcpTokens > 25_000) { - warnings.push(`Large MCP tools context (~${mcpTokens.toLocaleString()} tokens > 25,000).`); - } - } catch (error) { - warnings.push(`Context diagnostics failed: ${formatErrorMessage(error)}`); - } - try { - const plugins = await host.session.listPlugins(); - for (const plugin of plugins.filter((candidate) => candidate.hasErrors)) { - const info = await host.session.getPluginInfo(plugin.id); - const errors = info.diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); - warnings.push( - ...(errors.length === 0 - ? [`${plugin.id}: plugin state is ${plugin.state}`] - : errors.map((diagnostic) => `${plugin.id}: ${diagnostic.message}`)), - ); - } - } catch (error) { - warnings.push(`Plugin diagnostics failed: ${formatErrorMessage(error)}`); - } - return warnings; -} +async function loadManagedUsageReport(host: SlashCommandHost): Promise<ManagedUsageResult | undefined> { + const alias = host.state.appState.model; + const providerKey = host.state.appState.availableModels[alias]?.provider; + if (!isManagedUsageProvider(providerKey)) return undefined; -async function loadSessionUsageReport(host: SlashCommandHost): Promise<SessionUsageResult> { + let res; try { - return { usage: await host.requireSession().getUsage() }; + res = await host.harness.auth.getManagedUsage(providerKey); } catch (error) { return { error: formatErrorMessage(error) }; } -} - -async function loadRuntimeStatusReport(host: SlashCommandHost): Promise<RuntimeStatusResult> { - try { - return { status: await host.requireSession().getStatus() }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; + if (res.kind === 'error') { + return { error: res.message }; } + return { usage: { summary: res.summary, limits: res.limits, extraUsage: res.extraUsage } }; } - diff --git a/apps/pythinker-code/src/tui/commands/memory.ts b/apps/pythinker-code/src/tui/commands/memory.ts deleted file mode 100644 index 9c763a45..00000000 --- a/apps/pythinker-code/src/tui/commands/memory.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; - -import { resolveEditorCommand } from '#/utils/process/external-editor'; - -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; -import { formatErrorMessage } from '../utils/event-payload'; -import { openFileWithTuiSuspended } from './config'; -import type { SlashCommandHost } from './dispatch'; - -type MemoryScope = 'user' | 'project'; - -export async function handleMemoryCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - const scope = args.trim().toLowerCase(); - if (scope.length === 0) { - showMemoryPicker(host); - return; - } - if (scope !== 'user' && scope !== 'project') { - host.showError('Usage: /memory [user|project]'); - return; - } - await openMemoryFile(host, scope); -} - -export function showMemoryPicker(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Edit memory', - options: [ - { - value: 'user', - label: 'User memory', - description: `Instructions shared across projects in ${join( - host.harness.homeDir, - 'AGENTS.md', - )}.`, - }, - { - value: 'project', - label: 'Project memory', - description: `Instructions for this project in ${join( - host.state.appState.workDir, - 'AGENTS.md', - )}.`, - }, - ], - onSelect: (value) => { - host.restoreEditor(); - void openMemoryFile(host, value as MemoryScope); - }, - onCancel: () => { - host.restoreEditor(); - host.showNotice('Cancelled memory editing'); - }, - }), - ); -} - -async function openMemoryFile( - host: SlashCommandHost, - scope: MemoryScope, -): Promise<void> { - const path = join( - scope === 'user' ? host.harness.homeDir : host.state.appState.workDir, - 'AGENTS.md', - ); - try { - await mkdir(dirname(path), { recursive: true }); - try { - await writeFile(path, '', { encoding: 'utf8', flag: 'wx' }); - } catch (error) { - if (!isFileExists(error)) throw error; - } - - const command = resolveEditorCommand(host.state.appState.editorCommand); - if (command === undefined) { - host.showNotice( - `Memory file: ${path}`, - 'No editor configured. Set $VISUAL / $EDITOR, or run /editor <command>.', - ); - return; - } - if (!(await openFileWithTuiSuspended(host, path, command))) { - host.showError(`Editor exited before saving ${path}.`); - return; - } - - try { - await host.session?.refreshInstructions(); - host.showNotice( - `Opened ${path} in your editor.`, - host.session === undefined ? 'Applies to new sessions.' : 'Instructions refreshed.', - ); - } catch (error) { - host.showNotice( - `Opened ${path} in your editor.`, - `Failed to refresh instructions: ${formatErrorMessage(error)}`, - ); - } - host.track('memory_file_opened', { scope }); - } catch (error) { - host.showError(`Failed to open memory file: ${formatErrorMessage(error)}`); - } -} - -function isFileExists(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'EEXIST' - ); -} diff --git a/apps/pythinker-code/src/tui/commands/parse.ts b/apps/pythinker-code/src/tui/commands/parse.ts index ee19d358..1fcc0154 100644 --- a/apps/pythinker-code/src/tui/commands/parse.ts +++ b/apps/pythinker-code/src/tui/commands/parse.ts @@ -7,6 +7,8 @@ export function parseSlashInput(input: string): ParsedSlashInput | null { const spaceIdx = trimmed.indexOf(' '); const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); - if (name.includes('/')) return null; + // Reject file paths (e.g. `/usr/local/bin`), but allow namespaced plugin + // commands whose name itself contains `/` (e.g. `plugin:frontend/component`). + if (name.includes('/') && !name.includes(':')) return null; return { name, args }; } diff --git a/apps/pythinker-code/src/tui/commands/plugin-commands.ts b/apps/pythinker-code/src/tui/commands/plugin-commands.ts new file mode 100644 index 00000000..1fea6f9f --- /dev/null +++ b/apps/pythinker-code/src/tui/commands/plugin-commands.ts @@ -0,0 +1,27 @@ +import type { PluginCommandDef } from '@pymodel/pythinker-code-sdk'; + +import type { PythinkerSlashCommand } from './types'; + +export interface PluginSlashCommands { + readonly commands: readonly PythinkerSlashCommand[]; + /** Maps a namespaced command name (`plugin:command`) to its markdown body. */ + readonly commandMap: ReadonlyMap<string, string>; +} + +export function pluginCommandName(pluginId: string, name: string): string { + return `${pluginId}:${name}`; +} + +export function buildPluginSlashCommands(defs: readonly PluginCommandDef[]): PluginSlashCommands { + const commandMap = new Map<string, string>(); + const commands = defs.map((def) => { + const commandName = pluginCommandName(def.pluginId, def.name); + commandMap.set(commandName, def.body); + return { + name: commandName, + aliases: [], + description: def.description, + } satisfies PythinkerSlashCommand; + }); + return { commands, commandMap }; +} diff --git a/apps/pythinker-code/src/tui/commands/plugins.ts b/apps/pythinker-code/src/tui/commands/plugins.ts index f7a9e7fb..f214ec73 100644 --- a/apps/pythinker-code/src/tui/commands/plugins.ts +++ b/apps/pythinker-code/src/tui/commands/plugins.ts @@ -1,36 +1,43 @@ import { homedir as osHomedir } from 'node:os'; import { isAbsolute, join, resolve } from 'node:path'; -import type { - PluginInfo, - PluginInstallOptions, - PluginSummary, +import { + log, + type CapabilityStatus, + type PluginInfo, + type PluginSummary, + type Session, } from '@pymodel/pythinker-code-sdk'; +import { Markdown, Spacer } from '@pymodel/pi-tui'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import { - ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS, - PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS, -} from '#/constant/app'; -import { ApiKeyInputDialogComponent } from '../components/dialogs/api-key-input-dialog'; -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; -import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, - type PluginMarketplaceSelection, type PluginRemoveConfirmResult, - type PluginsOverviewSelection, + type PluginsPanelSelection, + type PluginsPanelTabId, } from '../components/dialogs/plugins-selector'; import { buildPluginsInfoLines, buildPluginsListLines, } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel } from '../utils/plugin-source-label'; -import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; +import { createMarkdownOptions } from '../utils/markdown-options'; +import { + formatPluginSourceLabel, + isOfficialPluginInstall, + isOfficialPluginSource, +} from '../utils/plugin-source-label'; +import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; +import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; +import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; interface ShowPluginsPickerOptions { @@ -39,6 +46,8 @@ interface ShowPluginsPickerOptions { readonly id: string; readonly text: string; }; + readonly initialTab?: PluginsPanelTabId; + readonly marketplaceSource?: string; } interface PluginMcpServerHint { @@ -51,11 +60,46 @@ interface ShowPluginMcpPickerOptions { readonly serverHint?: PluginMcpServerHint; } +/** The plugin-management surface `/plugins` operates on. */ +type PluginApi = Pick< + Session, + | 'listPlugins' + | 'installPlugin' + | 'setPluginEnabled' + | 'setPluginMcpServerEnabled' + | 'removePlugin' + | 'reloadPlugins' + | 'getPluginInfo' +>; + +/** + * Resolve the plugin-management API. On the v2 engine plugin state is + * app-global, so a session-less startup still gets a working `/plugins` + * through the harness's global facade; on v1 (and once a session exists) the + * session's own API is used. + */ +async function resolvePluginApi(host: SlashCommandHost): Promise<PluginApi> { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return { + listPlugins: () => host.harness.listPlugins(), + installPlugin: (source) => host.harness.installPlugin(source), + setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled), + setPluginMcpServerEnabled: (id, server, enabled) => + host.harness.setPluginMcpServerEnabled(id, server, enabled), + removePlugin: (id) => host.harness.removePlugin(id), + reloadPlugins: () => host.harness.reloadPlugins(), + getPluginInfo: (id) => host.harness.getPluginInfo(id), + }; +} + export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise<void> { const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0); const sub = args[0]; const rest = args.slice(1); - const session = host.requireSession(); + const session = await resolvePluginApi(host); try { if (sub === undefined) { @@ -72,6 +116,10 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showError('Usage: /plugins install <local-path-or-zip-url>'); return; } + if (!(await confirmInstallTrust(host, source, isOfficialPluginSource(source)))) { + host.showStatus('Install cancelled.'); + return; + } const spinner = host.showProgressSpinner(`Installing plugin from ${truncateForStatus(source)}…`); try { await installPluginFromSource(host, source); @@ -83,12 +131,15 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri return; } if (sub === 'marketplace') { - const source = rest.join(' ').trim(); - if (source.length === 0) { - showPluginMarketplaceSourcePicker(host); - return; - } - await showPluginMarketplacePicker(host, source); + const marketplaceSource = rest.join(' ').trim() || undefined; + await showPluginsPicker(host, { + // Custom marketplaces often omit `tier`, so their entries land on the + // Curated tab (entry.tier !== 'official'). Open there when a custom + // source is supplied; otherwise the default catalog's official entries + // make Official the right landing tab. + initialTab: marketplaceSource === undefined ? 'official' : 'third-party', + marketplaceSource, + }); return; } if (sub === 'info') { @@ -110,7 +161,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } await session.setPluginMcpServerEnabled(id, server, action === 'enable'); host.showStatus( - `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /new to apply.`, + `${action === 'enable' ? 'Enabled' : 'Disabled'} MCP server ${server} for ${id}. Run /reload or /new to apply.`, ); return; } @@ -133,8 +184,7 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri host.showStatus(`Remove cancelled: ${id}.`); return; } - await session.removePlugin(id); - host.showStatus(`Removed ${id} (plugin files left in place).`); + await removePlugin(host, id); return; } if (sub === 'reload') { @@ -152,128 +202,163 @@ export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: stri } } +/** + * Resolve the capability API. Like plugin state, capability state is + * app-global on the v2 engine, so a session-less startup still gets + * readiness and installs through the harness's global facade; with a live + * session the session's own API is used (v1 included, where the capability + * surface then reports itself unavailable). + */ +type CapabilityApi = Pick<Session, 'listCapabilities' | 'getCapability' | 'installCapability'>; + +async function resolveCapabilityApi(host: SlashCommandHost): Promise<CapabilityApi> { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + throw new Error(NO_ACTIVE_SESSION_MESSAGE); + } + return host.harness; +} + +function logCapabilityStatus(capability: CapabilityStatus, installed?: boolean): void { + const payload = { + capabilityId: capability.id, + pluginId: capability.pluginId, + installed, + supported: capability.supported, + state: capability.state, + version: capability.version, + install: capability.install, + steps: capability.steps, + }; + const hasStepIssues = capability.steps.some((step) => step.state !== 'ok'); + if ( + capability.install.error !== undefined || + (installed !== false && hasStepIssues) + ) { + log.warn('capability needs attention', payload); + } else { + log.info('capability status', payload); + } +} + async function showPluginsPicker( host: SlashCommandHost, options?: ShowPluginsPickerOptions, ): Promise<void> { let plugins: readonly PluginSummary[]; try { - plugins = await host.requireSession().listPlugins(); + plugins = await (await resolvePluginApi(host)).listPlugins(); } catch (error) { host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`); return; } - host.mountEditorReplacement( - new PluginsOverviewSelectorComponent({ - plugins, - selectedId: options?.selectedId, - pluginHint: options?.pluginHint, - onSelect: (selection) => { - // Each branch of the handler either mounts the next view or restores - // the editor itself, so do not pre-restore here — that would flash the - // editor for in-place actions like toggling a plugin. - void handlePluginsOverviewSelection(host, selection).catch((error: unknown) => { - host.showError(`/plugins failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); + let capabilities: readonly CapabilityStatus[] = []; + if (host.engineV2) { + try { + capabilities = await (await resolveCapabilityApi(host)).listCapabilities(); + } catch (error) { + log.warn('capability status unavailable', { error }); + } + } + + const installedIds = new Set(plugins.map((plugin) => plugin.id)); + for (const capability of capabilities) { + logCapabilityStatus(capability, installedIds.has(capability.pluginId ?? capability.id)); + } + + const panel = new PluginsPanelComponent({ + installed: plugins, + installedIds, + capabilities, + catalogIsDefault: isDefaultMarketplaceCatalog(options?.marketplaceSource), + initialTab: options?.initialTab, + selectedId: options?.selectedId, + pluginHint: options?.pluginHint, + onSelect: (selection) => { + // Each branch of the handler either mounts the next view or restores the + // editor itself, so do not pre-restore here — that would flash the editor + // for in-place actions like toggling a plugin. + void handlePluginsPanelSelection(host, panel, selection).catch((error: unknown) => { + host.showError(`/plugins failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + // Every tab except Custom needs the catalog: Official/Curated list it, + // and Installed uses it to show update badges. The Installed/Custom tabs + // keep working even when the marketplace is unreachable (badges simply stay + // hidden until data arrives). + onRequestMarketplace: () => { + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); + }, + }); + host.mountEditorReplacement(panel); + // Kick off the catalog fetch for any tab that needs it: Installed uses it for + // update badges, Official/Curated list it. Custom never reads the catalog, + // so skip the fetch there. Done here (after `panel` is initialized) rather + // than inside the component constructor, because the callback above closes + // over `panel`. + if (options?.initialTab !== 'custom') { + panel.setMarketplaceLoading(); + void loadMarketplaceCatalog(host, panel, options?.marketplaceSource, capabilities); + } } -function showPluginMarketplaceSourcePicker(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Plugin marketplace', - options: [ - { - value: PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS, - label: 'Pythinker', - description: 'Official and curated Pythinker plugins.', - }, - { - value: ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS, - label: 'Anthropic', - description: 'Official Claude Code plugin catalog.', - }, - { - value: 'custom', - label: 'Custom marketplace', - description: 'Local path, JSON URL, or GitHub repository.', - }, - ], - onSelect: (value) => { - if (value === 'custom') { - showCustomPluginMarketplaceInput(host); - return; - } - if ( - value === PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS || - value === ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS - ) { - void showPluginMarketplacePicker(host, value); - } - }, - onCancel: () => { - void showPluginsPicker(host); - }, - }), - ); +/** + * Adapt a capability from the engine's registry into a catalog row. The + * engine is the single source of truth for what the built-in capabilities + * are — the CLI only renders them. The `capability:<id>` source marker + * routes installs through the capability flow (never a plain plugin + * install), so the row needs no real URL. + */ +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + description: capability.description, + tier: 'official', + source: `capability:${capability.id}`, + builtIn: true, + }; } -function showCustomPluginMarketplaceInput(host: SlashCommandHost): void { - host.mountEditorReplacement( - new ApiKeyInputDialogComponent( - 'plugin marketplace', - ['Enter a local path, JSON URL, or GitHub owner/repository.'], - (result) => { - if (result.kind === 'cancel') { - showPluginMarketplaceSourcePicker(host); - return; - } - void showPluginMarketplacePicker(host, result.value); - }, - { - title: 'Custom plugin marketplace', - secret: false, - emptyMessage: 'Marketplace source cannot be empty.', - }, - ), - ); +/** + * Injection is part of the DEFAULT catalog experience only: any explicit + * replacement (the slash-command source or a user-set env override) opts out + * wholesale. The dev marketplace server started by scripts/dev.mjs serves + * this repo's own catalog and marks itself, so it still counts as default. + */ +function isDefaultMarketplaceCatalog( + source: string | undefined, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (source !== undefined) return false; + if (env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV] === undefined) return true; + return env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER'] === '1'; } -async function showPluginMarketplacePicker(host: SlashCommandHost, source: string): Promise<void> { - const spinner = host.showProgressSpinner('Loading plugin marketplace…'); +async function loadMarketplaceCatalog( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string | undefined, + capabilities: readonly CapabilityStatus[], +): Promise<void> { try { - const [marketplace, installed] = await Promise.all([ - loadPluginMarketplace({ workDir: host.state.appState.workDir, source }), - host.requireSession().listPlugins(), - ]); - spinner.stop({ ok: true, label: `Loaded ${marketplace.name}.` }); - host.mountEditorReplacement( - new PluginMarketplaceSelectorComponent({ - marketplace, - installed: new Map(installed.map((plugin) => [plugin.id, plugin] as const)), - onSelect: (selection) => { - // Every marketplace action re-mounts a picker, so let the handler do - // the mounting — pre-restoring the editor here would flash. - void handlePluginMarketplaceSelection(host, source, selection).catch((error: unknown) => { - host.showError(`/plugins marketplace failed: ${formatErrorMessage(error)}`); - }); - }, - onCancel: () => { - void showPluginsPicker(host); - }, - }), - ); + const marketplace = await loadPluginMarketplace({ + workDir: host.state.appState.workDir, + source, + builtInEntries: + host.engineV2 && isDefaultMarketplaceCatalog(source) + ? capabilities.map(capabilityMarketplaceEntry) + : undefined, + }); + panel.setMarketplace(marketplace.plugins, marketplace.source); } catch (error) { - spinner.stop({ ok: false, label: 'Failed to load plugin marketplace.' }); - host.showError(`Failed to load plugin marketplace: ${formatErrorMessage(error)}`); - showPluginMarketplaceSourcePicker(host); + panel.setMarketplaceError(formatErrorMessage(error)); } + host.state.ui.requestRender(); } async function showPluginMcpPicker( @@ -283,7 +368,7 @@ async function showPluginMcpPicker( ): Promise<void> { let info: PluginInfo; try { - info = await host.requireSession().getPluginInfo(id); + info = await (await resolvePluginApi(host)).getPluginInfo(id); } catch (error) { host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`); return; @@ -312,7 +397,7 @@ async function showPluginMcpPicker( async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<boolean> { let displayName = id; try { - displayName = (await host.requireSession().getPluginInfo(id)).displayName; + displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName; } catch { // Keep the confirmation available even when plugin details cannot be loaded. } @@ -331,13 +416,213 @@ async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise< }); } +async function confirmInstallTrust( + host: SlashCommandHost, + label: string, + official: boolean, +): Promise<boolean> { + // Pythinker-built official plugins are trusted implicitly; anything else requires + // the user to explicitly opt in via the trust prompt. + if (official) return true; + return new Promise((resolveConfirmed) => { + host.mountEditorReplacement( + new PluginInstallTrustConfirmComponent({ + label, + onDone: (result: PluginInstallTrustConfirmResult) => { + host.restoreEditor(); + resolveConfirmed(result.kind === 'confirm'); + }, + }), + ); + }); +} + +const CAPABILITY_POLL_INTERVAL_MS = 700; +const CAPABILITY_POLL_ATTEMPTS = 260; // ~3 minutes of runtime setup budget + +/** Client-injected v2 entries install their runtime and plugin together. + * Trust keys on the parser-proof `builtIn` flag — the `capability:<id>` + * source string stays purely diagnostic. */ +function isCapabilityEntry(host: SlashCommandHost, entry: PluginMarketplaceEntry): boolean { + return host.engineV2 && entry.builtIn === true; +} + +/** + * Closed-set plugin id check for the post-remove note. What must not happen + * is answering membership by running `listCapabilities()`, which fires every + * entry's detector (seconds of probes) just to print one hint line. + */ +function isCapabilityPluginId(host: SlashCommandHost, id: string): boolean { + return ( + host.engineV2 && + (id === 'pythinker-cu' || id === 'pythinker-cu-win' || id === 'pythinker-webbridge') + ); +} + +/** Poll a background capability install until it settles (or we run out of budget). */ +async function pollCapabilityInstall( + host: SlashCommandHost, + id: string, +): Promise<CapabilityStatus | undefined> { + const api = await resolveCapabilityApi(host); + let previousProgress = ''; + for (let attempt = 0; attempt < CAPABILITY_POLL_ATTEMPTS; attempt += 1) { + await new Promise((resolve) => { + setTimeout(resolve, CAPABILITY_POLL_INTERVAL_MS); + }); + const status = await api.getCapability(id); + if (!status.install.running) return status; + const progress = `${status.install.step ?? ''}:${status.install.percent ?? ''}`; + if (progress !== previousProgress) { + previousProgress = progress; + log.info('capability install progress', { + capabilityId: id, + step: status.install.step, + percent: status.install.percent, + }); + } + } + return undefined; +} + +export const __pluginsCommandInternals = { + isCapabilityEntry, + installCapabilityFromPanel, + isDefaultMarketplaceCatalog, + pollCapabilityInstall, + removePlugin, +}; + +async function installCapabilityFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + entry: PluginMarketplaceEntry, +): Promise<void> { + const label = entry.displayName; + // Capability entries are official by construction; the trust prompt is + // reserved for unreviewed third-party plugins. + panel.setInstalling(truncateForStatus(label)); + host.state.ui.requestRender(); + const api = await resolveCapabilityApi(host); + log.info('capability install requested', { capabilityId: entry.id }); + try { + // An install already running (started from another panel or client) is + // followed, not restarted — the service rejects duplicate starts even + // though the original is healthy. + const alreadyRunning = await api + .getCapability(entry.id) + .then((status) => status.install.running, () => false); + if (!alreadyRunning) { + await api.installCapability(entry.id); + } else { + log.info('following running capability install', { capabilityId: entry.id }); + } + } catch (error) { + log.warn('capability install failed to start', { capabilityId: entry.id, error }); + panel.clearInstalling(); + host.state.ui.requestRender(); + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + host.restoreEditor(); + return; + } + let result: CapabilityStatus | undefined; + try { + result = await pollCapabilityInstall(host, entry.id); + } catch (error) { + log.warn('capability install polling failed', { capabilityId: entry.id, error }); + result = undefined; + } + panel.clearInstalling(); + // Close the panel so the result lines land in the transcript, matching the + // plain plugin install flow. + host.restoreEditor(); + if (result === undefined) { + host.showStatus(`${label} installation is still running in the background.`); + return; + } + logCapabilityStatus(result); + if (result.install.error !== undefined) { + host.showError(`${label} installation failed: ${result.install.error}`); + host.showStatus('Fix the reported error, then install again from /plugins.', 'warning'); + return; + } + if (result.state !== 'ready') { + const permissionsRequired = + entry.id === 'pythinker-cu' && + result.steps.some((step) => step.id === 'permissions' && step.state !== 'ok'); + if (permissionsRequired) { + host.showStatus( + 'Grant Accessibility and Screen Recording in System Settings → Privacy & Security.', + 'warning', + ); + } else { + host.showError( + `${label} installation did not complete. Check the logs and install again from /plugins.`, + ); + } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + return; + } + if (entry.id === 'pythinker-webbridge') { + host.showNotice(`${label} is installed.`); + host.state.transcriptContainer.addChild(new Spacer(1)); + host.state.transcriptContainer.addChild( + new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, createMarkdownOptions()), + ); + host.state.ui.requestRender(); + return; + } + host.showStatus(`${label} is installed.`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); +} + +async function installFromPanel( + host: SlashCommandHost, + panel: PluginsPanelComponent, + source: string, + label: string, + official: boolean, +): Promise<void> { + if (!(await confirmInstallTrust(host, label, official))) { + host.showStatus(`Install cancelled: ${label}.`); + host.restoreEditor(); + return; + } + // Official installs keep the panel mounted and show the inline installing + // state; third-party installs pass through a trust prompt that replaces the + // panel, so fall back to a transcript status for those. + if (official) { + panel.setInstalling(truncateForStatus(label)); + } else { + host.showStatus(`Installing or updating ${label} from marketplace...`); + } + host.state.ui.requestRender(); + try { + await installPluginFromSource(host, source); + } catch (error) { + if (official) { + panel.clearInstalling(); + host.state.ui.requestRender(); + } else { + // The trust prompt replaced the panel; re-mount it so the user can retry + // instead of being dropped back at the editor. + host.mountEditorReplacement(panel); + } + host.showError(`Failed to install ${label}: ${formatErrorMessage(error)}`); + return; + } + // Close the panel after installing so the result status and the + // "/reload or /new" tip are visible in the transcript. + host.restoreEditor(); +} + async function applyPluginEnabled( host: SlashCommandHost, id: string, enabled: boolean, showStatus = true, ): Promise<string> { - const session = host.requireSession(); + const session = await resolvePluginApi(host); await session.setPluginEnabled(id, enabled); let info: PluginInfo | undefined; try { @@ -350,54 +635,75 @@ async function applyPluginEnabled( ? ` Some MCP servers are disabled; re-enable with /plugins mcp enable ${id} <server>.` : ''; if (showStatus) { - host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /new to apply.${mcpHint}`); + host.showStatus(`${enabled ? 'Enabled' : 'Disabled'} ${id}. Run /reload or /new to apply.${mcpHint}`); } const inlineMcpHint = mcpHint.length > 0 ? ' · MCP servers disabled' : ''; return `${pluginInlineChangeHint()}${inlineMcpHint}`; } -async function handlePluginsOverviewSelection( +async function handlePluginsPanelSelection( host: SlashCommandHost, - selection: PluginsOverviewSelection, + panel: PluginsPanelComponent, + selection: PluginsPanelSelection, ): Promise<void> { - const session = host.requireSession(); switch (selection.kind) { - case 'marketplace': - showPluginMarketplaceSourcePicker(host); - return; - case 'reload': - await reloadPlugins(host); - await showPluginsPicker(host); - return; - case 'show-list': - host.restoreEditor(); - await renderPluginsList(host); - return; case 'toggle': { const hint = await applyPluginEnabled(host, selection.id, selection.enabled, false); await showPluginsPicker(host, { + initialTab: 'installed', selectedId: selection.id, pluginHint: { id: selection.id, text: hint }, }); return; } - case 'mcp': - await showPluginMcpPicker(host, selection.id); - return; case 'remove': if (!(await confirmRemovePlugin(host, selection.id))) { host.showStatus(`Remove cancelled: ${selection.id}.`); - await showPluginsPicker(host, { selectedId: selection.id }); + await showPluginsPicker(host, { initialTab: 'installed', selectedId: selection.id }); return; } - await session.removePlugin(selection.id); - host.showStatus(`Removed ${selection.id} (plugin files left in place).`); - await showPluginsPicker(host); + await removePlugin(host, selection.id); + await showPluginsPicker(host, { initialTab: 'installed' }); return; - case 'info': + case 'mcp': + await showPluginMcpPicker(host, selection.id); + return; + case 'details': host.restoreEditor(); await renderPluginInfo(host, selection.id); return; + case 'reload': + await reloadPlugins(host); + await showPluginsPicker(host, { initialTab: 'installed' }); + return; + case 'install': + if (isCapabilityEntry(host, selection.entry)) { + await installCapabilityFromPanel(host, panel, selection.entry); + return; + } + await installFromPanel( + host, + panel, + selection.entry.source, + selection.entry.displayName, + isOfficialPluginSource(selection.entry.source), + ); + return; + case 'install-source': + await installFromPanel( + host, + panel, + selection.source, + selection.source, + isOfficialPluginSource(selection.source), + ); + return; + case 'open-url': + host.restoreEditor(); + openUrl(selection.url); + host.showStatus(`Opening the ${selection.label} page in your browser…`, 'success'); + host.showStatus(`If it did not open, visit ${selection.url}`); + return; } } @@ -407,11 +713,9 @@ async function handlePluginMcpSelection( ): Promise<void> { switch (selection.kind) { case 'toggle': - await host.requireSession().setPluginMcpServerEnabled( - selection.pluginId, - selection.server, - selection.enabled, - ); + await ( + await resolvePluginApi(host) + ).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled); await showPluginMcpPicker(host, selection.pluginId, { selectedServer: selection.server, serverHint: { @@ -426,94 +730,75 @@ async function handlePluginMcpSelection( } } -async function handlePluginMarketplaceSelection( - host: SlashCommandHost, - source: string, - selection: PluginMarketplaceSelection, -): Promise<void> { - if (selection.kind === 'unavailable') { - host.showError(`${selection.entry.displayName} is unavailable: ${selection.reason}`); - return; - } - - const { entry } = selection; - if (entry.install.kind === 'unsupported') { - host.showError(`${entry.displayName} is unavailable: ${entry.install.reason}`); +async function removePlugin(host: SlashCommandHost, id: string): Promise<void> { + await (await resolvePluginApi(host)).removePlugin(id); + host.showStatus(`Removed ${id}.`); + if (isCapabilityPluginId(host, id)) { + host.showStatus( + 'Note: the runtime binaries were left untouched, but Pythinker Code plugin wiring is disabled for new sessions. Restart Pythinker Code before reinstalling from the Official tab.', + ); return; } - - const spinner = host.showProgressSpinner(`Installing or updating ${entry.displayName}…`); - try { - const summary = await installPluginFromSource(host, entry.install.source, { - successNotice: 'marketplace', - installOptions: entry.install.options, - }); - spinner.stop({ ok: true, label: `Installed or updated ${summary.displayName}.` }); - await showPluginsPicker(host, { selectedId: summary.id }); - } catch (error) { - spinner.stop({ ok: false, label: `Install failed: ${formatErrorMessage(error)}` }); - host.showError(`Failed to install ${entry.displayName}: ${formatErrorMessage(error)}`); - await showPluginMarketplacePicker(host, source); - } + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); } async function renderPluginsList( host: SlashCommandHost, plugins?: readonly PluginSummary[], ): Promise<void> { - const currentPlugins = plugins ?? (await host.requireSession().listPlugins()); + const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins()); const title = ` Plugins (${currentPlugins.length}) `; const panel = new UsagePanelComponent( () => buildPluginsListLines({ plugins: currentPlugins }), 'primary', title, ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<void> { - const info = await host.requireSession().getPluginInfo(id); + const info = await (await resolvePluginApi(host)).getPluginInfo(id); const panel = new UsagePanelComponent( () => buildPluginsInfoLines({ info }), 'primary', ` ${info.id} `, ); - host.state.transcriptContainer.addTranscriptChild(panel, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } -interface InstallPluginFromSourceOptions { - readonly successNotice?: 'marketplace'; - readonly installOptions?: PluginInstallOptions; -} - async function installPluginFromSource( host: SlashCommandHost, source: string, - options?: InstallPluginFromSourceOptions, -): Promise<PluginSummary> { - const session = host.requireSession(); +): Promise<void> { + const session = await resolvePluginApi(host); const beforeList = await session.listPlugins(); const summary = await session.installPlugin( resolvePluginInstallSource(source, host.state.appState.workDir), - options?.installOptions, ); - showPluginInstallResult(host, beforeList, summary, options); - return summary; + showPluginInstallResult(host, beforeList, summary); } +const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; + +const WEBBRIDGE_POST_INSTALL_MARKDOWN = [ + '*Two steps left to use Pythinker WebBridge:*', + '1. Install the browser extension', + '', + ' - [Chrome Web Store](https://chromewebstore.google.com/detail/pythinker-webbridge/fldmhceldgbpfpkbgopacenieobmligc)', + ' - [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/pythinker-webbridge/bnlffdbcfnanfbknnlaflhlhkocccckg)', + ' - [Manual installation guide](https://www.kimi.com/code/docs/pythinker-code-cli/customization/plugins.html#install-the-browser-extension)', + '', + '2. Run `/reload` or `/new` to apply it.', +].join('\n'); + +const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], summary: PluginSummary, - options?: InstallPluginFromSourceOptions, ): void { const previous = beforeList.find((entry) => entry.id === summary.id); const serverWord = summary.mcpServerCount === 1 ? 'server' : 'servers'; @@ -522,14 +807,12 @@ function showPluginInstallResult( ? ` Declares ${summary.mcpServerCount} MCP ${serverWord}; enabled by default and configurable from /plugins.` : ''; const action = describeInstallAction(previous, summary); - host.showStatus( - `${action} (${summary.id}).${mcpHint} Run /new to apply plugin changes.`, - ); - if (options?.successNotice === 'marketplace') { - host.showNotice( - `Installed or updated ${summary.displayName}`, - `Marketplace install or update succeeded for ${summary.id}. Run /new to apply plugin changes.`, - ); + host.showStatus(`${action} (${summary.id}).${mcpHint}`); + host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + // Gate on provenance, not just the id: a local/GitHub fork whose manifest + // reuses a billed plugin's id is not the official quota-consuming build. + if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { + host.showStatus(PLUGIN_QUOTA_NOTE, 'warning'); } } @@ -543,13 +826,19 @@ function describeInstallAction( return ` ${prev} → ${cur ?? '-'}`; }; if (previous === undefined) { - return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} from ${sourceLabel}`; + return `Installed ${next.displayName}${versionFromTo(undefined, next.version)} ${sourcePhrase(sourceLabel)}`; } if (sourceIdentity(previous) !== sourceIdentity(next)) { const prevSourceLabel = formatPluginSourceLabel(previous); return `Migrated ${next.displayName}: ${prevSourceLabel} → ${sourceLabel}${versionFromTo(previous.version, next.version)}`; } - return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} from ${sourceLabel}`; + return `Updated ${next.displayName}${versionFromTo(previous.version, next.version)} ${sourcePhrase(sourceLabel)}`; +} + +// formatPluginSourceLabel already prefixes zip-url hosts with "via", so adding +// "from" would read as "from via <host>". Only prepend "from" otherwise. +function sourcePhrase(sourceLabel: string): string { + return sourceLabel.startsWith('via ') ? sourceLabel : `from ${sourceLabel}`; } function sourceIdentity(plugin: PluginSummary): string { @@ -565,10 +854,13 @@ function truncateForStatus(input: string): string { } async function reloadPlugins(host: SlashCommandHost): Promise<void> { - const summary = await host.requireSession().reloadPlugins(); + const summary = await (await resolvePluginApi(host)).reloadPlugins(); const line = `Reload: +${summary.added.length} -${summary.removed.length}` + (summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : ''); host.showStatus(line); + // Rebuild the TUI's plugin slash-command list from the reloaded service so + // newly added/enabled commands resolve in this session-less UI right away. + await host.refreshPluginCommands(host.session); } function resolvePluginInstallSource(source: string, workDir: string): string { @@ -580,5 +872,5 @@ function resolvePluginInstallSource(source: string, workDir: string): string { } function pluginInlineChangeHint(): string { - return 'require run /new to apply'; + return 'run /reload or /new to apply'; } diff --git a/apps/pythinker-code/src/tui/commands/prompts.ts b/apps/pythinker-code/src/tui/commands/prompts.ts index 4927bd5b..e68b699e 100644 --- a/apps/pythinker-code/src/tui/commands/prompts.ts +++ b/apps/pythinker-code/src/tui/commands/prompts.ts @@ -1,61 +1,36 @@ import { catalogModelToAlias, - catalogConnectionWire, - coerceEffortForModel, - DEFAULT_CATALOG_URL, - effortLevelsForModel, - fetchCatalog, - loadBuiltInCatalog, - managedModelToAlias, + resolveCatalogImport, type Catalog, type CatalogModel, type ModelAlias, - type PlatformSelection, + type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; -import type { - PlatformModelInfo, - OpenPlatformDefinition, +import { + capabilitiesForModel, + OPENAI_CODEX_PROVIDER_ID, + type ManagedPythinkerCodeModelInfo, + type OpenAICodexModelInfo, + type OpenPlatformDefinition, } from '@pymodel/pythinker-code-oauth'; -import { ApiKeyInputDialogComponent, type ApiKeyInputResult } from '../components/dialogs/api-key-input-dialog'; +import { + ApiKeyInputDialogComponent, + type ApiKeyInputDialogOptions, + type ApiKeyInputResult, +} from '../components/dialogs/api-key-input-dialog'; import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; import { FeedbackInputDialogComponent, type FeedbackInputDialogResult } from '../components/dialogs/feedback-input-dialog'; import { ModelSelectorComponent } from '../components/dialogs/model-selector'; import { PlatformSelectorComponent } from '../components/dialogs/platform-selector'; -import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; -import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; -export async function promptPlatformSelection( - host: SlashCommandHost, -): Promise<PlatformSelection | undefined> { - let catalog = loadBuiltInCatalog(BUILT_IN_CATALOG_JSON) ?? {}; - const controller = new AbortController(); - const cancel = (): void => { - controller.abort(); - }; - host.cancelInFlight = cancel; - const spinner = host.showLoginProgressSpinner('Loading provider catalog'); - try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); - spinner.stop({ ok: true, label: 'Provider catalog loaded.' }); - } catch (error) { - if (controller.signal.aborted) { - spinner.stop({ ok: false, label: 'Aborted.' }); - return undefined; - } - spinner.stop({ ok: false, label: 'Using bundled provider catalog.' }); - host.showStatus(`Live provider catalog unavailable: ${formatErrorMessage(error)}`, 'warning'); - } finally { - if (host.cancelInFlight === cancel) host.cancelInFlight = undefined; - } - +export function promptPlatformSelection(host: SlashCommandHost): Promise<string | undefined> { return new Promise((resolve) => { const selector = new PlatformSelectorComponent({ - catalog, onSelect: (platformId) => { host.restoreEditor(); - resolve({ platformId, catalog }); + resolve(platformId); }, onCancel: () => { host.restoreEditor(); @@ -89,21 +64,63 @@ export function promptLogoutProviderSelection( }); } -export function promptFeedbackInput(host: SlashCommandHost): Promise<string | undefined> { +export interface FeedbackPromptResult { + readonly value: string; +} + +export function promptFeedbackInput(host: SlashCommandHost): Promise<FeedbackPromptResult | undefined> { return new Promise((resolve) => { const dialog = new FeedbackInputDialogComponent((result: FeedbackInputDialogResult) => { host.restoreEditor(); - resolve(result.kind === 'ok' ? result.value : undefined); + resolve(result.kind === 'ok' ? { value: result.value } : undefined); }); host.mountEditorReplacement(dialog); }); } +export type FeedbackAttachmentLevel = 'none' | 'logs' | 'logs+codebase'; + +const FEEDBACK_ATTACHMENT_OPTIONS: readonly ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs', + label: 'Logs only', + description: 'Upload wire events and diagnostic logs from this session', + }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: + 'Include your codebase for deeper diagnosis. Sensitive files are automatically excluded — e.g. .env, config files, secret keys. We use attachments only for diagnosis and never share them.', + descriptionTone: 'warning', + }, +]; + +export function promptFeedbackAttachment( + host: SlashCommandHost, +): Promise<FeedbackAttachmentLevel | undefined> { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info to help us investigate?', + options: FEEDBACK_ATTACHMENT_OPTIONS, + onSelect: (value) => { + host.restoreEditor(); + resolve(value as FeedbackAttachmentLevel); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + export function promptApiKey( host: SlashCommandHost, platformName: string, subtitleLines: readonly string[] = ['Your key will be saved to ~/.pythinker-code/config.toml'], - options: import('../components/dialogs/api-key-input-dialog').ApiKeyInputDialogOptions = {}, + options: ApiKeyInputDialogOptions = {}, ): Promise<string | undefined> { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( @@ -119,10 +136,35 @@ export function promptApiKey( }); } +/** + * Asks for the provider endpoint the catalog did not declare (or declared + * only as an env placeholder) — required for catalog imports whose protocol + * was guessed, where the built-in default endpoint would point at the wrong + * host. Esc cancels the import. + */ +export function promptBaseUrl(host: SlashCommandHost, platformName: string): Promise<string | undefined> { + return new Promise((resolve) => { + const dialog = new ApiKeyInputDialogComponent( + platformName, + ['The catalog declares no endpoint for this provider — enter its base URL.'], + (result: ApiKeyInputResult) => { + host.restoreEditor(); + resolve(result.kind === 'ok' ? result.value : undefined); + }, + { + title: `Enter base URL for ${platformName}`, + mask: false, + emptyHint: 'Base URL cannot be empty.', + }, + ); + host.mountEditorReplacement(dialog); + }); +} + export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: Catalog): Promise<string | undefined> { return new Promise((resolve) => { const options: ChoiceOption[] = Object.entries(catalog) - .filter(([, entry]) => catalogConnectionWire(entry) !== undefined) + .filter(([, entry]) => resolveCatalogImport(entry).kind !== 'invalid') .map(([id, entry]) => ({ value: id, label: entry.name ?? id, @@ -132,7 +174,7 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: .toSorted((a, b) => a.label.localeCompare(b.label)); if (options.length === 0) { - host.showError('Catalog has no providers that can be configured with one API key.'); + host.showError('Catalog has no providers with supported wire types.'); resolve(undefined); return; } @@ -156,24 +198,57 @@ export function promptCatalogProviderSelection(host: SlashCommandHost, catalog: export async function promptModelSelectionForOpenPlatform( host: SlashCommandHost, - models: PlatformModelInfo[], + models: ManagedPythinkerCodeModelInfo[], platform: OpenPlatformDefinition, -): Promise<{ model: PlatformModelInfo; effort: string } | undefined> { +): Promise<{ model: ManagedPythinkerCodeModelInfo; thinking: ThinkingEffort } | undefined> { const modelDict: Record<string, ModelAlias> = {}; for (const m of models) { - modelDict[`${platform.id}/${m.id}`] = managedModelToAlias(platform.id, m); + modelDict[`${platform.id}/${m.id}`] = { + provider: platform.id, + model: m.id, + maxContextSize: m.contextLength, + capabilities: capabilitiesForModel(m), + displayName: m.displayName, + }; } const selection = await runModelSelector(host, modelDict); if (selection === undefined) return undefined; const model = models.find((m) => `${platform.id}/${m.id}` === selection.alias); - return model ? { model, effort: selection.effort } : undefined; + return model ? { model, thinking: selection.thinking } : undefined; +} + +export async function promptModelSelectionForCodex( + host: SlashCommandHost, + models: OpenAICodexModelInfo[], +): Promise<{ model: OpenAICodexModelInfo; thinking: ThinkingEffort } | undefined> { + const modelDict: Record<string, ModelAlias> = {}; + for (const model of models) { + const capabilities = capabilitiesForModel(model) ?? []; + modelDict[`${OPENAI_CODEX_PROVIDER_ID}/${model.id}`] = { + provider: OPENAI_CODEX_PROVIDER_ID, + model: model.id, + maxContextSize: model.contextLength, + capabilities: model.supportsFastMode === true ? [...capabilities, 'fast_mode'] : capabilities, + supportEfforts: + model.supportedReasoningEfforts === undefined + ? undefined + : [...model.supportedReasoningEfforts], + displayName: model.displayName, + }; + } + const selection = await runModelSelector(host, modelDict); + if (selection === undefined) return undefined; + const model = models.find( + (candidate) => `${OPENAI_CODEX_PROVIDER_ID}/${candidate.id}` === selection.alias, + ); + return model === undefined ? undefined : { model, thinking: selection.thinking }; } export async function promptModelSelectionForCatalog( host: SlashCommandHost, providerId: string, models: CatalogModel[], -): Promise<{ model: CatalogModel; effort: string } | undefined> { +): Promise<{ model: CatalogModel; thinking: ThinkingEffort } | undefined> { const modelDict: Record<string, ModelAlias> = {}; for (const m of models) { modelDict[`${providerId}/${m.id}`] = catalogModelToAlias(providerId, m); @@ -181,28 +256,25 @@ export async function promptModelSelectionForCatalog( const selection = await runModelSelector(host, modelDict); if (selection === undefined) return undefined; const model = models.find((m) => `${providerId}/${m.id}` === selection.alias); - return model ? { model, effort: selection.effort } : undefined; + return model ? { model, thinking: selection.thinking } : undefined; } export function runModelSelector( host: SlashCommandHost, modelDict: Record<string, ModelAlias>, -): Promise<{ alias: string; effort: string } | undefined> { +): Promise<{ alias: string; thinking: ThinkingEffort } | undefined> { return new Promise((resolve) => { const firstAlias = Object.keys(modelDict)[0] ?? ''; - const firstModel = modelDict[firstAlias]; - const initialEffort = coerceEffortForModel( - firstModel, - effortLevelsForModel(firstModel).find((level) => level !== 'off') ?? 'off', - ); + const caps = modelDict[firstAlias]?.capabilities ?? []; + const initialThinking = caps.includes('always_thinking') || caps.includes('thinking'); const selector = new ModelSelectorComponent({ models: modelDict, currentValue: firstAlias, - currentEffort: initialEffort, + currentThinkingEffort: initialThinking ? 'on' : 'off', searchable: true, - onSelect: ({ alias, effort }) => { + onSelect: ({ alias, thinking }) => { host.restoreEditor(); - resolve({ alias, effort }); + resolve({ alias, thinking }); }, onCancel: () => { host.restoreEditor(); diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index 7f106af2..48028831 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -2,15 +2,22 @@ import { applyCustomRegistryEntries, fetchCustomRegistry, type CustomRegistrySource, - type PlatformConfigShape, + type ManagedPythinkerConfigShape, } from '@pymodel/pythinker-code-oauth'; import { + applyCatalogProvider, + cascadeSubagentModelPool, + catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, - fetchCatalog, + resolveCatalogImport, + SECONDARY_DERIVED_MODEL_ALIAS, type Catalog, + type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; +import { createPythinkerCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { CustomRegistryImportDialogComponent, @@ -21,9 +28,15 @@ import { type ProviderManagerOptions, } from '../components/dialogs/provider-manager'; import { TabbedModelSelectorComponent } from '../components/dialogs/tabbed-model-selector'; +import { DEFAULT_OAUTH_PROVIDER_NAME } from '../constant/pythinker-tui'; import { formatErrorMessage } from '../utils/event-payload'; -import { connectCatalogProvider } from './auth'; -import { promptCatalogProviderSelection } from './prompts'; +import { thinkingEffortToConfig } from '../utils/thinking-config'; +import { effectiveModelForHost } from './config'; +import { + promptApiKey, + promptBaseUrl, + promptCatalogProviderSelection, +} from './prompts'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -74,6 +87,13 @@ async function handleProviderManagerDeleteSource( } async function handleProviderDelete(host: SlashCommandHost, providerId: string): Promise<void> { + if (providerId === DEFAULT_OAUTH_PROVIDER_NAME) { + await host.harness.auth.logout(DEFAULT_OAUTH_PROVIDER_NAME); + await host.authFlow.refreshConfigAfterLogout(); + await host.authFlow.clearActiveSessionAfterLogout(); + return; + } + const activeProvider = host.state.appState.availableModels[host.state.appState.model]?.provider; const config = await host.harness.removeProvider(providerId); @@ -144,8 +164,17 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> { const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); let catalog: Catalog | undefined; try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); + const loaded = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + signal: controller.signal, + userAgent: createPythinkerCodeUserAgent(), + }); + catalog = loaded.catalog; + spinner.stop({ + ok: true, + label: loaded.fromBuiltIn + ? 'Catalog loaded from built-in snapshot (models.dev unreachable).' + : 'Catalog loaded.', + }); } catch (error) { if (controller.signal.aborted) { spinner.stop({ ok: false, label: 'Aborted.' }); @@ -164,18 +193,133 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> { if (providerId === undefined) return; const entry = catalog[providerId]; if (entry === undefined) return; - await connectCatalogProvider(host, providerId, entry); + + const models = catalogProviderModels(entry); + if (models.length === 0) { + host.showError(`Provider "${providerId}" has no usable models in this catalog.`); + return; + } + + let resolution = resolveCatalogImport(entry); + if (resolution.kind === 'needs-base-url') { + const entered = await promptBaseUrl(host, entry.name ?? providerId); + if (entered === undefined) return; + resolution = resolveCatalogImport(entry, entered); + } + if (resolution.kind !== 'ok') { + if (resolution.kind === 'invalid') { + if (resolution.reason === 'unknown-explicit-type') { + host.showError( + `Provider "${providerId}" declares protocol "${entry.type}" in the catalog, which this client version does not support.`, + ); + } else if (resolution.reason === 'proprietary-sdk') { + host.showError( + `Provider "${providerId}" uses a proprietary SDK this client cannot speak (e.g. Amazon Bedrock or Cohere); it cannot be imported from the catalog.`, + ); + } else { + host.showError( + `Base URL contains an env placeholder or is empty. Enter the resolved URL instead.`, + ); + } + } + return; + } + const { wire, baseUrl } = resolution; + + const apiKey = await promptApiKey(host, entry.name ?? providerId); + if (apiKey === undefined) return; + + // Persist the provider and all its models immediately after the api key is + // entered. The model selector that follows is just a convenience to pick the + // default model; ESC leaves the provider in place without a default selection. + const existingConfig = await host.harness.getConfig(); + const poolSnapshot = + existingConfig.providers[providerId] !== undefined + ? existingConfig.secondaryModel + : undefined; + if (existingConfig.providers[providerId] !== undefined) { + await host.harness.removeProvider(providerId); + } + + const config = await host.harness.getConfig(); + applyCatalogProvider(config, { + providerId, + wire, + baseUrl, + apiKey, + models, + selectedModelId: '', // no default yet; user picks in the model selector + thinking: false, // will be resolved by the model selector + }); + + await host.harness.setConfig({ + providers: config.providers, + models: config.models, + }); + + // removeProvider cascaded the subagent pool against a model table where + // every `${providerId}/...` alias was absent; restore the entries that + // survived the re-add (aliases the catalog genuinely dropped stay dropped). + if (poolSnapshot !== undefined) { + const restored = cascadeSubagentModelPool(poolSnapshot, config.models ?? {}); + if (restored !== null) { + await host.harness.setConfig({ secondaryModel: restored ?? poolSnapshot }); + } + } + + await host.authFlow.refreshConfigAfterLogin(); + host.track('connect', { provider: providerId, method: 'catalog' }); + host.showStatus(`Provider added: ${entry.name ?? providerId}`); + if (resolution.guessed) { + host.showStatus( + `Protocol guessed as "openai" for ${providerId} — edit "type" in config.toml if requests fail.`, + ); + } + + // Build a merged model dictionary that includes existing models plus the + // newly-persisted provider's models, so the tabbed selector shows every + // provider's tab (the new provider's tab starts active via initialTabId). + // The v1 runtime may carry the synthesized `__secondary__` derived entry — + // never selectable in a picker. + const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + const mergedModels = { ...stateModels }; + delete mergedModels[SECONDARY_DERIVED_MODEL_ALIAS]; + + const selector = new TabbedModelSelectorComponent({ + models: mergedModels, + currentValue: host.state.appState.model, + selectedValue: Object.keys(mergedModels).find((a) => a.startsWith(`${providerId}/`)), + currentThinkingEffort: host.state.appState.thinkingEffort, + initialTabId: providerId, + onSelect: ({ alias, thinking }) => { + host.restoreEditor(); + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { + host.showError(`Set default model failed: ${formatErrorMessage(error)}`); + }); + }, + onCancel: () => { + host.restoreEditor(); + }, + }); + host.mountEditorReplacement(selector); } async function setDefaultModel( host: SlashCommandHost, alias: string, - effort: string, + effort: ThinkingEffort, ): Promise<void> { + // Resolve efforts the same way the /model path does (effectiveModelForHost + // applies overrides and the protocol-profile inference): catalog entries for + // e.g. Anthropic models declare no support_efforts on the alias, and without + // the inference a top-tier pick would slip through as a persisted effort. + const model = host.state.appState.availableModels[alias]; await host.harness.setConfig({ defaultModel: alias, - defaultThinking: effort !== 'off', - thinking: { effort }, + thinking: thinkingEffortToConfig( + effort, + model === undefined ? undefined : effectiveModelForHost(host, model).supportEfforts, + ), }); await host.authFlow.refreshConfigAfterLogin(); host.track('model_switch', { model: alias }); @@ -194,7 +338,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; try { - entries = await fetchCustomRegistry(source); + entries = await fetchCustomRegistry(source, { userAgent: createPythinkerCodeUserAgent() }); } catch (error) { host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; @@ -204,7 +348,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise try { const config = await host.harness.getConfig(); applyCustomRegistryEntries( - config as unknown as PlatformConfigShape, + config as unknown as ManagedPythinkerConfigShape, entries, source, ); @@ -231,8 +375,10 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise ); // Offer the model selector so the user can pick a default, just like the - // catalog (known-provider) flow. - const stateModels = await host.harness.getConfig().then((c) => c.models ?? {}); + // catalog (known-provider) flow. Copy without the v1-synthesized + // `__secondary__` derived entry — never selectable in a picker. + const stateModels = { ...(await host.harness.getConfig().then((c) => c.models ?? {})) }; + delete stateModels[SECONDARY_DERIVED_MODEL_ALIAS]; const firstNewAlias = Object.keys(stateModels).find((a) => addedProviderIds.some((pid) => a.startsWith(`${pid}/`)), ); @@ -243,11 +389,11 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise models: stateModels, currentValue: host.state.appState.model, selectedValue: firstNewAlias, - currentEffort: host.state.appState.thinkingLevel, + currentThinkingEffort: host.state.appState.thinkingEffort, initialTabId: firstNewProvider, - onSelect: ({ alias, effort }) => { + onSelect: ({ alias, thinking }) => { host.restoreEditor(); - void setDefaultModel(host, alias, effort).catch((error: unknown) => { + void setDefaultModel(host, alias, thinking).catch((error: unknown) => { host.showError(`Set default model failed: ${formatErrorMessage(error)}`); }); }, diff --git a/apps/pythinker-code/src/tui/commands/registry.ts b/apps/pythinker-code/src/tui/commands/registry.ts index fd7f0c8f..e819c780 100644 --- a/apps/pythinker-code/src/tui/commands/registry.ts +++ b/apps/pythinker-code/src/tui/commands/registry.ts @@ -1,4 +1,8 @@ -import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, relative, resolve } from 'pathe'; + +import type { AutocompleteItem } from '@pymodel/pi-tui'; import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { PythinkerSlashCommand, SlashCommandAvailability } from './types'; @@ -18,45 +22,12 @@ const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ ]; const DYNAMIC_WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'on', description: 'Turn Dynamic Workflow mode on' }, - { value: 'off', description: 'Turn Dynamic Workflow mode off' }, - { value: 'model', description: 'Set the model Dynamic Workflow subagents run on' }, - { value: 'save', description: 'Save the last Dynamic Workflow as a reusable command' }, -]; - -const FAST_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'on', description: 'Turn Fast mode on' }, - { value: 'off', description: 'Turn Fast mode off' }, - { value: 'status', description: 'Show Fast mode status' }, -]; -const ADVISOR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'status', description: 'Show advisor status' }, - { value: 'on', description: 'Enable the advisor' }, - { value: 'off', description: 'Disable the advisor' }, - { value: 'toggle', description: 'Toggle the advisor' }, - { value: 'reload', description: 'Reload WATCHDOG configuration' }, -]; - -const COLORS_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'on', description: 'Keep rainbow colors on' }, - { value: 'off', description: 'Turn rainbow colors off' }, + { value: 'on', description: 'Turn dynamic_workflow mode on' }, + { value: 'off', description: 'Turn dynamic_workflow mode off' }, ]; -const PLUGIN_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'list', description: 'List installed plugins' }, - { value: 'install', description: 'Install a plugin from a path or ZIP URL' }, - { value: 'marketplace', description: 'Browse the plugin marketplace' }, - { value: 'info', description: 'Show details for one plugin' }, - { value: 'enable', description: 'Enable a plugin' }, - { value: 'disable', description: 'Disable a plugin' }, - { value: 'remove', description: 'Remove a plugin from the session' }, - { value: 'reload', description: 'Reload plugins in the current session' }, - { value: 'mcp', description: 'Manage plugin-declared MCP servers' }, -]; - -const PLUGIN_MCP_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'enable', description: 'Enable one plugin MCP server' }, - { value: 'disable', description: 'Disable one plugin MCP server' }, +const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'list', description: 'Show configured additional workspace directories' }, ]; /** Argument autocompletion for the `/goal` command (subcommands). */ @@ -73,60 +44,107 @@ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteIte return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); } -/** Argument autocompletion for the `/workflow` command (subcommands). */ +/** Argument autocompletion for the `/dynamic_workflow` command (subcommands). */ export function dynamicWorkflowArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { return completeLeadingArg(DYNAMIC_WORKFLOW_ARG_COMPLETIONS, argumentPrefix); } -/** Argument autocompletion for the `/fast` command. */ -export function fastArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { - return completeLeadingArg(FAST_ARG_COMPLETIONS, argumentPrefix); +/** Argument autocompletion for the `/add-dir` command. */ +export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (isPathLikeAddDirArgument(argumentPrefix)) { + return completeAddDirPath(argumentPrefix); + } + return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix); +} + +function isPathLikeAddDirArgument(argumentPrefix: string): boolean { + return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~'); +} + +function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null { + const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix; + const expandedPrefix = expandHomePrefix(normalizedPrefix); + const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix); + const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix); + const parentDir = resolveDirectoryCompletionParent(parentInput); + let entries; + try { + entries = readdirSync(parentDir, { withFileTypes: true }); + } catch { + return null; + } + + const items: AutocompleteItem[] = []; + for (const entry of entries) { + if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue; + if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue; + const absolutePath = join(parentDir, entry.name); + if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue; + const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name); + items.push({ + value, + label: `${entry.name}/`, + description: absolutePath, + }); + } + + return items.length > 0 ? items : null; +} + +function expandHomePrefix(argumentPrefix: string): string { + if (argumentPrefix === '~') return homedir(); + if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2)); + return argumentPrefix; } -/** Argument autocompletion for the `/advisor` command. */ -export function advisorArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { - return completeLeadingArg(ADVISOR_ARG_COMPLETIONS, argumentPrefix); + +function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string { + if (argumentPrefix === '/') return '/'; + if (argumentPrefix === '~/') return homedir(); + if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1); + return dirname(expandedPrefix); } -/** Argument autocompletion for the `/colors` command. */ -export function colorsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { - return completeLeadingArg(COLORS_ARG_COMPLETIONS, argumentPrefix); +function resolveDirectoryCompletionParent(parentInput: string): string { + if (parentInput === '~') return homedir(); + if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2)); + return resolve(parentInput); } -/** Argument autocompletion for the `/plugins` command (subcommands). */ -export function pluginsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { - const mcpMatch = argumentPrefix.match(/^mcp\s+(\S*)$/i); - if (mcpMatch !== null) { - return ( - completeLeadingArg(PLUGIN_MCP_ARG_COMPLETIONS, mcpMatch[1] ?? '')?.map((item) => ({ - ...item, - value: `mcp ${item.value}`, - })) ?? null - ); +function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean { + if (isDirectory) return true; + if (!isSymlink) return false; + try { + return statSync(path).isDirectory(); + } catch { + return false; } - return completeLeadingArg(PLUGIN_ARG_COMPLETIONS, argumentPrefix); +} + +function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string { + if (argumentPrefix.startsWith('~/')) { + const home = homedir(); + const homeRelative = relative(home, parentInput); + return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`; + } + if (argumentPrefix.startsWith('/')) { + return `${join(parentInput, entryName)}/`; + } + return `${join(parentInput, entryName)}/`; } export const BUILTIN_SLASH_COMMANDS = [ - { - name: 'colors', - aliases: [], - description: 'Animate or toggle rainbow colors', - priority: 100, - completeArgs: colorsArgumentCompletions, - availability: 'always', - }, { name: 'yolo', aliases: ['yes'], description: 'Toggle YOLO mode: auto-approve tool actions, but the agent may still ask questions.', - priority: 100, + priority: 101, availability: 'always', }, { name: 'auto', aliases: [], description: 'Toggle Auto mode: fully autonomous, agent decides everything without asking.', - priority: 100, + priority: 99, availability: 'always', }, { @@ -136,13 +154,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, - { - name: 'permissions', - aliases: ['allowed-tools'], - description: 'Manage allow, ask, and deny permission rules', - priority: 100, - availability: 'idle-only', - }, { name: 'settings', aliases: ['config'], @@ -150,13 +161,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 100, availability: 'always', }, - { - name: 'privacy-settings', - aliases: [], - description: 'View or update telemetry privacy settings', - priority: 100, - availability: 'always', - }, { name: 'plan', aliases: [], @@ -165,45 +169,35 @@ export const BUILTIN_SLASH_COMMANDS = [ availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'), }, { - name: 'workflow', + name: 'dynamic_workflow', aliases: [], - description: 'Toggle Dynamic Workflow, set its subagent model, or run a task in parallel', + description: 'Toggle dynamic_workflow mode or run one task in dynamic_workflow mode', priority: 100, + argumentHint: '[on|off] | <task>', completeArgs: dynamicWorkflowArgumentCompletions, availability: 'idle-only', }, { name: 'model', aliases: [], - description: 'Switch model; assign with /model <role>, clear it, or list /model roles', + description: 'Switch LLM model', priority: 100, availability: 'always', }, { - name: 'effort', - aliases: [], - description: 'Set thinking effort for the current model', - priority: 100, + name: 'secondary-model', + aliases: ['subagent-model'], + description: 'Configure the secondary model for subagents', + priority: 90, availability: 'always', + experimentalFlag: 'secondary-model', }, { - name: 'fast', - aliases: [], - description: 'Toggle provider-native Fast mode', - priority: 100, - completeArgs: fastArgumentCompletions, - availability: (args) => args.trim().toLowerCase() === 'status' ? 'always' : 'idle-only', - }, - { - name: 'advisor', - aliases: [], - description: 'Show or control the second-opinion advisor', + name: 'effort', + aliases: ['thinking'], + description: 'Switch thinking effort', priority: 95, - completeArgs: advisorArgumentCompletions, - availability: (args) => { - const verb = args.trim().toLowerCase(); - return verb === '' || verb === 'status' ? 'always' : 'idle-only'; - }, + availability: 'always', }, { name: 'provider', @@ -228,19 +222,19 @@ export const BUILTIN_SLASH_COMMANDS = [ }, { name: 'new', - aliases: ['clear', 'reset'], + aliases: ['clear'], description: 'Start a fresh session in the current workspace', priority: 80, }, { name: 'sessions', - aliases: ['resume', 'continue'], + aliases: ['resume'], description: 'Browse and resume sessions', priority: 80, }, { name: 'tasks', - aliases: ['task', 'bashes'], + aliases: ['task'], description: 'Browse background tasks', priority: 80, availability: 'always', @@ -252,76 +246,21 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'files', - aliases: [], - description: 'List files currently loaded in context', - priority: 60, - availability: 'always', - }, - { - name: 'hooks', - aliases: [], - description: 'View configured hooks', - priority: 60, - availability: 'always', - }, - { - name: 'doctor', - aliases: [], - description: 'Check configuration and keybindings', - priority: 60, - availability: 'always', - }, - { - name: 'update', - aliases: ['upgrade'], - description: 'Update Pythinker Code to the latest version', - priority: 60, - availability: 'always', - }, - { - name: 'debug', - aliases: [], - description: 'Analyze the current session diagnostic log', - priority: 60, - }, - { - name: 'heapdump', - aliases: [], - description: 'Dump the JavaScript heap to ~/Desktop', - priority: 60, - availability: 'always', - hidden: true, - }, { name: 'plugins', - aliases: ['plugin'], + aliases: [], description: 'Manage plugins', priority: 60, - completeArgs: pluginsArgumentCompletions, availability: 'always', }, { - name: 'reload-plugins', + name: 'add-dir', aliases: [], - description: 'Activate plugin changes in the current session', + description: 'Add or list an additional workspace directory', priority: 60, availability: 'idle-only', - }, - { - name: 'skills', - aliases: [], - description: 'List available skills', - priority: 60, - availability: 'always', - }, - { - name: 'agents', - aliases: [], - description: 'Browse resolved agent profiles', - priority: 60, - availability: 'always', + argumentHint: '[list] | <path>', + completeArgs: addDirArgumentCompletions, }, { name: 'experiments', @@ -344,72 +283,19 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'release-notes', - aliases: [], - description: 'View release notes', - priority: 60, - availability: 'always', - }, - { - name: 'review', - aliases: [], - description: 'Review a pull request', - priority: 60, - }, - { - name: 'security-review', - aliases: [], - description: 'Review branch changes for security vulnerabilities', - priority: 60, - }, - { - name: 'pr-comments', - aliases: [], - description: 'Show GitHub pull request comments', - priority: 60, - }, - { - name: 'commit', - aliases: [], - description: 'Create a git commit', - priority: 60, - }, - { - name: 'commit-push-pr', - aliases: [], - description: 'Commit, push, and open a pull request', - priority: 60, - }, { name: 'compact', aliases: [], description: 'Compact the conversation context', priority: 80, - }, - { - name: 'copy', - aliases: [], - description: 'Copy a recent assistant response or code block', - priority: 80, - availability: 'always', - }, - { - name: 'add-dir', - aliases: [], - description: 'Add another working directory', - priority: 80, - availability: 'idle-only', + argumentHint: '<instruction>', }, { name: 'goal', aliases: [], description: 'Start or manage an autonomous goal', priority: 80, - // No argumentHint: the menu description stays as short as every other - // command's. The subcommands (status/pause/resume/cancel/replace) surface in - // the argument autocomplete list once the user types `/goal ` (see - // completeArgs), so they don't need to be spelled out inline. + argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>', completeArgs: goalArgumentCompletions, // status / pause / cancel are always available; creation, replacement, and // resume start (or restart) a turn and so are idle-only. @@ -426,16 +312,10 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Analyze the codebase and generate AGENTS.md', }, - { - name: 'init-verifiers', - aliases: [], - description: 'Create functional verifier skills for this project', - priority: 60, - }, { name: 'fork', - aliases: ['branch'], - description: 'Fork the current session', + aliases: [], + description: 'Fork the current session into a copy without switching to it', priority: 80, }, { @@ -443,6 +323,7 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: ['rename'], description: 'Set or show session title', priority: 60, + argumentHint: '<title>', availability: 'always', }, { @@ -452,34 +333,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'cost', - aliases: [], - description: 'Show session spend and current model token rates', - priority: 60, - availability: 'always', - }, - { - name: 'context', - aliases: [], - description: 'Show what is using the model context window', - priority: 60, - availability: 'always', - }, - { - name: 'memory', - aliases: [], - description: 'Edit user or project memory', - priority: 60, - availability: 'always', - }, - { - name: 'diff', - aliases: [], - description: 'Inspect uncommitted working-tree changes', - priority: 60, - availability: 'always', - }, { name: 'status', aliases: [], @@ -487,13 +340,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'tag', - aliases: [], - description: 'Toggle a searchable tag on the current session', - priority: 60, - availability: 'always', - }, { name: 'feedback', aliases: ['bug'], @@ -503,7 +349,7 @@ export const BUILTIN_SLASH_COMMANDS = [ }, { name: 'undo', - aliases: ['rewind'], + aliases: [], description: 'Withdraw the last prompt from the transcript', priority: 80, availability: 'idle-only', @@ -515,20 +361,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'keybindings', - aliases: [], - description: 'Open or create the keybindings configuration', - priority: 60, - availability: 'always', - }, - { - name: 'terminal-setup', - aliases: [], - description: 'Check multiline input support', - priority: 60, - availability: 'always', - }, { name: 'theme', aliases: [], @@ -536,20 +368,6 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, - { - name: 'vim', - aliases: [], - description: 'Toggle between Vim and normal editing modes', - priority: 60, - availability: 'always', - }, - { - name: 'output-style', - aliases: ['outputstyle'], - description: 'Set the response output style', - priority: 60, - availability: 'always', - }, { name: 'logout', aliases: ['disconnect'], @@ -558,7 +376,7 @@ export const BUILTIN_SLASH_COMMANDS = [ }, { name: 'login', - aliases: ['connect'], + aliases: [], description: 'Select a platform and authenticate', priority: 40, }, @@ -574,10 +392,16 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'copy', + aliases: [], + description: 'Copy the last assistant message to the clipboard', + priority: 40, + }, { name: 'web', aliases: [], - description: 'Open the current session in the Web UI and exit the terminal', + description: 'Open the current session in the Web UI by starting a new server', priority: 40, availability: 'always', }, diff --git a/apps/pythinker-code/src/tui/commands/reload.ts b/apps/pythinker-code/src/tui/commands/reload.ts index e51e1500..5760b974 100644 --- a/apps/pythinker-code/src/tui/commands/reload.ts +++ b/apps/pythinker-code/src/tui/commands/reload.ts @@ -2,36 +2,49 @@ import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; +import { setMarkdownRenderLatex } from '../utils/markdown-options'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> { - const tuiConfig = await loadTuiConfig(); - await applyReloadedTuiConfig(host, tuiConfig); - const warnings = host.reloadKeybindings?.() ?? []; - host.showStatus( - warnings.length === 0 ? 'TUI config reloaded.' : warnings.join(' '), - warnings.length === 0 ? 'success' : 'warning', + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), ); + await applyReloadedTuiConfig(host, tuiConfig); + host.showStatus('TUI config reloaded.', 'success'); } export async function handleReloadCommand(host: SlashCommandHost): Promise<void> { - const tuiConfig = await loadTuiConfig(); + const tuiConfig = await loadTuiConfig(undefined, (message) => + host.showStatus(message, 'warning'), + ); const session = host.session; if (session !== undefined) { - await session.reloadSession(); + await session.reloadSession({ forcePluginSessionStartReminder: true }); await host.reloadCurrentSessionView(session, 'Session reloaded.'); } const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); - await host.refreshSkillCommands(session); + const sessionlessV2 = session === undefined && host.engineV2; + if (sessionlessV2) { + // Session-less v2: rebuild the workspace-level dynamic commands too, so + // skill/plugin changes apply before the first session exists. + await host.refreshSkillCommands(); + await host.refreshPluginCommands(); + } + host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); await applyReloadedTuiConfig(host, tuiConfig); - host.reloadKeybindings?.(); if (session === undefined) { + // Still session-less on the v2 engine: refresh the lazy defaults too, so + // defaults edited externally (config.toml, a newly added default model) + // reach the first lazy-created session instead of staying stale. + if (sessionlessV2) { + await host.hydrateLazyConfigDefaults(); + } host.showStatus( 'Runtime and TUI config reloaded; no active session.', 'success', @@ -43,6 +56,10 @@ export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, ): Promise<void> { + // Set the LaTeX toggle before applyTheme: theme application invalidates the + // transcript components, which rebuild their Markdown children and copy the + // options at construction — so the new value must be live by then. + setMarkdownRenderLatex(config.renderLatex ?? true); const resolved = config.theme === 'auto' ? (currentTheme.palette === lightColors ? 'light' : 'dark') : undefined; @@ -50,11 +67,14 @@ export async function applyReloadedTuiConfig( host.refreshTerminalThemeTracking(); host.setAppState({ editorCommand: config.editorCommand, + disablePasteBurst: config.disablePasteBurst, + renderLatex: config.renderLatex, + cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, }); - host.state.copyFullResponse = config.copyFullResponse; + host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } function applyRuntimeConfig(host: SlashCommandHost, config: PythinkerConfig): void { diff --git a/apps/pythinker-code/src/tui/commands/resolve.ts b/apps/pythinker-code/src/tui/commands/resolve.ts index 2e3a2f24..5156a58c 100644 --- a/apps/pythinker-code/src/tui/commands/resolve.ts +++ b/apps/pythinker-code/src/tui/commands/resolve.ts @@ -6,6 +6,7 @@ import { } from './registry'; import { isExperimentalFlagEnabled } from './experimental-flags'; import { parseSlashInput } from './parse'; +import type { TUIState } from '../tui-state'; import type { PythinkerSlashCommand, SlashCommandBusyReason, @@ -26,6 +27,12 @@ export type SlashCommandIntent = readonly skillName: string; readonly args: string; } + | { + readonly kind: 'plugin-command'; + readonly commandName: string; + readonly pluginId: string; + readonly args: string; + } | { readonly kind: 'message'; readonly input: string } | { readonly kind: 'blocked'; @@ -41,6 +48,7 @@ export type SlashCommandIntent = export interface ResolveSlashCommandInput { readonly input: string; readonly skillCommandMap: ReadonlyMap<string, string>; + readonly pluginCommandMap: ReadonlyMap<string, string>; readonly isStreaming: boolean; readonly isCompacting: boolean; } @@ -76,6 +84,19 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name); if (skillName !== undefined) { + // Skill activations are never blocked by a busy session: the TUI queues + // them behind the running turn exactly like normal messages (see + // sendSkillActivation), and Ctrl-S steers them as real activations, so + // commands like /tower can be issued any time. + return { + kind: 'skill', + commandName: parsed.name, + skillName, + args: parsed.args.trim(), + }; + } + + if (options.pluginCommandMap.has(parsed.name)) { const busyReason = slashCommandBusyReason(options); if (busyReason !== undefined) { return { @@ -84,10 +105,13 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla reason: busyReason, }; } + const separator = parsed.name.indexOf(':'); + const pluginId = separator === -1 ? parsed.name : parsed.name.slice(0, separator); + const commandName = separator === -1 ? '' : parsed.name.slice(separator + 1); return { - kind: 'skill', - commandName: parsed.name, - skillName, + kind: 'plugin-command', + commandName, + pluginId, args: parsed.args.trim(), }; } @@ -122,3 +146,12 @@ export function slashBusyMessage( } return `Cannot /${commandName} while compacting — wait for compaction to finish first.`; } + +/** + * Whether a delayed input restore is still safe: the editor must be empty + * (no newer draft) and still mounted (no editor-replacement panel opened + * meanwhile). Restores that run synchronously with submit do not need this. + */ +export function canRestoreSubmittedInput(host: { state: TUIState }): boolean { + return host.state.editor.getText().length === 0 && !host.state.editorReplacementMounted; +} diff --git a/apps/pythinker-code/src/tui/commands/session.ts b/apps/pythinker-code/src/tui/commands/session.ts index 832452d8..b5b58634 100644 --- a/apps/pythinker-code/src/tui/commands/session.ts +++ b/apps/pythinker-code/src/tui/commands/session.ts @@ -5,7 +5,9 @@ import { pathToFileURL } from 'node:url'; import type { Session } from '@pymodel/pythinker-code-sdk'; import { detectInstallSource } from '#/cli/update/source'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { detectShellEnvironment } from '#/utils/process/shell-env'; +import { quoteShellArg } from '#/utils/shell-quote'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import { isAbortError } from '../utils/errors'; @@ -29,10 +31,16 @@ export async function handleTitleCommand(host: SlashCommandHost, args: string): return; } - const session = host.session; + let session = host.session; if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // Setting a title needs a live session; lazy-create it on first use (the + // bare read-only form above works session-less). + session = await host.ensureSession(); + if (session === undefined) return; } const newTitle = title.slice(0, 200); @@ -55,27 +63,56 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } const sourceTitle = forkSourceTitle(host, session); - let forked: Session; try { - forked = await host.harness.forkSession({ + const forked = await host.harness.forkSession({ id: session.id, title: `Fork: ${sourceTitle}`, }); + const forkId = forked.id; + try { + await forked.close(); + } catch (error) { + const msg = formatErrorMessage(error); + host.showError(`Session forked (${forkId}), but failed to release its runtime: ${msg}`); + return; + } + // Stay in the source session: switching to the fork would close the + // source, killing its in-flight turn and background tasks. The fork is + // an independent copy the user can switch to explicitly via /sessions, + // or enter from a new CLI process with the printed resume command. + const command = forkResumeCommand(host.state.appState.workDir, forkId); + let clipboardNote: string; + try { + const method = await copyTextToClipboard(command); + // OSC 52 delivery is fire-and-forget: terminals without OSC 52 support + // silently drop the sequence, so only native delivery may claim success + // (same wording convention as /copy). + clipboardNote = + method === 'native' + ? 'Command copied to clipboard' + : 'Command copied via terminal escape sequence (unverified)'; + } catch { + clipboardNote = 'Failed to copy command to clipboard'; + } + host.showStatus( + `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.\n` + + ` To enter the fork in a new process, run: ${command}\n` + + ` ${clipboardNote}`, + ); } catch (error) { const msg = formatErrorMessage(error); host.showError(`Failed to fork session: ${msg}`); - return; } +} - try { - await host.switchToSession( - forked, - `Session forked (${forked.id}). To return to the original session: pythinker -r ${session.id}`, - ); - } catch (error) { - const msg = formatErrorMessage(error); - host.showError(`Failed to switch to forked session: ${msg}`); - } +function forkResumeCommand(workDir: string, forkId: string): string { + const dir = quoteShellArg(workDir); + // cmd.exe's `cd` only updates the given drive's remembered directory — a + // terminal on a different drive stays put, and the resume then runs in the + // wrong working directory. `pushd` switches drive + directory in both + // cmd.exe and PowerShell (`cd /d` would break PowerShell). + const changeDir = process.platform === 'win32' ? `pushd ${dir}` : `cd ${dir}`; + return `${changeDir} && pythinker --resume ${quoteShellArg(forkId)}`; } function forkSourceTitle(host: SlashCommandHost, session: Session): string { diff --git a/apps/pythinker-code/src/tui/commands/skills.ts b/apps/pythinker-code/src/tui/commands/skills.ts index 085ac049..b45d444e 100644 --- a/apps/pythinker-code/src/tui/commands/skills.ts +++ b/apps/pythinker-code/src/tui/commands/skills.ts @@ -1,42 +1,48 @@ -import { - buildSkillSlashCommands, - isUserActivatableSkill, - type Session, -} from '@pymodel/pythinker-code-sdk'; +import type { Session, SkillSummary } from '@pymodel/pythinker-code-sdk'; -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; -import type { SlashCommandHost } from './dispatch'; +import type { PythinkerSlashCommand } from './types'; export type SkillListSession = Pick<Session, 'listSkills'>; -export type { SkillSlashCommands } from '@pymodel/pythinker-code-sdk'; -export { buildSkillSlashCommands, isUserActivatableSkill }; +export interface SkillSlashCommands { + readonly commands: readonly PythinkerSlashCommand[]; + readonly commandMap: ReadonlyMap<string, string>; +} -export async function handleSkillsCommand( - host: SlashCommandHost, - args: string, -): Promise<void> { - if (args.trim().length > 0) { - host.showError('Usage: /skills'); - return; - } - if (host.session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } +export function isUserActivatableSkill(skill: SkillSummary): boolean { + return ( + skill.type === undefined || + skill.type === 'prompt' || + skill.type === 'inline' || + skill.type === 'flow' + ); +} - const skills = (await host.session.listSkills()).filter(isUserActivatableSkill); - if (skills.length === 0) { - host.showNotice( - 'No skills found', - 'Create skills in .pythinker-code/skills or ~/.pythinker-code/skills.', - ); - return; - } - host.showNotice( - `Skills (${String(skills.length)})`, - skills - .map((skill) => `/${skill.name} · ${skill.source} · ${skill.description}`) - .join('\n'), +function compareSkillSlashCommands(a: SkillSummary, b: SkillSummary): number { + return ( + getSkillSlashCommandGroup(a.source) - getSkillSlashCommandGroup(b.source) || + a.name.localeCompare(b.name) ); } + +function getSkillSlashCommandGroup(source: SkillSummary['source']): number { + return source === 'builtin' ? 0 : 1; +} + +export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands { + const commandMap = new Map<string, string>(); + const sortedSkills = [...skills].toSorted(compareSkillSlashCommands); + const commands = sortedSkills.filter(isUserActivatableSkill).map((skill) => { + const commandName = + skill.source === 'builtin' || skill.isSubSkill === true + ? skill.name + : `skill:${skill.name}`; + commandMap.set(commandName, skill.name); + return { + name: commandName, + aliases: [], + description: skill.description ?? '', + }; + }); + return { commands, commandMap }; +} diff --git a/apps/pythinker-code/src/tui/commands/tag.ts b/apps/pythinker-code/src/tui/commands/tag.ts deleted file mode 100644 index d50e48c0..00000000 --- a/apps/pythinker-code/src/tui/commands/tag.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; -import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './dispatch'; - -export async function handleTagCommand(host: SlashCommandHost, args: string): Promise<void> { - const tag = normalizeTag(args); - if (tag === undefined) { - host.showError('Usage: /tag <name> (letters, numbers, dots, dashes, and underscores).'); - return; - } - - try { - const session = host.requireSession(); - const metadata = await session.getSessionMetadata(); - if (metadata.custom['tag'] !== tag) { - await session.updateSessionMetadata({ - custom: { ...metadata.custom, tag }, - }); - host.showStatus(`Tagged session with #${tag}.`, 'success'); - return; - } - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: `Remove tag #${tag}?`, - options: [ - { value: 'remove', label: 'Yes, remove tag', tone: 'danger' }, - { value: 'keep', label: 'No, keep tag' }, - ], - onSelect: (choice) => { - host.restoreEditor(); - if (choice !== 'remove') return; - void removeTag(host, metadata.custom, tag); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); - } catch (error) { - host.showError(`Failed to update session tag: ${formatErrorMessage(error)}`); - } -} - -async function removeTag( - host: SlashCommandHost, - metadata: Record<string, unknown>, - tag: string, -): Promise<void> { - const { tag: _tag, ...custom } = metadata; - void _tag; - try { - await host.requireSession().updateSessionMetadata({ custom }); - host.showStatus(`Removed tag #${tag}.`, 'success'); - } catch (error) { - host.showError(`Failed to remove session tag: ${formatErrorMessage(error)}`); - } -} - -function normalizeTag(value: string): string | undefined { - const tag = value.trim().replace(/^#/, '').normalize('NFKC'); - if (tag.length === 0 || tag.length > 64) return undefined; - return /^[\p{L}\p{N}][\p{L}\p{N}._-]*$/u.test(tag) ? tag : undefined; -} diff --git a/apps/pythinker-code/src/tui/commands/types.ts b/apps/pythinker-code/src/tui/commands/types.ts index 0a4bfcc4..103f6ff1 100644 --- a/apps/pythinker-code/src/tui/commands/types.ts +++ b/apps/pythinker-code/src/tui/commands/types.ts @@ -1,4 +1,4 @@ -import type { AutocompleteItem, SlashCommand } from '@earendil-works/pi-tui'; +import type { AutocompleteItem, SlashCommand } from '@pymodel/pi-tui'; import type { FlagId } from '@pymodel/pythinker-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; @@ -9,8 +9,6 @@ export interface PythinkerSlashCommand<Name extends string = string> extends Sla readonly description: string; readonly priority?: number; readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); - /** Execute by name without advertising the command in help or autocomplete. */ - readonly hidden?: boolean; /** When set, the command is hidden from the palette and blocked unless this flag is enabled. */ readonly experimentalFlag?: FlagId; /** diff --git a/apps/pythinker-code/src/tui/commands/undo.ts b/apps/pythinker-code/src/tui/commands/undo.ts index f036519d..f627f8d4 100644 --- a/apps/pythinker-code/src/tui/commands/undo.ts +++ b/apps/pythinker-code/src/tui/commands/undo.ts @@ -1,27 +1,21 @@ -import type { Component } from '@earendil-works/pi-tui'; -import type { - ContextMessage, - FileCheckpointSummary, - PartialCompactionDirection, - RestoreFileCheckpointResult, - SessionFileCheckpointPreview, -} from '@pymodel/pythinker-code-sdk'; +import type { Component } from '@pymodel/pi-tui'; +import type { ContextMessage } from '@pymodel/pythinker-code-sdk'; import { isPythinkerError } from '@pymodel/pythinker-code-sdk'; import { WelcomeComponent } from '../components/chrome/welcome'; -import { ChoicePickerComponent, type ChoiceOption } from '../components/dialogs/choice-picker'; import { CompactionComponent } from '../components/dialogs/compaction'; import { UndoSelectorComponent, type UndoChoice, } from '../components/dialogs/undo-selector'; import { AgentGroupComponent } from '../components/messages/agent-group'; -import { DynamicWorkflowMissionControlComponent } from '../components/messages/dynamic-workflow-mission-control'; +import { AgentDynamicWorkflowProgressComponent } from '../components/messages/agent-dynamic-workflow-progress'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from '../components/messages/background-agent-status'; import { CronMessageComponent } from '../components/messages/cron-message'; import { ReadGroupComponent } from '../components/messages/read-group'; import { SkillActivationComponent } from '../components/messages/skill-activation'; +import { PluginCommandComponent } from '../components/messages/plugin-command'; import { ThinkingComponent } from '../components/messages/thinking'; import { ToolCallComponent } from '../components/messages/tool-call'; import { UserMessageComponent } from '../components/messages/user-message'; @@ -83,54 +77,77 @@ export async function handleUndoCommand( await undoByCount(host, count); } -async function undoByCount( - host: SlashCommandHost, - count: number, - reportFailure: boolean = true, -): Promise<boolean> { +async function undoByCount(host: SlashCommandHost, count: number): Promise<boolean> { const session = host.session; if (session === undefined) { - if (reportFailure) host.showError(NO_ACTIVE_SESSION_MESSAGE); + host.showError(NO_ACTIVE_SESSION_MESSAGE); return false; } const entries = host.state.transcriptEntries; const lastUserIndex = findUndoAnchorEntryIndex(entries, count); if (lastUserIndex === undefined) { - if (reportFailure) showUndoLimitStatus(host, 'Nothing to undo.'); + showUndoLimitStatus(host, 'Nothing to undo.'); return false; } + // When the anchor is a bundled prompt, its skill activation cards sit + // before it (contiguous, marked at submission/replay time) and are removed + // together with it. try { await session.undoHistory(count); } catch (error) { const limit = undoLimitFromError(error); if (limit !== undefined) { - if (reportFailure) { - showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); - } + showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit)); return false; } - if (reportFailure) { - host.showError(`Failed to undo: ${formatErrorMessage(error)}`); - } + const message = formatErrorMessage(error); + host.showError(`Failed to undo: ${message}`); return false; } - - // The undone turn's mission control must not survive, or late events for - // it would resurrect streaming UI for removed work. - host.clearDynamicWorkflowMissionControls(); + host.noteContextCut?.(); const children = host.state.transcriptContainer.children; const lastUserComponentIndex = findUndoAnchorComponentIndex(children, count); if (lastUserComponentIndex !== undefined) { - removeUndoContextComponents(children, lastUserComponentIndex); + // A hook result may interleave between the bundle's cards and its prompt + // and survives undo in the engine, so it is skipped (kept) while the + // cards around it are removed. Only the contiguous marked run belongs to + // this submission: a standalone `/skill` card is unmarked and never + // swept. Structural removal only: the container's ref-checked render + // cache detects the child-list change; no tree-wide invalidate needed. + const groupChildIndices = new Set<number>(); + for (let i = lastUserComponentIndex - 1; i >= 0; i--) { + const entry = getTranscriptComponentEntry(children[i]!); + if (entry?.bundledWithPrompt === true) { + groupChildIndices.add(i); + continue; + } + if (entry?.hookResult === true) continue; + break; + } + removeUndoContextComponents(children, lastUserComponentIndex, groupChildIndices); } - const preservedEntries = entries.slice(lastUserIndex).filter( - (entry) => !isUndoContextEntry(entry), + const groupEntryIndices = new Set<number>(); + for (let i = lastUserIndex - 1; i >= 0; i--) { + const prev = entries[i]; + if (prev?.bundledWithPrompt === true) { + groupEntryIndices.add(i); + continue; + } + if (prev?.hookResult === true) continue; + break; + } + const preservedEntries = entries.filter( + (entry, index) => + !( + (index >= lastUserIndex || groupEntryIndices.has(index)) && + isUndoContextEntry(entry) + ), ); - entries.splice(lastUserIndex, entries.length - lastUserIndex, ...preservedEntries); + entries.splice(0, entries.length, ...preservedEntries); if (entries.length === 0) { renderWelcome(host); @@ -141,23 +158,13 @@ async function undoByCount( } async function showUndoSelector(host: SlashCommandHost): Promise<void> { - const session = host.session; - if (session === undefined) { + if (host.session === undefined) { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } - let checkpoints: readonly FileCheckpointSummary[]; - try { - checkpoints = await session.listFileCheckpoints(); - } catch (error) { - host.showError(`Failed to load checkpoints: ${formatErrorMessage(error)}`); - return; - } - const availability = await resolveUndoAvailability(host); const choices = createUndoChoices( - checkpoints, host.state.transcriptEntries, host.state.transcriptContainer.children, availability.maxCount, @@ -171,104 +178,13 @@ async function showUndoSelector(host: SlashCommandHost): Promise<void> { new UndoSelectorComponent({ choices, onSelect: (choice) => { - void previewUndoChoice(host, choice); - }, - onSummarize: (choice, direction) => { - void compactAtChoice(host, choice, direction); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), - ); -} - -async function previewUndoChoice( - host: SlashCommandHost, - choice: UndoChoice, -): Promise<void> { - const session = host.session; - if (session === undefined) { - host.restoreEditor(); - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - - let preview: SessionFileCheckpointPreview; - try { - preview = await session.previewFileCheckpoint(choice.id); - } catch (error) { - host.restoreEditor(); - host.showError(`Failed to preview checkpoint: ${formatErrorMessage(error)}`); - return; - } - - if (!preview.complete) { - host.restoreEditor(); - host.showError('Cannot restore code because this checkpoint is incomplete.'); - return; - } - - const canUndoConversation = - choice.count !== undefined && preview.conversationAvailable; - if (preview.paths.length === 0) { - if (!canUndoConversation) { - host.restoreEditor(); - host.showError('This checkpoint has no tracked file changes to restore.'); - return; - } - const undone = await undoByCount(host, choice.count); - if (undone) { - host.restoreInputText(choice.input); - } else { - host.restoreEditor(); - } - return; - } - - showRestoreActions(host, choice, preview, canUndoConversation); -} - -type RestoreAction = 'both' | 'conversation' | 'code'; - -function showRestoreActions( - host: SlashCommandHost, - choice: UndoChoice, - preview: SessionFileCheckpointPreview, - canUndoConversation: boolean, -): void { - const options: ChoiceOption[] = canUndoConversation - ? [ - { value: 'both', label: 'Restore code and conversation' }, - { value: 'conversation', label: 'Restore conversation only' }, - { value: 'code', label: 'Restore code only' }, - { value: 'cancel', label: 'Cancel' }, - ] - : [ - { value: 'code', label: 'Restore code only' }, - { value: 'cancel', label: 'Cancel' }, - ]; - const fileLabel = `${String(preview.paths.length)} ${ - preview.paths.length === 1 ? 'file' : 'files' - }`; - const notice = - `${fileLabel} · ${String(preview.insertions)} insertions · ` + - `${String(preview.deletions)} deletions. ` + - 'Shell commands and manual edits are not tracked.'; - - host.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Restore checkpoint', - notice, - noticeTone: 'warning', - options, - onSelect: (value) => { - if (value === 'cancel') { + void undoByCount(host, choice.count).then((undone) => { + if (undone) { + host.restoreInputText(choice.input); + return; + } host.restoreEditor(); - return; - } - if (value !== 'both' && value !== 'conversation' && value !== 'code') return; - void performRestoreAction(host, choice, value); + }); }, onCancel: () => { host.restoreEditor(); @@ -277,99 +193,6 @@ function showRestoreActions( ); } -async function performRestoreAction( - host: SlashCommandHost, - choice: UndoChoice, - action: RestoreAction, -): Promise<void> { - const session = host.session; - if (session === undefined) { - host.restoreEditor(); - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - const conversationCount = choice.count; - if ( - (action === 'both' || action === 'conversation') && - conversationCount === undefined - ) { - host.restoreEditor(); - host.showError('Conversation undo is unavailable for this checkpoint.'); - return; - } - - host.restoreEditor(); - let restore: RestoreFileCheckpointResult | undefined; - if (action === 'both' || action === 'code') { - try { - restore = await session.restoreFileCheckpoint(choice.id); - } catch (error) { - host.showError(`Failed to restore code: ${formatErrorMessage(error)}`); - return; - } - } - - if (action === 'both' || action === 'conversation') { - if (conversationCount === undefined) return; - const undone = await undoByCount(host, conversationCount, action === 'conversation'); - if (!undone) { - if (restore !== undefined) { - host.showError( - 'Files were restored, but conversation undo failed. ' + - `Recovery checkpoint: ${restore.recoveryCheckpointId}.`, - ); - } - return; - } - host.restoreInputText(choice.input); - } - - if (restore !== undefined) { - showRestoreSuccess(host, restore); - } -} - -function showRestoreSuccess( - host: SlashCommandHost, - restore: RestoreFileCheckpointResult, -): void { - host.showNotice( - 'Files restored', - `Restored: ${String(restore.restoredPaths.length)}. ` + - `Deleted: ${String(restore.deletedPaths.length)}. ` + - `Recovery checkpoint: ${restore.recoveryCheckpointId}.`, - ); -} - -async function compactAtChoice( - host: SlashCommandHost, - choice: UndoChoice, - direction: PartialCompactionDirection, -): Promise<void> { - const session = host.session; - if (session === undefined) { - host.restoreEditor(); - host.showError(NO_ACTIVE_SESSION_MESSAGE); - return; - } - if (choice.count === undefined) return; - try { - await session.compact({ - promptFromEnd: choice.count, - direction, - }); - } catch (error) { - host.restoreEditor(); - host.showError(`Failed to summarize: ${formatErrorMessage(error)}`); - return; - } - if (direction === 'from') { - host.restoreInputText(choice.input); - } else { - host.restoreEditor(); - } -} - function parseUndoCount(args: string): number | undefined { const value = args.trim(); if (value.length === 0) return 1; @@ -448,74 +271,25 @@ function isContextUndoAnchor(message: ContextMessage): boolean { if (origin.kind === 'skill_activation') { return origin.trigger === 'user-slash'; } + if (origin.kind === 'plugin_command') { + return origin.trigger === 'user-slash'; + } return false; } function createUndoChoices( - checkpoints: readonly FileCheckpointSummary[], entries: readonly TranscriptEntry[], children: readonly Component[], maxCount: number, ): readonly UndoChoice[] { - const activeAnchors = activeUndoAnchorEntries(entries, children).anchors; - const anchors = maxCount > 0 ? activeAnchors.slice(-maxCount) : []; - const counts = matchCheckpointCounts(checkpoints, anchors); - return checkpoints.map((checkpoint) => { - const input = checkpoint.prompt ?? ''; - const title = - singleLine(input) || - (checkpoint.kind === 'recovery' ? 'Recovery checkpoint' : 'User prompt'); - const time = formatCheckpointTime(checkpoint.createdAt); - return { - id: checkpoint.id, - count: counts.get(checkpoint.id), - input, - label: time.length > 0 ? `${title} · ${time}` : title, - }; - }); -} - -function matchCheckpointCounts( - checkpoints: readonly FileCheckpointSummary[], - anchors: readonly TranscriptEntry[], -): ReadonlyMap<string, number> { - const userCheckpoints = checkpoints.filter((checkpoint) => checkpoint.kind === 'user'); - const counts = new Map<string, number>(); - let checkpointIndex = userCheckpoints.length - 1; - - for (let anchorIndex = anchors.length - 1; anchorIndex >= 0; anchorIndex--) { - const anchor = anchors[anchorIndex]; - if (anchor === undefined) continue; - let matchIndex = checkpointIndex; - if (anchor.checkpointId !== undefined) { - matchIndex = -1; - for (let index = checkpointIndex; index >= 0; index--) { - if (userCheckpoints[index]?.id === anchor.checkpointId) { - matchIndex = index; - break; - } - } - if (matchIndex < 0) continue; - } - - const checkpoint = userCheckpoints[matchIndex]; - if (checkpoint === undefined) break; - counts.set(checkpoint.id, anchors.length - anchorIndex); - checkpointIndex = matchIndex - 1; - } - return counts; -} - -function formatCheckpointTime(createdAt: string): string { - const timestamp = Date.parse(createdAt); - if (!Number.isFinite(timestamp)) return ''; - const seconds = Math.floor(Math.max(0, Date.now() - timestamp) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${String(minutes)}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${String(hours)}h ago`; - return `${String(Math.floor(hours / 24))}d ago`; + if (maxCount <= 0) return []; + const anchors = activeUndoAnchorEntries(entries, children).anchors.slice(-maxCount); + return anchors.map((entry, index) => ({ + id: entry.id, + count: anchors.length - index, + input: formatUndoChoiceInput(entry), + label: formatUndoChoiceLabel(entry), + })); } function activeUndoAnchorEntries( @@ -547,6 +321,52 @@ function activeUndoAnchorEntries( }; } +function formatUndoChoiceLabel( + entry: TranscriptEntry, +): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return 'Skill: unknown'; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? 'User message'; + } + + const content = singleLine(entry.content); + const imageCount = entry.imageAttachmentIds?.length ?? 0; + if (content.length > 0) return content; + if (imageCount > 0) { + return `User message (${String(imageCount)} ${imageCount === 1 ? 'image' : 'images'})`; + } + return 'User message'; +} + +function formatUndoChoiceInput(entry: TranscriptEntry): string { + if (entry.kind === 'skill_activation') { + const name = singleLine( + entry.skillName ?? entry.content.replace(/^Activated skill:\s*/, ''), + ); + const args = singleLine(entry.skillArgs ?? ''); + if (name.length === 0) return ''; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; + } + if (entry.kind === 'plugin_command' && entry.pluginCommandData !== undefined) { + return formatPluginCommandSlash(entry.pluginCommandData) ?? entry.content; + } + return entry.content; +} + +function formatPluginCommandSlash(data: NonNullable<TranscriptEntry['pluginCommandData']>): string | undefined { + const name = `${data.pluginId}:${data.commandName}`; + const args = singleLine(data.args ?? ''); + if (name.length === 0) return undefined; + return args.length > 0 ? `/${name} ${args}` : `/${name}`; +} + function singleLine(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } @@ -604,7 +424,10 @@ function undoLimitFromError( function isUndoAnchorEntry(entry: TranscriptEntry): boolean { return ( entry.kind === 'user' || - (entry.kind === 'skill_activation' && entry.skillTrigger === 'user-slash') + (entry.kind === 'skill_activation' && + entry.skillTrigger === 'user-slash' && + entry.bundledWithPrompt !== true) || + entry.kind === 'plugin_command' ); } @@ -630,6 +453,7 @@ function isUndoContextEntry(entry: TranscriptEntry): boolean { case 'tool_call': case 'thinking': case 'skill_activation': + case 'plugin_command': case 'cron': return true; case 'status': @@ -658,19 +482,28 @@ function findUndoAnchorComponentIndex( function removeUndoContextComponents( children: Component[], startIndex: number, + additionalIndices: ReadonlySet<number>, ): void { - for (let i = children.length - 1; i >= startIndex; i--) { + for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; - if (child !== undefined && isUndoContextComponent(child)) { + if ( + child !== undefined && + (i >= startIndex || additionalIndices.has(i)) && + isUndoContextComponent(child) + ) { children.splice(i, 1); } } } function isUndoAnchorComponent(child: Component): boolean { + const entry = getTranscriptComponentEntry(child); return ( child instanceof UserMessageComponent || - (child instanceof SkillActivationComponent && child.trigger === 'user-slash') + (child instanceof SkillActivationComponent && + child.trigger === 'user-slash' && + entry?.bundledWithPrompt !== true) || + child instanceof PluginCommandComponent ); } @@ -686,9 +519,10 @@ function isUndoContextComponent(child: Component): boolean { child instanceof ThinkingComponent || child instanceof ToolCallComponent || child instanceof AgentGroupComponent || - child instanceof DynamicWorkflowMissionControlComponent || + child instanceof AgentDynamicWorkflowProgressComponent || child instanceof ReadGroupComponent || child instanceof SkillActivationComponent || + child instanceof PluginCommandComponent || child instanceof BackgroundAgentStatusComponent || child instanceof CronMessageComponent ); @@ -702,10 +536,7 @@ function renderWelcome(host: SlashCommandHost): void { ) { return; } - host.state.transcriptContainer.addTranscriptChild( - new WelcomeComponent(host.state.appState, () => { - host.state.ui.requestRender(); - }), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, + host.state.transcriptContainer.addChild( + new WelcomeComponent(host.state.appState), ); } diff --git a/apps/pythinker-code/src/tui/commands/vim.ts b/apps/pythinker-code/src/tui/commands/vim.ts deleted file mode 100644 index 226bc22d..00000000 --- a/apps/pythinker-code/src/tui/commands/vim.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { formatErrorMessage } from '../utils/event-payload'; -import type { SlashCommandHost } from './dispatch'; -import { - isExperimentalFlagEnabled, - setExperimentalFeatureForRun, - setExperimentalFeatures, -} from './experimental-flags'; - -/** - * Toggles vim editing for this run. Enabling is a session-local override - * (never persisted); disabling also clears a previously persisted - * `experimental.vim_mode` setting in the harness config. - */ -export async function handleVimCommand(host: SlashCommandHost): Promise<void> { - const wasFlagEnabled = isExperimentalFlagEnabled('vim_mode'); - const enabled = !host.state.editor.isVimModeEnabled(); - setExperimentalFeatureForRun('vim_mode', enabled); - host.state.editor.setVimMode(enabled); - host.state.ui.requestRender(); - - try { - if (!enabled && wasFlagEnabled) { - await host.harness.setConfig({ experimental: { vim_mode: false } }); - setExperimentalFeatures(await host.harness.getExperimentalFeatures()); - host.state.editor.setVimMode(false); - } - host.showStatus( - enabled - ? 'Editor mode set to vim (NORMAL) for this run. Press i to enter INSERT mode.' - : 'Editor mode set to normal.', - 'success', - ); - } catch (error) { - host.showError(`Failed to reset saved Vim mode: ${formatErrorMessage(error)}`); - } -} diff --git a/apps/pythinker-code/src/tui/commands/web.ts b/apps/pythinker-code/src/tui/commands/web.ts index bce7ccbb..e40c48b1 100644 --- a/apps/pythinker-code/src/tui/commands/web.ts +++ b/apps/pythinker-code/src/tui/commands/web.ts @@ -1,21 +1,23 @@ -import { ensureDaemon } from '#/cli/sub/server/daemon'; +import chalk from 'chalk'; + +import { splitTokenFragment } from '#/cli/sub/web/access-urls'; +import { formatReadyBanner, startServerForeground } from '#/cli/sub/web/run'; +import { parseServerOptions, tryResolveServerToken } from '#/cli/sub/web/shared'; import { openUrl } from '#/utils/open-url'; +import { getDataDir } from '#/utils/paths'; -import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; +import { darkColors } from '../theme/colors'; import { formatErrorMessage } from '../utils/event-payload'; import type { SlashCommandHost } from './dispatch'; -const WEB_CONFIRM = 'confirm'; -const WEB_CANCEL = 'cancel'; - /** * `/web` — hand the current session off to the browser. * - * Equivalent to `pythinker server run` (ensures the background daemon is up) plus - * `pythinker web` (opens the browser), but deep-linked to the active session and - * followed by shutting down this terminal UI. A confirmation step spells out - * the consequences and only proceeds when the user presses Enter on Continue. + * Always starts a new server: the TUI shuts down and this process becomes the + * server, running in the foreground attached to this terminal and taking the + * next free port alongside any running ones. The session deep link opens from + * the ready hook once the server is actually listening. */ export async function handleWebCommand(host: SlashCommandHost): Promise<void> { const session = host.session; @@ -23,53 +25,58 @@ export async function handleWebCommand(host: SlashCommandHost): Promise<void> { host.showError(NO_ACTIVE_SESSION_MESSAGE); return; } - const sessionId = session.id; - const confirmed = await new Promise<boolean>((resolve) => { - const picker = new ChoicePickerComponent({ - title: 'Open current session in the Web UI?', - hint: '↑↓ navigate · Enter select · Esc cancel', - options: [ - { - value: WEB_CONFIRM, - label: 'Continue', - description: - 'Start the Pythinker server (background daemon if needed), open this session in your default browser, and exit the terminal UI.', - }, - { - value: WEB_CANCEL, - label: 'Cancel', - description: 'Stay in the terminal UI.', + startNewServerAfterExit(host, session.id); + await host.stop(); +} + +/** + * Register the exit takeover that turns this process into the new server once + * the TUI has shut down (where `process.exit` would normally happen): the + * server stays attached to this terminal until Ctrl+C, and the session deep + * link opens from the ready hook once the server is actually listening. The + * terminal shows the same ready banner as `pythinker web` plus the deep link. + */ +function startNewServerAfterExit(host: SlashCommandHost, sessionId: string): void { + host.setExitForegroundTask(async () => { + const options = parseServerOptions({}); + try { + await startServerForeground(options, { + onReady: (origin) => { + // Resolve the token here (after the server is listening): a fresh + // server writes `server.token` on first boot, so reading it earlier + // would miss first-time starts and the browser would hit the auth + // gate. + const token = tryResolveServerToken(getDataDir()); + const url = webSessionUrl(origin, sessionId, token); + process.stdout.write(formatReadyBanner(origin, options.host, { token })); + process.stdout.write(`\n ${sessionLine(url)}\n`); + openUrl(url); }, - ], - onSelect: (value) => { - resolve(value === WEB_CONFIRM); - }, - onCancel: () => { - resolve(false); - }, - }); - host.mountEditorReplacement(picker); + }); + } catch (error) { + process.stderr.write(`Failed to start server: ${formatErrorMessage(error)}\n`); + process.exit(1); + } }); - host.restoreEditor(); - if (!confirmed) return; - - host.showStatus('Starting Pythinker server and opening web UI…'); - let origin: string; - try { - ({ origin } = await ensureDaemon({})); - } catch (error) { - host.showError(`Failed to start server: ${formatErrorMessage(error)}`); - return; - } +} - const url = webSessionUrl(origin, sessionId); - openUrl(url); - host.setExitOpenUrl(url); - await host.stop(); +/** Styled `Session:` line for the foreground handoff; the token fragment is + * dimmed like in the ready banner so the host/path stands out. */ +function sessionLine(url: string): string { + const label = (text: string): string => chalk.bold.hex(darkColors.textDim)(text); + const accent = (text: string): string => chalk.hex(darkColors.accent)(text); + const dim = (text: string): string => chalk.hex(darkColors.textDim)(text); + const [base, frag] = splitTokenFragment(url); + return `${label('Session: ')}${accent(base)}${frag === '' ? '' : dim(frag)}`; } -/** Build the deep-link URL the web UI recognises for a session. */ -export function webSessionUrl(origin: string, sessionId: string): string { - return `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; +/** + * Build the deep-link URL the web UI recognises for a session. When a token is + * known it rides in the `#token=` fragment (never sent to the server, so never + * logged), so the browser authenticates on load just like `pythinker web`. + */ +export function webSessionUrl(origin: string, sessionId: string, token?: string): string { + const base = `${origin.replace(/\/+$/, '')}/sessions/${encodeURIComponent(sessionId)}`; + return token === undefined ? base : `${base}#token=${token}`; } diff --git a/apps/pythinker-code/src/tui/commands/workflow-availability.ts b/apps/pythinker-code/src/tui/commands/workflow-availability.ts deleted file mode 100644 index 98baf2dd..00000000 --- a/apps/pythinker-code/src/tui/commands/workflow-availability.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - resolveWorkflowSizeGuideline, - type WorkflowSizeGuideline, -} from '@pymodel/pythinker-code-sdk'; - -const DISABLE_WORKFLOWS_ENV = 'PYTHINKER_CODE_DISABLE_WORKFLOWS'; -const TRUE_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']); -const FALSE_ENV_VALUES = new Set(['0', 'false', 'no', 'off']); - -let disabled = false; - -/** Cache the resolved switch. Call once at startup with the value from `harness.getConfig()`. */ -export function setDynamicWorkflowDisabled(configValue: boolean | undefined, env = process.env): void { - const envValue = env[DISABLE_WORKFLOWS_ENV]?.trim().toLowerCase(); - if (envValue !== undefined && envValue.length > 0) { - if (TRUE_ENV_VALUES.has(envValue)) { - disabled = true; - return; - } - if (FALSE_ENV_VALUES.has(envValue)) { - disabled = false; - return; - } - } - disabled = configValue ?? false; -} - -export function isDynamicWorkflowDisabled(): boolean { - return disabled; -} - -let sizeGuideline: WorkflowSizeGuideline | undefined; - -/** Cache the resolved guideline. Call once at startup with the value from `harness.getConfig()`. */ -export function setWorkflowSizeGuideline( - configValue: WorkflowSizeGuideline | undefined, - env = process.env, -): void { - sizeGuideline = resolveWorkflowSizeGuideline({ workflowSizeGuideline: configValue }, env); -} - -/** The guideline in force for this session, for surfaces that persist it (e.g. `/workflow save`). */ -export function currentWorkflowSizeGuideline(): WorkflowSizeGuideline | undefined { - return sizeGuideline; -} diff --git a/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts b/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts deleted file mode 100644 index d7425397..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/activity-loader.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Text } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; - -import { - BRAILLE_SPINNER_FRAMES, - BRAILLE_SPINNER_INTERVAL_MS, - formatThinkingSpinnerLabel, -} from '#/tui/constant/rendering'; -import { shimmerText } from '#/tui/utils/shimmer'; - -export interface ActivityLoaderOptions { - readonly verbLabels?: boolean; -} - -export class ActivityLoader extends Text { - private currentFrame = 0; - private animationFrame = 0; - private intervalId: ReturnType<typeof setInterval> | null = null; - private ui: TUI; - private frames: string[]; - private interval: number; - private colorFn?: (s: string) => string; - private label: string; - private useVerbLabels = false; - private displayText = ''; - - constructor( - ui: TUI, - colorFn?: (s: string) => string, - label: string = '', - options?: ActivityLoaderOptions, - ) { - super('', 1, 0); - this.ui = ui; - this.frames = [...BRAILLE_SPINNER_FRAMES]; - this.interval = BRAILLE_SPINNER_INTERVAL_MS; - this.colorFn = colorFn; - this.useVerbLabels = options?.verbLabels ?? false; - this.label = this.useVerbLabels ? formatThinkingSpinnerLabel() : label; - this.start(); - } - - start(): void { - this.updateDisplay(); - this.intervalId = setInterval(() => { - this.currentFrame = (this.currentFrame + 1) % this.frames.length; - this.animationFrame += 1; - this.updateDisplay(); - }, this.interval); - } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - setLabel(label: string): void { - this.useVerbLabels = false; - this.label = label; - this.updateDisplay(); - } - - setVerbLabels(enabled: boolean): void { - this.useVerbLabels = enabled; - if (enabled) { - this.label = formatThinkingSpinnerLabel(); - } - this.updateDisplay(); - } - - setColorFn(colorFn: (s: string) => string): void { - this.colorFn = colorFn; - this.updateDisplay(); - } - - renderInline(): string { - return this.displayText; - } - - private updateDisplay(): void { - if (this.useVerbLabels) { - this.label = formatThinkingSpinnerLabel(); - } - const frame = this.frames[this.currentFrame]!; - const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; - const label = this.useVerbLabels - ? shimmerText(this.label, { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - }) - : this.label; - this.displayText = label ? `${coloredFrame} ${label}` : coloredFrame; - this.setText(this.displayText); - this.ui.requestRender(); - } -} diff --git a/apps/pythinker-code/src/tui/components/chrome/banner.ts b/apps/pythinker-code/src/tui/components/chrome/banner.ts index 265f0b02..c03884e6 100644 --- a/apps/pythinker-code/src/tui/components/chrome/banner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/banner.ts @@ -1,11 +1,19 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { BannerState } from '#/tui/types'; const PREFIX_STAR = '✦'; const PADDING = ' '; +/** + * Minimum column count the main text gets next to an inline tag. A long tag + * (e.g. a full sentence from the remote banner config) can fit on the line + * yet leave only a sliver for the main text, which then wraps into a narrow, + * hard-broken column. When that would happen the tag moves onto its own line + * and the main text uses (nearly) the full width instead. + */ +const MIN_INLINE_MAIN_TEXT_WIDTH = 16; export class BannerComponent implements Component { constructor(private readonly state: BannerState) {} @@ -30,14 +38,22 @@ export class BannerComponent implements Component { const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; const tagWidth = visibleWidth(tagDisplay); const showTag = tagWidth > 0 && tagWidth < width; + // Hanging indent aligning with the tag text (right after "✦ "). + const hangingWidth = visibleWidth(PREFIX_STAR + PADDING); + // If the inline tag would squeeze the main text into too narrow a column, + // render the tag on its own line and give the main text the full width. + const tagOnOwnLine = showTag && width - tagWidth < MIN_INLINE_MAIN_TEXT_WIDTH; + const inlineTag = showTag && !tagOnOwnLine; // Body lines (continuations of the main text) indent to match the first - // line's main-text column, which starts right after the tag display. - const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // line's main-text column, which starts right after the tag display. When + // the tag is on its own line, the main text aligns with the tag text. + const bodyIndent = inlineTag ? ' '.repeat(tagWidth) : tagOnOwnLine ? ' '.repeat(hangingWidth) : ''; // Descriptive subtext lines (the second line in the design) start at the // column after the leading star + space, aligning with the tag text itself. - const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; - const bodyContentWidth = width - (showTag ? tagWidth : 0); - const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + const descIndent = showTag ? ' '.repeat(hangingWidth) : ''; + const bodyContentWidth = + width - (inlineTag ? tagWidth : tagOnOwnLine ? hangingWidth : 0); + const descContentWidth = width - (showTag ? hangingWidth : 0); if (bodyContentWidth <= 0) { return ['']; @@ -47,11 +63,14 @@ export class BannerComponent implements Component { const subSegments = this.state.subText ? this.state.subText.split('\n') : []; const result: string[] = []; + if (tagOnOwnLine) { + result.push(tagStyled); + } for (let i = 0; i < mainSegments.length; i++) { const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); for (let j = 0; j < wrapped.length; j++) { const boldLine = main(wrapped[j]!); - if (i === 0 && j === 0 && showTag) { + if (i === 0 && j === 0 && inlineTag) { result.push(tagDisplay + boldLine); } else { result.push(bodyIndent + boldLine); diff --git a/apps/pythinker-code/src/tui/components/chrome/device-code-box.ts b/apps/pythinker-code/src/tui/components/chrome/device-code-box.ts index b2fee832..688fad44 100644 --- a/apps/pythinker-code/src/tui/components/chrome/device-code-box.ts +++ b/apps/pythinker-code/src/tui/components/chrome/device-code-box.ts @@ -6,8 +6,8 @@ * active palette so theme switches take effect on the next render. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; @@ -49,7 +49,8 @@ export class DeviceCodeBoxComponent implements Component { const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine]; if (hint !== undefined && hint.length > 0) { - contentLines.push('', truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); + contentLines.push(''); + contentLines.push(truncateToWidth(currentTheme.fg('textDim', hint), innerWidth, '…')); } if (safeWidth < 4) { @@ -69,7 +70,9 @@ export class DeviceCodeBoxComponent implements Component { lines.push(border('│') + pad + truncated + ' '.repeat(rightPad) + border('│')); } - lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'), border('╰' + '─'.repeat(safeWidth - 2) + '╯'), ''); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index 5de1c347..dcf056b1 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -1,26 +1,25 @@ /** - * Footer/status bar — a renderer-neutral operational status row for pi-tui. - * The editor owns the composer row and the activity pane owns legacy activity. - * This component renders validation and the shared footer status row beneath it. + * Footer/status bar — multi-line status display at the bottom of the TUI. + * + * Layout: + * Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints> + * Line 2: context: N% (tokens/max) */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; import chalk from 'chalk'; +import { effectiveModelAlias } from '@pymodel/pythinker-code-sdk'; -import { - createFooterState, - reduceFooterState, - selectFooterViewModel, - type FooterBackgroundCounts, - type FooterGitStatus, - type FooterGoal, - type FooterState, - type FooterStatus, - type FooterViewModel, - type FooterViewModelRow, -} from '#/tui/runtime/footer/footer-model'; +import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips'; +import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; +import { + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; import { createGitStatusCache, formatGitBadgeBase, @@ -28,66 +27,158 @@ import { type GitStatus, type GitStatusCache, } from '#/utils/git/git-status'; +import { + formatTokenCount, + usagePercent, + usagePercentFromRatio, +} from '#/utils/usage/usage-format'; + +const DEFAULT_STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git'] as const; + +const MAX_CWD_SEGMENTS = 3; +const GOAL_TIMER_INTERVAL_MS = 1_000; + +// Toolbar tips — rotates every 10s. Most tips are short and pair up (two +// joined by " | ") when space allows; tips flagged `solo` are long or +// important enough to take the whole slot on their own. A `priority` weight +// makes a tip recur more often in the rotation (default 1). Width is always +// the final arbiter (a pair that doesn't fit falls back to its first tip). +const TIP_ROTATE_INTERVAL_MS = 10_000; +const TIP_SEPARATOR = ' | '; + +/** + * Expand tips into a rotation sequence using smooth weighted round-robin + * (the nginx SWRR algorithm). Higher-`priority` tips appear more often while + * staying evenly spread, so a tip generally does not land next to its own + * duplicate. Deterministic and computed once at module load. Exported for + * unit testing. + */ +export function buildWeightedTips(tips: readonly ToolbarTip[]): readonly ToolbarTip[] { + const items = tips.map((t) => ({ + tip: t, + weight: Math.max(1, Math.trunc(t.priority ?? 1)), + current: 0, + })); + const total = items.reduce((sum, it) => sum + it.weight, 0); + const seq: ToolbarTip[] = []; + for (let n = 0; n < total; n++) { + let best = items[0]!; + for (const it of items) { + it.current += it.weight; + if (it.current > best.current) best = it; + } + best.current -= total; + seq.push(best.tip); + } + return seq; +} -const FOOTER_FRESHNESS_INTERVAL_MS = 1_000; +const ROTATION: readonly ToolbarTip[] = buildWeightedTips(ALL_TIPS); -export type FooterActionId = 'goal' | 'shell-tasks' | 'agents'; +function currentTipIndex(): number { + return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS); +} -export interface FooterActionItem { - readonly id: FooterActionId; - readonly label: string; +/** + * Pick the tip(s) for a rotation index over the weighted ROTATION sequence. + * `primary` is always shown when it fits; `pair` (primary + next tip joined + * by the separator) is offered for wide terminals. Pairing is skipped when + * the current/next tip is `solo` or when the neighbour is a duplicate of the + * current tip (which can happen at the wrap boundary), keeping long/important + * tips on their own and avoiding "X | X". + */ +function tipsForIndex(index: number): { primary: string; pair: string | null } { + const n = ROTATION.length; + if (n === 0) return { primary: '', pair: null }; + const offset = ((index % n) + n) % n; + const current = ROTATION[offset]!; + if (n === 1 || current.solo) return { primary: current.text, pair: null }; + const next = ROTATION[(offset + 1) % n]!; + if (next.solo || next.text === current.text) return { primary: current.text, pair: null }; + return { primary: current.text, pair: current.text + TIP_SEPARATOR + next.text }; } /** - * Maps live app state into the shared footer reducer's status fields. Token - * speed is event-owned and intentionally omitted so status refreshes retain it. + * Footer goal badge, e.g. `[goal ● active · 4m · 7 turns]`. Only shown for a + * live (active/paused) goal; terminal/no goal -> no badge. Turn count is a raw + * count unless an explicit turn budget is set, in which case it shows used/limit. */ -export function footerStatusFromAppState( - state: AppState, - git: GitStatus | null, - now = Date.now(), -): Partial<FooterStatus> { +function formatGoalBadge( + goal: AppState['goal'], + colors: ColorPalette, + wallClockMs?: number, +): string | null { + if (goal === null || goal === undefined) return null; + // Show the badge for every persisted, resumable status. `complete` clears the + // goal, so it never reaches here; only the unset case returns null. + if (goal.status !== 'active' && goal.status !== 'paused' && goal.status !== 'blocked') { + return null; + } + const dotColor = + goal.status === 'active' + ? colors.primary + : goal.status === 'blocked' + ? colors.warning + : colors.textMuted; + const turns = + goal.budget.turnBudget !== null + ? `${goal.turnsUsed}/${goal.budget.turnBudget} turns` + : `${goal.turnsUsed} ${goal.turnsUsed === 1 ? 'turn' : 'turns'}`; + const label = `${goal.status} · ${formatBadgeElapsed(wallClockMs ?? goal.wallClockMs)} · ${turns}`; + return ( + chalk.hex(colors.textMuted)('[goal ') + + chalk.hex(dotColor)('●') + + chalk.hex(colors.textMuted)(` ${label}]`) + ); +} + +function formatBadgeElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h${minutes % 60}m`; +} + +function modelDisplayName(state: AppState): string { const model = state.availableModels[state.model]; - return { - model: model?.displayName ?? model?.model ?? state.model, - thinkingLevel: state.thinkingLevel, - cwd: state.workDir, - homeDir: process.env['HOME'] ?? null, - git: footerGitStatus(git), - permissionMode: state.permissionMode, - planMode: state.planMode, - dynamicWorkflowMode: state.dynamicWorkflowMode, - // Only surface fast mode when the active model supports it. - fastMode: state.fastMode === true && state.fastModeSupported === true, - contextUsage: state.contextUsage, - contextTokens: state.contextTokens, - maxContextTokens: state.maxContextTokens, - elapsedMs: hasActiveElapsed(state) ? Math.max(0, now - state.streamingStartTime) : null, - }; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? state.model; } -function hasActiveElapsed( - state: Pick<AppState, 'streamingPhase' | 'streamingStartTime'>, -): boolean { - return state.streamingStartTime > 0 && state.streamingPhase !== 'idle'; +function shortenCwd(path: string): string { + if (!path) return path; + const home = process.env['HOME'] ?? ''; + let work = path; + if (home && path === home) { + return '~'; + } + if (home && path.startsWith(home + '/')) { + work = '~' + path.slice(home.length); + } + + const segments = work.split('/').filter((s) => s.length > 0); + if (segments.length <= MAX_CWD_SEGMENTS) return work; + const tail = segments.slice(-MAX_CWD_SEGMENTS).join('/'); + return `…/${tail}`; } -function footerGitStatus(status: GitStatus | null): FooterGitStatus | null { - if (status === null) return null; - return { - branch: status.branch, - dirty: status.dirty, - ahead: status.ahead, - behind: status.behind, - diffAdded: status.diffAdded, - diffDeleted: status.diffDeleted, - pullRequest: - status.pullRequest === null ? null : { number: status.pullRequest.number }, - }; +/** + * Footer context readout. Percent comes from the exact token counts when + * both are known (the ratio can lag a step behind); otherwise it falls + * back to the precomputed ratio. Counts use the shared 1024-based + * formatter. + */ +function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { + if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) { + const pct = String(usagePercent(tokens, maxTokens)); + return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + } + return `context: ${String(usagePercentFromRatio(usage))}%`; } -/** Retained for the legacy git-status caller and its coloured PR link. */ -export function formatFooterGitBadge(status: GitStatus, colors: { readonly textDim: string; readonly primary: string }): string { +export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { const base = chalk.hex(colors.textDim)(formatGitBadgeBase(status)); if (status.pullRequest === null) return base; @@ -97,227 +188,278 @@ export function formatFooterGitBadge(status: GitStatus, colors: { readonly textD return `${base} ${pullRequest}`; } -/** - * Footer chrome: renders the shared footer status rows and keeps the git - * cache, goal clock, and freshness timer aligned with the reducer-owned model. - */ export class FooterComponent implements Component { private state: AppState; - private onRefresh: () => void; - private gitCache: GitStatusCache | null = null; + private readonly onRefresh: () => void; + private gitCache: GitStatusCache; private gitCacheWorkDir: string; - private gitCacheGeneration = 0; - private fallbackState: FooterState; - private viewModel: FooterViewModel; - private hasSharedViewModel = false; + private transientHint: string | null = null; + private warningHint: string | null = null; private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); - private freshnessTimer: ReturnType<typeof setInterval> | null = null; + private goalTimer: ReturnType<typeof setInterval> | null = null; + private statusLineRunner: StatusLineCommandRunner | null = null; + /** + * Non-terminal background-task counts split by kind so the footer can + * render two distinct badges. `bashTasks` covers `bash-*` BPM tasks + * spawned via `Shell run_in_background=true`; `agentTasks` covers + * `agent-*` BPM tasks (background subagents). Either zero hides its + * respective badge. + */ private backgroundBashTaskCount = 0; private backgroundAgentCount = 0; - private selectedAction: FooterActionId | null = null; constructor(state: AppState, onRefresh: () => void = () => {}) { this.state = state; this.onRefresh = onRefresh; this.gitCacheWorkDir = state.workDir; - this.syncGitCache(); + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); this.syncGoalClock(state.goal); - this.syncFooterFreshnessTimer(); - this.fallbackState = createFooterState(footerStatusFromAppState(state, this.getGitStatus())); - this.fallbackState = reduceFooterState(this.fallbackState, { - type: 'goal.updated', - goal: this.footerGoal(), - }); - this.viewModel = selectFooterViewModel( - this.fallbackState, - Date.now(), - this.state.statusLine, - ); - } - - /** Lets the host turn cache, goal, and elapsed freshness into one reducer projection. */ - setRefreshHandler(onRefresh: () => void): void { - this.onRefresh = onRefresh; + this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); } setState(state: AppState): void { - this.syncAppState(state); - this.updateFallback({ - type: 'status.updated', - changes: footerStatusFromAppState(state, this.getGitStatus()), - }); - this.updateFallback({ type: 'goal.updated', goal: this.footerGoal() }); - } - - /** Updates cache/timer/action inputs without independently folding production footer state. */ - syncAppState(state: AppState): void { - this.state = state; - this.syncGitCache(); + if (state.workDir !== this.gitCacheWorkDir) { + this.gitCacheWorkDir = state.workDir; + this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onRefresh }); + } this.syncGoalClock(state.goal); - this.syncFooterFreshnessTimer(); - this.clearStaleSelection(); + this.syncGoalTimer(state.goal); + this.syncStatusLineRunner(state); + this.state = state; } - /** Receives the one reducer-derived model from the presentation host. */ - setViewModel(viewModel: FooterViewModel): void { - this.viewModel = viewModel; - this.hasSharedViewModel = true; + private syncStatusLineRunner(state: AppState): void { + const command = state.statusLine?.command ?? null; + if (command === null) { + this.statusLineRunner?.dispose(); + this.statusLineRunner = null; + return; + } + if (this.statusLineRunner?.command !== command) { + // A reload can swap one command for another; the old runner would + // otherwise keep executing the previous script until restart. + this.statusLineRunner?.dispose(); + this.statusLineRunner = new StatusLineCommandRunner(command, this.onRefresh); + } } - getGitStatus(): GitStatus | null { - if (!this.state.statusLine.showGit) return null; - return this.gitCache?.getStatus() ?? null; + /** + * Short-lived hint that replaces the rotating toolbar tips on line 1. + * Used by the exit-confirmation double-tap flow to show "Press Ctrl+C + * again to exit" without requiring a toast/overlay subsystem. + * Pass `null` to clear. + */ + setTransientHint(hint: string | null): void { + this.transientHint = hint; } - setTransientHint(hint: string | null): void { - this.updateFallback({ type: 'transient-hint.updated', hint }); + getTransientHint(): string | null { + return this.transientHint; } - setBackgroundCounts(counts: FooterBackgroundCounts): void { - this.syncActionCounts(counts); - this.updateFallback({ - type: 'background-counts.updated', - counts: { - bashTasks: this.backgroundBashTaskCount, - agentTasks: this.backgroundAgentCount, - }, - }); - this.clearStaleSelection(); + /** + * Longer-lived warning for line 2 (e.g. the over-long `/goal` objective + * warning). Unlike the transient hint it has no owner/timeout: the caller + * sets and clears it directly. A transient hint takes precedence while + * present; the warning returns as soon as the transient hint clears. + * Pass `null` to clear. + */ + setWarningHint(hint: string | null): void { + this.warningHint = hint; } - /** Keeps footer keyboard actions aligned with the reducer-owned counts. */ - syncActionCounts(counts: FooterBackgroundCounts): void { + /** + * Sync both background-task badges with live counts. Each non-zero + * count produces its own bracketed badge on line 1; zeros hide them + * independently. + */ + setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void { this.backgroundBashTaskCount = Math.max(0, counts.bashTasks); this.backgroundAgentCount = Math.max(0, counts.agentTasks); - this.clearStaleSelection(); } - setTokenSpeed(tokensPerSecond: number | null, estimated = false): void { - const tokenSpeed = - tokensPerSecond !== null && Number.isFinite(tokensPerSecond) && tokensPerSecond >= 0 - ? tokensPerSecond - : null; - const tokenSpeedEstimated = tokenSpeed !== null && estimated; - if ( - this.fallbackState.status.tokenSpeed === tokenSpeed && - this.fallbackState.status.tokenSpeedEstimated === tokenSpeedEstimated - ) { - return; - } - this.updateFallback({ - type: 'status.updated', - changes: { tokenSpeed, tokenSpeedEstimated }, - }); - this.onRefresh(); - } + invalidate(): void {} - actionItems(): readonly FooterActionItem[] { - const items: FooterActionItem[] = []; - if (this.state.statusLine.showGoal && hasGoalBadge(this.state.goal)) { - items.push({ id: 'goal', label: 'Goal' }); - } - if (this.state.statusLine.showBackgroundTasks && this.backgroundBashTaskCount > 0) { - items.push({ id: 'shell-tasks', label: 'Shell tasks' }); + render(width: number): string[] { + const colors = currentTheme.palette; + const state = this.state; + + // ── Line 1: slots composed per status_line.items, or a user command ── + let line1: string; + let customLine: string | null = null; + if (this.statusLineRunner !== null) { + this.statusLineRunner.maybeRefresh(this.statusLinePayload()); + customLine = this.statusLineRunner.current(); } - if (this.state.statusLine.showBackgroundTasks && this.backgroundAgentCount > 0) { - items.push({ id: 'agents', label: 'Agents' }); + + if (customLine !== null) { + // status_line.command: the first stdout line takes over line 1. + line1 = chalk.hex(colors.text)(customLine); + } else { + const slots = this.buildSlots(colors); + const configured = this.state.statusLine?.items ?? null; + const order: readonly string[] = configured ?? DEFAULT_STATUS_LINE_ITEMS; + const left: string[] = []; + for (const slot of order) { + const pieces = slots[slot as keyof typeof slots]; + if (pieces !== undefined) left.push(...pieces); + } + + const leftLine = left.join(' '); + const leftWidth = visibleWidth(leftLine); + + // Rotating hint tips stay on the right unless they were given an + // inline slot in items (rendered above at their configured position) + // or the user dropped 'tips' from items. + let tipText = ''; + const tipsInline = order.includes('tips'); + const showTips = !tipsInline && (configured === null || configured.includes('tips')); + if (showTips) { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const gap = 2; + const remaining = Math.max(0, width - leftWidth - gap); + if (pair && visibleWidth(pair) <= remaining) { + tipText = pair; + } else if (primary && visibleWidth(primary) <= remaining) { + tipText = primary; + } + } + + if (tipText) { + const pad = width - leftWidth - visibleWidth(tipText); + line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText); + } else if (leftWidth <= width) { + line1 = leftLine; + } else { + line1 = truncateToWidth(leftLine, width, '…'); + } } - return items; - } - selectedActionId(): FooterActionId | null { - return this.selectedAction; - } + // ── Line 2: hint (bottom-left) + context (right) ── + const contextText = formatContextStatus( + state.contextUsage, + state.contextTokens, + state.maxContextTokens, + ); + const contextWidth = visibleWidth(contextText); + let line2: string; + const hint = this.transientHint ?? this.warningHint; + if (hint) { + const maxHintWidth = Math.max(0, width - contextWidth - 1); + const shownHint = + visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…'); + const hintWidth = visibleWidth(shownHint); + const pad = Math.max(0, width - hintWidth - contextWidth); + line2 = + chalk.hex(colors.warning).bold(shownHint) + + ' '.repeat(pad) + + chalk.hex(colors.text)(contextText); + } else { + const leftPad = Math.max(0, width - contextWidth); + line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText); + } - selectFirst(): void { - this.selectAt(0); + return [truncateToWidth(line1, width), truncateToWidth(line2, width)]; } - selectNext(): void { - const items = this.actionItems(); - if (items.length === 0) return; - const current = items.findIndex((item) => item.id === this.selectedAction); - this.selectAt((current + 1 + items.length) % items.length); - } + /** + * Rendered pieces per status-line slot. Empty-content slots (e.g. no goal, + * outside a git repo) yield an empty list so composition just skips them. + */ + private buildSlots(colors: ColorPalette): Record<string, string[]> { + const state = this.state; + const slots: Record<string, string[]> = { + mode: [], + goal: [], + model: [], + tasks: [], + cwd: [], + git: [], + tips: [], + }; - selectPrevious(): void { - const items = this.actionItems(); - if (items.length === 0) return; - const current = items.findIndex((item) => item.id === this.selectedAction); - this.selectAt((current - 1 + items.length) % items.length); - } + { + const { primary, pair } = tipsForIndex(currentTipIndex()); + const tip = pair ?? primary; + if (tip) slots['tips'] = [chalk.hex(colors.textMuted)(tip)]; + } - clearSelection(): void { - if (this.selectedAction === null) return; - this.selectedAction = null; - this.onRefresh(); - } + const modes: string[] = []; + if (state.permissionMode === 'auto') modes.push(chalk.hex(colors.warning).bold('auto')); + if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); + if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); + if (state.dynamicWorkflowMode) modes.push(chalk.hex(colors.accent).bold('dynamic_workflow')); + if (modes.length > 0) slots['mode'] = [modes.join(' ')]; + + const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); + if (goalBadge !== null) slots['goal'] = [goalBadge]; + + const model = modelDisplayName(state); + if (model) { + const effort = state.thinkingEffort; + const rawCurrentModel = state.availableModels[state.model]; + const currentModel = + rawCurrentModel === undefined ? undefined : effectiveModelAlias(rawCurrentModel); + // Only effort-capable models (those declaring support_efforts) show the + // concrete effort; legacy boolean models keep the plain "thinking" suffix. + const hasEfforts = (currentModel?.supportEfforts?.length ?? 0) > 0; + const thinkingLabel = + effort !== 'off' + ? hasEfforts && effort !== 'on' + ? ` thinking: ${effort}` + : ' thinking' + : ''; + const modelLabel = `${model}${thinkingLabel}`; + let renderedModelLabel = chalk.hex(colors.text)(modelLabel); + if (isRainbowDancing()) { + renderedModelLabel = renderDanceFooterModel(modelLabel); + } + slots['model'] = [renderedModelLabel]; + } - dispose(): void { - if (this.freshnessTimer !== null) { - clearInterval(this.freshnessTimer); - this.freshnessTimer = null; + // Background-task badges. `bash-*` tasks (shell processes) and `agent-*` + // tasks (background subagents) stay separate so the user can tell them + // apart at a glance. + const taskBadges: string[] = []; + if (this.backgroundBashTaskCount > 0) { + const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; + taskBadges.push( + chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), + ); } - } + if (this.backgroundAgentCount > 0) { + const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; + taskBadges.push( + chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), + ); + } + slots['tasks'] = taskBadges; - invalidate(): void {} + const cwd = shortenCwd(state.workDir); + if (cwd) slots['cwd'] = [chalk.hex(colors.textDim)(cwd)]; - render(width: number): string[] { - const viewModel = this.hasSharedViewModel - ? this.viewModel - : selectFooterViewModel( - this.fallbackState, - Date.now(), - this.state.statusLine, - ); - return viewModel.rows.flatMap((row) => { - if (row.kind === 'composer' || row.kind === 'status' || row.kind === 'activity') return []; - return [truncateToWidth(renderLegacyRow(row), width, '…')]; - }); - } + const git = this.gitCache.getStatus(); + if (git !== null) slots['git'] = [formatFooterGitBadge(git, colors)]; - private createGitCache(workDir: string): GitStatusCache { - const generation = this.gitCacheGeneration; - return createGitStatusCache(workDir, { - onChange: () => { - if ( - generation !== this.gitCacheGeneration || - !this.state.statusLine.showGit - ) { - return; - } - this.onRefresh(); - }, - }); + return slots; } - private syncGitCache(): void { - if (!this.state.statusLine.showGit) { - this.gitCache = null; - this.gitCacheWorkDir = this.state.workDir; - this.gitCacheGeneration += 1; - return; - } - if ( - this.gitCache !== null && - this.gitCacheWorkDir === this.state.workDir - ) { - return; - } - this.gitCacheWorkDir = this.state.workDir; - this.gitCacheGeneration += 1; - this.gitCache = this.createGitCache(this.state.workDir); - } - - private updateFallback(event: Parameters<typeof reduceFooterState>[1]): void { - this.hasSharedViewModel = false; - this.fallbackState = reduceFooterState(this.fallbackState, event); - this.viewModel = selectFooterViewModel( - this.fallbackState, - Date.now(), - this.state.statusLine, - ); + private statusLinePayload(): StatusLinePayload { + const state = this.state; + return { + model: modelDisplayName(state), + cwd: state.workDir, + gitBranch: this.gitCache.getStatus()?.branch ?? null, + permissionMode: state.permissionMode, + planMode: state.planMode, + contextUsage: state.contextUsage, + contextTokens: state.contextTokens, + maxContextTokens: state.maxContextTokens, + sessionId: state.sessionId, + version: state.version, + }; } private syncGoalClock(goal: AppState['goal']): void { @@ -327,60 +469,34 @@ export class FooterComponent implements Component { this.goalObservedAtMs = Date.now(); } - private syncFooterFreshnessTimer(): void { - const needsGoalFreshness = - this.state.statusLine.showGoal && this.state.goal?.status === 'active'; - const needsElapsedFreshness = - this.state.statusLine.showElapsed && hasActiveElapsed(this.state); - if (needsGoalFreshness || needsElapsedFreshness) { - if (this.freshnessTimer !== null) return; - this.freshnessTimer = setInterval( - () => this.onRefresh(), - FOOTER_FRESHNESS_INTERVAL_MS, - ); - this.freshnessTimer.unref?.(); + private syncGoalTimer(goal: AppState['goal']): void { + if (goal?.status === 'active') { + if (this.goalTimer !== null) return; + this.goalTimer = setInterval(() => { + this.onRefresh(); + }, GOAL_TIMER_INTERVAL_MS); + this.goalTimer.unref?.(); return; } - if (this.freshnessTimer !== null) { - clearInterval(this.freshnessTimer); - this.freshnessTimer = null; - } - } - - private footerGoal(): FooterGoal | null { - const goal = this.state.goal; - if (goal === null || goal === undefined) return null; - return { - status: goal.status, - turnsUsed: goal.turnsUsed, - turnBudget: goal.budget.turnBudget, - wallClockMs: goal.wallClockMs, - observedAtMs: this.goalObservedAtMs, - }; - } - private selectAt(index: number): void { - const action = this.actionItems()[index]; - if (action === undefined || this.selectedAction === action.id) return; - this.selectedAction = action.id; - this.onRefresh(); + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; + } } - private clearStaleSelection(): void { - if (this.selectedAction !== null && !this.actionItems().some((item) => item.id === this.selectedAction)) { - this.clearSelection(); + dispose(): void { + if (this.goalTimer !== null) { + clearInterval(this.goalTimer); + this.goalTimer = null; } } -} -function renderLegacyRow( - row: Extract<FooterViewModelRow, { readonly kind: 'validation' }>, -): string { - return row.level === 'info' ? row.message : `${row.level}: ${row.message}`; -} - -function hasGoalBadge(goal: AppState['goal']): boolean { - return goal?.status === 'active' || goal?.status === 'paused' || goal?.status === 'blocked'; + private goalWallClockMs(goal: AppState['goal']): number | undefined { + if (goal === null || goal === undefined) return undefined; + if (goal.status !== 'active') return goal.wallClockMs; + return goal.wallClockMs + Math.max(0, Date.now() - this.goalObservedAtMs); + } } function goalSnapshotKey(goal: AppState['goal']): string | null { diff --git a/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts b/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts index 39ed97c4..32d41408 100644 --- a/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/pythinker-code/src/tui/components/chrome/gutter-container.ts @@ -7,11 +7,30 @@ * prefixed with `left` plain spaces. Right padding is logical only — we * never emit trailing spaces, since terminals already paint background to * the edge and adding them would just churn the diff renderer. + * + * The render cache below validates per child (component identity + the + * identity of its rendered line array), so structural child-list changes — + * append, splice-removal, in-place replacement — are picked up correctly + * without a tree-wide `invalidate()`. Reserve `invalidate()` for global + * style changes that genuinely dirty every child (e.g. theme switches). */ -import { Container } from '@earendil-works/pi-tui'; +import { Container } from '@pymodel/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; + +import { prefixPreservingOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +interface TranscriptRenderCache { + width: number; + childRefs: Component[]; + childRenderRefs: string[][]; + prefixed: string[][]; + out: string[]; +} export class GutterContainer extends Container { + private renderCache: TranscriptRenderCache | undefined; constructor( private readonly leftPad: number, private readonly rightPad: number, @@ -19,15 +38,58 @@ export class GutterContainer extends Container { super(); } + override invalidate(): void { + this.renderCache = undefined; + super.invalidate(); + } + override render(width: number): string[] { const inner = Math.max(1, width - this.leftPad - this.rightPad); const lead = ' '.repeat(this.leftPad); - const out: string[] = []; + + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childRenderRefs: string[][] = []; + const prefixed: string[][] = []; + let allReused = cacheValid; + + let i = 0; for (const child of this.children) { - for (const line of child.render(inner)) { - out.push(lead + line); + const lines = child.render(inner); + childRefs.push(child); + childRenderRefs.push(lines); + const reused = cacheValid && cache.childRefs[i] === child && cache.childRenderRefs[i] === lines; + if (reused) { + prefixed.push(cache.prefixed[i]!); + } else { + allReused = false; + // OSC 133 zone markers must stay at byte 0 for the fullscreen + // renderer's prompt navigation, so the gutter goes after them. + prefixed.push(lines.map((line) => prefixPreservingOsc133Zone(line, lead))); + } + i++; + } + + let out: string[]; + if (allReused) { + out = cache!.out; + } else { + out = []; + for (const lines of prefixed) { + for (const line of lines) out.push(line); } } + + if (isRenderCacheEnabled()) { + this.renderCache = { width, childRefs, childRenderRefs, prefixed, out }; + } + return out; } } diff --git a/apps/pythinker-code/src/tui/components/chrome/moon-loader.ts b/apps/pythinker-code/src/tui/components/chrome/moon-loader.ts new file mode 100644 index 00000000..6838a1c2 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/chrome/moon-loader.ts @@ -0,0 +1,107 @@ +import { Text, visibleWidth } from '@pymodel/pi-tui'; +import type { TUI } from '@pymodel/pi-tui'; + +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, + MOON_SPINNER_FRAMES, + MOON_SPINNER_INTERVAL_MS, +} from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; + +export type SpinnerStyle = 'moon' | 'braille'; + +export class MoonLoader extends Text { + private currentFrame = 0; + private intervalId: ReturnType<typeof setInterval> | null = null; + private ui: TUI; + private frames: string[]; + private interval: number; + private colorFn?: (s: string) => string; + private label: string; + private displayText = ''; + // Inline text used when the spinner is embedded into another line (e.g. the + // agent-dynamic_workflow progress status line). It intentionally excludes the tip: the + // tip is only rendered when the loader sits on its own row in the activity + // pane, otherwise it would get squeezed against whatever follows the inline + // spinner (like the dynamic_workflow progress bar). + private inlineText = ''; + private tip: string = ''; + private availableWidth = 0; + + constructor( + ui: TUI, + style: SpinnerStyle = 'moon', + colorFn?: (s: string) => string, + label: string = '', + ) { + super('', 1, 0); + this.ui = ui; + this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES]; + this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS; + this.colorFn = colorFn; + this.label = label; + this.start(); + } + + start(): void { + this.updateDisplay(); + this.intervalId = setInterval(() => { + this.currentFrame = (this.currentFrame + 1) % this.frames.length; + this.updateDisplay(); + }, this.interval); + } + + stop(): void { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + dispose(): void { + this.stop(); + } + + setLabel(label: string): void { + this.label = label; + this.updateDisplay(); + } + + setColorFn(colorFn: (s: string) => string): void { + this.colorFn = colorFn; + this.updateDisplay(); + } + + setTip(tip: string): void { + this.tip = tip; + this.updateDisplay(); + } + + setAvailableWidth(width: number): void { + if (this.availableWidth === width) return; + this.availableWidth = width; + this.updateDisplay(); + } + + renderInline(): string { + return this.inlineText; + } + + private updateDisplay(): void { + const frame = this.frames[this.currentFrame]!; + const coloredFrame = this.colorFn ? this.colorFn(frame) : frame; + const baseText = this.label ? `${coloredFrame} ${this.label}` : coloredFrame; + this.inlineText = baseText; + let text = baseText; + if (this.tip) { + const withTip = baseText + currentTheme.fg('textDim', this.tip); + if (this.availableWidth === 0 || visibleWidth(withTip) <= this.availableWidth) { + text = withTip; + } + } + this.displayText = text; + this.setText(this.displayText); + this.ui.requestRender(); + } +} diff --git a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts b/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts deleted file mode 100644 index ef036139..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/pythinker-logo.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Terminal rendering of the Pythinker robot mark. - * Derived from apps/pythinker-web/public/pythinker_animated.svg and the - * installer logo art in apps/pythinker-web/public/install.sh. - */ - -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -/** Brand palette sampled from pythinker_animated.svg. */ -export const PYTHINKER_LOGO_COLORS = { - body: '#213853', - bodyMid: '#495F7C', - face: '#F9F2F5', - faceDim: '#DFDCDF', - accent: '#8CA0F5', - accentDeep: '#7B8CE8', - /** Antenna bulb — coral, so the head's highlight reads against the - * periwinkle brand rather than blending into it. Kept separate from - * `ear` so the two can diverge without touching the shared accent. */ - antenna: '#EE9983', - /** Side ears — coral, matching the antenna bulb. */ - ear: '#EE9983', - eye: '#AFE3F1', - eyeRing: '#3A506D', -} as const; - -/** Plain-text robot mark — five rows, fixed layout. */ -export const PYTHINKER_LOGO_LINES = [ - ' ●', - ' │', - ' ▛▀▀▀▀▀▀▀▜', - ' ◖█ ◉ ◉ █◗', - ' ▙▄▄▄≡▄▄▄▟', -] as const; - -export const PYTHINKER_LOGO_WIDTH = Math.max( - ...PYTHINKER_LOGO_LINES.map((row) => visibleWidth(row)), -); - -/** Row/column of each eye on the face line — matches install.sh / install.ps1 grid. */ -export const LOGO_EYE_ROW = 3; -export const LOGO_LEFT_EYE_COL = 4; -export const LOGO_RIGHT_EYE_COL = 8; - -/** Antenna bulb cell on row 0. */ -export const LOGO_ANTENNA_ROW = 0; -export const LOGO_ANTENNA_COL = 6; - -/** Antenna spin frames — half-shaded circles read as a clockwise rotation. */ -export const ANTENNA_SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] as const; - -/** Eye phases from the installer `_blink_eyes` / `Blink-Eye` sequence. */ -export type EyeBlinkPhase = 'open' | 'glance' | 'closed' | 'open-shine'; - -export interface LogoEyeBlinkState { - readonly left: EyeBlinkPhase; - readonly right: EyeBlinkPhase; -} - -export const LOGO_EYES_OPEN: LogoEyeBlinkState = { left: 'open', right: 'open' }; - -/** Bright flash tone used when an eye opens — installer `$SHINE`. */ -export const PYTHINKER_LOGO_EYE_SHINE = '#FFFFFF'; - -type LogoStyler = (text: string) => string; - -function segment(line: string, ranges: Array<[number, number, LogoStyler]>): string { - let out = ''; - let cursor = 0; - for (const [start, end, style] of ranges) { - if (cursor < start) { - out += line.slice(cursor, start); - } - out += style(line.slice(start, end)); - cursor = end; - } - if (cursor < line.length) { - out += line.slice(cursor); - } - return out; -} - -function body(text: string): string { - return chalk.hex(PYTHINKER_LOGO_COLORS.body)(text); -} - -function face(text: string): string { - return chalk.hex(PYTHINKER_LOGO_COLORS.face)(text); -} - -function antenna(text: string): string { - return chalk.hex(PYTHINKER_LOGO_COLORS.antenna)(text); -} - -function ear(text: string): string { - return chalk.hex(PYTHINKER_LOGO_COLORS.ear)(text); -} - -function eye(text: string): string { - return chalk.hex(PYTHINKER_LOGO_COLORS.eye)(text); -} - -function eyeShine(text: string): string { - return chalk.hex(PYTHINKER_LOGO_EYE_SHINE)(text); -} - -function eyeGlyphStyle(phase: EyeBlinkPhase): LogoStyler { - switch (phase) { - case 'glance': - case 'open-shine': - return eyeShine; - case 'closed': - return eye; - case 'open': - return eye; - } -} - -function eyeGlyphChar(phase: EyeBlinkPhase): string { - return phase === 'closed' ? '─' : '◉'; -} - -/** Paint the face row with per-eye blink phases (installer `_blink_eyes` design). */ -export function renderPythinkerLogoEyeRow(state: LogoEyeBlinkState): string { - if (state.left === 'open' && state.right === 'open') { - return renderPythinkerLogoLine(LOGO_EYE_ROW); - } - - const plain = PYTHINKER_LOGO_LINES[LOGO_EYE_ROW]; - const chars = Array.from(plain); - - const placeEye = (col: number, phase: EyeBlinkPhase): void => { - if (phase === 'glance') { - chars[col - 1] = eyeGlyphChar(phase); - if (col < chars.length) chars[col] = ' '; - return; - } - chars[col] = eyeGlyphChar(phase); - }; - - placeEye(LOGO_LEFT_EYE_COL, state.left); - placeEye(LOGO_RIGHT_EYE_COL, state.right); - const line = chars.join(''); - - const ranges: Array<[number, number, LogoStyler]> = [ - [1, 2, ear], - [2, 3, body], - [10, 11, body], - [11, 12, ear], - ]; - - const stampEye = (col: number, phase: EyeBlinkPhase): void => { - const glyph = eyeGlyphChar(phase); - const at = phase === 'glance' ? col - 1 : col; - const end = at + glyph.length; - ranges.push([at, end, eyeGlyphStyle(phase)]); - }; - - stampEye(LOGO_LEFT_EYE_COL, state.left); - stampEye(LOGO_RIGHT_EYE_COL, state.right); - - ranges.sort((a, b) => a[0] - b[0]); - return segment(line, ranges); -} - -/** Paint the antenna row with a spinner frame in place of the static bulb. */ -export function renderPythinkerLogoAntennaRow(frameIndex: number): string { - const frame = - ANTENNA_SPINNER_FRAMES[frameIndex % ANTENNA_SPINNER_FRAMES.length] ?? '●'; - const chars = Array.from(PYTHINKER_LOGO_LINES[LOGO_ANTENNA_ROW]); - chars[LOGO_ANTENNA_COL] = frame; - const line = chars.join(''); - return segment(line, [ - [LOGO_ANTENNA_COL, LOGO_ANTENNA_COL + frame.length, antenna], - ]); -} - -export function renderPythinkerLogoWithEyes( - state: LogoEyeBlinkState = LOGO_EYES_OPEN, - antennaFrame?: number, -): string[] { - return PYTHINKER_LOGO_LINES.map((_, index) => { - if (index === LOGO_EYE_ROW) return renderPythinkerLogoEyeRow(state); - if (index === LOGO_ANTENNA_ROW && antennaFrame !== undefined) { - return renderPythinkerLogoAntennaRow(antennaFrame); - } - return renderPythinkerLogoLine(index); - }); -} - -/** Paint one logo row with SVG-accurate brand colors. */ -export function renderPythinkerLogoLine(index: number): string { - switch (index) { - case 0: - return segment(PYTHINKER_LOGO_LINES[0], [[6, 7, antenna]]); - case 1: - return segment(PYTHINKER_LOGO_LINES[1], [[6, 7, body]]); - case 2: - return segment(PYTHINKER_LOGO_LINES[2], [ - [2, 3, body], - [3, 10, face], - [10, 11, body], - ]); - case 3: - return segment(PYTHINKER_LOGO_LINES[3], [ - [1, 2, ear], - [2, 3, body], - [4, 5, eye], - [8, 9, eye], - [10, 11, body], - [11, 12, ear], - ]); - case 4: - return segment(PYTHINKER_LOGO_LINES[4], [ - [2, 3, body], - [3, 6, body], - [6, 7, face], - [7, 10, body], - [10, 11, body], - ]); - default: - return PYTHINKER_LOGO_LINES[index] ?? ''; - } -} - -export function renderPythinkerLogo(): string[] { - return PYTHINKER_LOGO_LINES.map((_, index) => renderPythinkerLogoLine(index)); -} - -function padColored(text: string, targetWidth: number): string { - const vis = visibleWidth(text); - if (vis >= targetWidth) return text; - return text + ' '.repeat(targetWidth - vis); -} - -export interface WelcomeHeaderSideText { - eyebrow: string; - title: string; - tagline: string; - prompt: string; -} - -function resolveSideTextRows(sideText: WelcomeHeaderSideText): string[] { - const slots = [sideText.eyebrow, sideText.title, sideText.tagline, sideText.prompt]; - const firstSlot = sideText.eyebrow; - const sideRows = Array<string>(PYTHINKER_LOGO_LINES.length).fill(''); - const content = slots.filter((text) => text.length > 0); - if (content.length === 0) { - return sideRows; - } - - const welcomeCopyLayout = - firstSlot.length === 0 && - content.length === 3 && - slots.slice(1).every((text) => text.length > 0); - - const startRow = welcomeCopyLayout - ? PYTHINKER_LOGO_LINES.length - content.length - : firstSlot.length > 0 - ? 0 - : Math.max(0, Math.floor((PYTHINKER_LOGO_LINES.length - content.length) / 2)); - - for (let index = 0; index < content.length; index++) { - sideRows[startRow + index] = content[index]!; - } - - return sideRows; -} - -export function buildLogoHeaderRows( - textWidth: number, - sideText: WelcomeHeaderSideText, - colorLogoLine: (index: number, plain: string) => string, - gap = ' ', -): string[] { - const sideRows = resolveSideTextRows(sideText); - const rows: string[] = []; - - for (let index = 0; index < PYTHINKER_LOGO_LINES.length; index++) { - const plainLogoLine = PYTHINKER_LOGO_LINES[index]; - if (plainLogoLine === undefined) continue; - const logo = padColored( - colorLogoLine(index, plainLogoLine), - PYTHINKER_LOGO_WIDTH, - ); - const text = truncateToWidth(sideRows[index] ?? '', textWidth, '…'); - rows.push(logo + gap + text); - } - - return rows; -} diff --git a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts b/apps/pythinker-code/src/tui/components/chrome/status-bar.ts deleted file mode 100644 index 59af57fa..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/status-bar.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { sep } from 'node:path'; - -import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import type { StatusLineConfig } from '#/tui/config'; -import { - formatTokenSpeed, - type FooterStatus, -} from '#/tui/runtime/footer/footer-model'; -import { currentTheme } from '#/tui/theme'; -import { themeFromHexChannels } from '#/tui/theme/terminal-background'; -import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels'; -import { sessionAccentHex } from '#/tui/utils/session-accent'; - -export type StatusBarStatus = Pick< - FooterStatus, - | 'model' - | 'thinkingLevel' - | 'cwd' - | 'homeDir' - | 'permissionMode' - | 'planMode' - | 'fastMode' - | 'dynamicWorkflowMode' - | 'tokenSpeed' - | 'tokenSpeedEstimated' -> & { - readonly extras: readonly string[]; - /** The `extras` entry that carries the update notice, painted in `warning`. */ - readonly updateExtra?: string; - readonly sessionKey: string; - readonly statusLine: StatusLineConfig; -}; - -export class StatusBarComponent implements Component { - private status: StatusBarStatus | undefined; - - update(status: StatusBarStatus): void { - this.status = status; - } - - render(width: number): string[] { - const status = this.status; - if (status === undefined) return []; - - const effortSuffix = status.statusLine.showEffort && status.thinkingLevel !== 'off' - ? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg( - effortColorToken(status.thinkingLevel), - shortEffortLabel(status.thinkingLevel), - )}` - : ''; - const fastSuffix = status.statusLine.showModes && status.fastMode - ? `${currentTheme.fg('textDim', ' · ')}${currentTheme.fg('modeFast', '↯ fast')}` - : ''; - const speed = status.statusLine.showTokenSpeed ? formatTokenSpeed(status) : null; - const modelChip = status.statusLine.showModel - ? chip( - `${currentTheme.fg('text', status.model)}${effortSuffix}${fastSuffix}${ - speed === null ? '' : currentTheme.fg('textDim', ` · ${speed}`) - }`, - ) - : undefined; - let modesChip = status.statusLine.showModes ? renderModesChip(status) : undefined; - const extraChips = status.extras.map((extra) => - chip(currentTheme.fg(extra === status.updateExtra ? 'warning' : 'textDim', extra)), - ); - let cwdChip: string | undefined = chip( - currentTheme.fg('textDim', shortenCwd(status.cwd, status.homeDir)), - ); - const left = (): string => - [modelChip, modesChip, ...extraChips] - .filter((item): item is string => item !== undefined) - .join(' '); - const fullGapWidth = - width - visibleWidth(left()) - (cwdChip === undefined ? 1 : visibleWidth(cwdChip) + 2); - - let line: string; - if (fullGapWidth > 0) { - const background = currentTheme.color('background'); - const mode = themeFromHexChannels( - background.slice(1, 3), - background.slice(3, 5), - background.slice(5, 7), - ); - const gap = chalk.hex(sessionAccentHex(status.sessionKey, mode))('─'.repeat(fullGapWidth)); - line = cwdChip === undefined ? `${left()} ${gap}` : `${left()} ${gap} ${cwdChip}`; - } else { - line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; - while (visibleWidth(line) > width && extraChips.length > 0) { - extraChips.pop(); - line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; - } - if (visibleWidth(line) > width && modesChip !== undefined) { - modesChip = undefined; - line = `${left()}${cwdChip === undefined ? '' : ` ${cwdChip}`}`; - } - if (visibleWidth(line) > width && cwdChip !== undefined) { - cwdChip = undefined; - line = left(); - } - } - - return [truncateToWidth(line, Math.max(0, width))]; - } - - invalidate(): void {} -} - -function chip(content: string): string { - return currentTheme.bg('surfaceHighlight', ` ${content} `); -} - -function renderModesChip(status: StatusBarStatus): string | undefined { - const modes: string[] = []; - if (status.planMode) modes.push(currentTheme.fg('modePlan', 'plan')); - if (status.permissionMode === 'auto') modes.push(currentTheme.fg('modePermission', 'auto')); - if (status.permissionMode === 'yolo') modes.push(currentTheme.fg('error', 'yolo')); - if (status.dynamicWorkflowMode) modes.push(currentTheme.fg('accent', 'workflow')); - return modes.length === 0 ? undefined : chip(modes.join(' ')); -} - -function shortenCwd(cwd: string, homeDir: string | null): string { - const path = homeDir !== null && homeDir.length > 0 - ? cwd === homeDir - ? '~' - : cwd.startsWith(`${homeDir}${sep}`) - ? `~${cwd.slice(homeDir.length)}` - : cwd - : cwd; - const segments = path.startsWith(`~${sep}`) - ? path.slice(2).split(sep) - : path.startsWith(sep) - ? path.slice(1).split(sep) - : []; - return segments.length > 2 ? `…${sep}${segments.slice(-2).join(sep)}` : path; -} diff --git a/apps/pythinker-code/src/tui/components/chrome/todo-panel.ts b/apps/pythinker-code/src/tui/components/chrome/todo-panel.ts index 0263df5d..ac9cfd3e 100644 --- a/apps/pythinker-code/src/tui/components/chrome/todo-panel.ts +++ b/apps/pythinker-code/src/tui/components/chrome/todo-panel.ts @@ -9,8 +9,8 @@ * is issued. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { currentTheme } from '#/tui/theme'; @@ -20,7 +20,6 @@ export type TodoStatus = 'pending' | 'in_progress' | 'done'; export interface TodoItem { readonly title: string; - readonly activeForm?: string; readonly status: TodoStatus; } @@ -29,6 +28,7 @@ const MAX_VISIBLE = 5; export interface VisibleTodos { readonly rows: readonly TodoItem[]; readonly hidden: number; + readonly hiddenCounts: Record<TodoStatus, number>; } /** @@ -50,7 +50,11 @@ export interface VisibleTodos { */ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { if (todos.length <= MAX_VISIBLE) { - return { rows: [...todos], hidden: 0 }; + return { + rows: [...todos], + hidden: 0, + hiddenCounts: { done: 0, in_progress: 0, pending: 0 }, + }; } const inProgress: number[] = []; @@ -92,21 +96,27 @@ export function selectVisibleTodos(todos: readonly TodoItem[]): VisibleTodos { } const sortedIdx = [...picked].toSorted((a, b) => a - b); + + const hiddenCounts: Record<TodoStatus, number> = { done: 0, in_progress: 0, pending: 0 }; + for (const [i, todo] of todos.entries()) { + if (!picked.has(i)) { + hiddenCounts[todo.status] += 1; + } + } + return { rows: sortedIdx.map((i) => todos[i] as TodoItem), hidden: todos.length - sortedIdx.length, + hiddenCounts, }; } export class TodoPanelComponent implements Component { private todos: readonly TodoItem[] = []; + private expanded = false; setTodos(todos: readonly TodoItem[]): void { - this.todos = todos.map((t) => ({ - title: t.title, - activeForm: t.activeForm, - status: t.status, - })); + this.todos = todos.map((t) => ({ title: t.title, status: t.status })); } getTodos(): readonly TodoItem[] { @@ -115,29 +125,57 @@ export class TodoPanelComponent implements Component { clear(): void { this.todos = []; + this.expanded = false; } isEmpty(): boolean { return this.todos.length === 0; } + /** True when the list exceeds the collapsed cap, i.e. there is something to expand. */ + hasOverflow(): boolean { + return this.todos.length > MAX_VISIBLE; + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded; + } + + toggleExpanded(): void { + this.expanded = !this.expanded; + } + invalidate(): void {} render(width: number): string[] { if (this.todos.length === 0) return []; const c = currentTheme.palette; - const { rows, hidden } = selectVisibleTodos(this.todos); - const done = this.todos.filter((todo) => todo.status === 'done').length; const lines: string[] = [ chalk.hex(c.border)('─'.repeat(width)), - chalk.hex(c.primary).bold(' Todo') + - chalk.hex(c.textMuted)(` · ${String(done)}/${String(this.todos.length)} done`), + chalk.hex(c.primary).bold(' Todo'), ]; - for (const todo of rows) { - lines.push(renderRow(todo, c)); - } - if (hidden > 0) { - lines.push(chalk.hex(c.textDim)(` … +${hidden} more`)); + + if (this.expanded) { + for (const todo of this.todos) { + lines.push(renderRow(todo, c)); + } + if (this.todos.length > MAX_VISIBLE) { + lines.push( + chalk.hex(c.textDim)(` all ${String(this.todos.length)} items · ctrl+t to collapse`), + ); + } + } else { + const { rows, hidden, hiddenCounts } = selectVisibleTodos(this.todos); + for (const todo of rows) { + lines.push(renderRow(todo, c)); + } + if (hidden > 0) { + const distribution = formatHiddenCounts(hiddenCounts); + const suffix = distribution.length > 0 ? ` (${distribution})` : ''; + lines.push( + chalk.hex(c.textDim)(` … +${hidden} more${suffix} · ctrl+t to expand`), + ); + } } return lines.map((line) => truncateToWidth(line, width)); @@ -146,19 +184,14 @@ export class TodoPanelComponent implements Component { function renderRow(todo: TodoItem, colors: ColorPalette): string { const marker = statusMarker(todo.status, colors); - // Only the active item switches to present-continuous copy; pending rows stay imperative. - const title = - todo.status === 'in_progress' && todo.activeForm !== undefined - ? todo.activeForm - : todo.title; - const titleStyled = styleTitle(title, todo.status, colors); + const titleStyled = styleTitle(todo.title, todo.status, colors); return ` ${marker} ${titleStyled}`; } function statusMarker(status: TodoStatus, colors: ColorPalette): string { switch (status) { case 'in_progress': - return chalk.hex(colors.success).bold('●'); + return chalk.hex(colors.primary).bold('●'); case 'done': return chalk.hex(colors.success)('✓'); case 'pending': @@ -176,3 +209,15 @@ function styleTitle(title: string, status: TodoStatus, colors: ColorPalette): st return chalk.hex(colors.text)(title); } } + +const STATUS_LABELS: readonly { status: TodoStatus; label: string }[] = [ + { status: 'done', label: 'done' }, + { status: 'in_progress', label: 'in progress' }, + { status: 'pending', label: 'pending' }, +]; + +export function formatHiddenCounts(counts: Record<TodoStatus, number>): string { + return STATUS_LABELS.filter(({ status }) => counts[status] > 0) + .map(({ status, label }) => `${counts[status]} ${label}`) + .join(' · '); +} diff --git a/apps/pythinker-code/src/tui/components/chrome/transcript-container.ts b/apps/pythinker-code/src/tui/components/chrome/transcript-container.ts deleted file mode 100644 index d72e78ed..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/transcript-container.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { Component } from '@earendil-works/pi-tui'; - -import { GutterContainer } from './gutter-container'; -import { - getTranscriptChildMetadata, - type TranscriptChildMetadata, - type TranscriptChildRole, - markTranscriptChild, -} from '../../utils/transcript-component-metadata'; - -export type { TranscriptChildMetadata, TranscriptChildRole } from '../../utils/transcript-component-metadata'; - -export class TranscriptContainer extends GutterContainer { - private readonly leftGutter: number; - private readonly rightGutter: number; - private renderedRowsAfterChildDepth = 0; - - constructor(leftPad: number, rightPad: number) { - super(leftPad, rightPad); - this.leftGutter = leftPad; - this.rightGutter = rightPad; - } - - addTranscriptChild(child: Component, metadata: TranscriptChildMetadata): void { - markTranscriptChild(child, metadata); - super.addChild(child); - } - - addTranscriptChildAt( - index: number, - child: Component, - metadata: TranscriptChildMetadata, - ): void { - markTranscriptChild(child, metadata); - this.children.splice(Math.max(0, Math.min(index, this.children.length)), 0, child); - this.invalidate(); - } - replaceTranscriptChild( - current: Component, - next: Component, - metadata: TranscriptChildMetadata, - ): void { - const index = this.children.indexOf(current); - if (index < 0) { - this.addTranscriptChild(next, metadata); - return; - } - markTranscriptChild(next, metadata); - this.children[index] = next; - this.invalidate(); - } - override addChild(_child: Component): void { - throw new Error('TranscriptContainer requires addTranscriptChild() metadata'); - } - - renderedRowsAfterChild(width: number, child: Component): number { - const index = this.children.indexOf(child); - if (index < 0) return 0; - const metadata = getTranscriptChildMetadata(child); - if (metadata === undefined) { - throw new Error('Transcript child was added without metadata'); - } - const inner = Math.max(1, width - this.leftGutter - this.rightGutter); - const nestedRender = this.renderedRowsAfterChildDepth > 0; - this.renderedRowsAfterChildDepth += 1; - try { - let rows = 0; - let previousDurable = nestedRender ? isDurable(metadata.role) : false; - if (!nestedRender) { - for (let previousIndex = index; previousIndex >= 0; previousIndex -= 1) { - const previousChild = this.children[previousIndex]!; - const previousMetadata = getTranscriptChildMetadata(previousChild); - if (previousMetadata === undefined) { - throw new Error('Transcript child was added without metadata'); - } - const previousRows = this.normalizeRows( - previousChild.render(inner), - previousMetadata, - ); - if (previousRows.length === 0) continue; - previousDurable = isDurable(previousMetadata.role); - break; - } - } - for (let childIndex = index + 1; childIndex < this.children.length; childIndex += 1) { - const followingChild = this.children[childIndex]!; - const followingMetadata = getTranscriptChildMetadata(followingChild); - if (followingMetadata === undefined) { - throw new Error('Transcript child was added without metadata'); - } - const followingRows = this.normalizeRows( - followingChild.render(inner), - followingMetadata, - ); - if (followingRows.length === 0) continue; - if (previousDurable && isDurable(followingMetadata.role)) rows += 1; - rows += followingRows.length; - previousDurable = isDurable(followingMetadata.role); - } - return rows; - } finally { - this.renderedRowsAfterChildDepth -= 1; - } - } - - override render(width: number): string[] { - const inner = Math.max(1, width - this.leftGutter - this.rightGutter); - const lead = ' '.repeat(this.leftGutter); - const rows: string[] = []; - let hasVisible = false; - let previousDurable = false; - for (const child of this.children) { - const metadata = getTranscriptChildMetadata(child); - if (metadata === undefined) { - throw new Error('Transcript child was added without metadata'); - } - const childRows = this.normalizeRows(child.render(inner), metadata); - if (childRows.length === 0) continue; - if (hasVisible && previousDurable && isDurable(metadata.role)) rows.push(lead); - for (const row of childRows) rows.push(lead + row); - hasVisible = true; - previousDurable = isDurable(metadata.role); - } - return rows; - } - - private normalizeRows( - rows: readonly string[], - metadata: TranscriptChildMetadata, - ): readonly string[] { - if (metadata.edgeBlankPolicy === 'preserve') return rows; - let start = 0; - let end = rows.length; - while (start < end && isPlainBlank(rows[start]!)) start += 1; - while (end > start && isPlainBlank(rows[end - 1]!)) end -= 1; - return rows.slice(start, end); - } -} - -function isPlainBlank(value: string): boolean { - return /^[ ]*$/u.test(value); -} - -function isDurable(role: TranscriptChildRole): boolean { - return role === 'durable' || role === 'live-durable'; -} diff --git a/apps/pythinker-code/src/tui/components/chrome/transcript-viewport.ts b/apps/pythinker-code/src/tui/components/chrome/transcript-viewport.ts deleted file mode 100644 index 92328c6b..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/transcript-viewport.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * TranscriptViewport — fixed-height window over the transcript. - * - * Renders the wrapped transcript container into a flat line buffer and - * emits exactly `height` lines per frame: a slice of that buffer, padded - * at the bottom with blanks so the layout root can pin the editor and - * footer to the terminal's last rows. The viewport owns transcript - * scrolling (mouse wheel via MouseController) and drag selection; - * native terminal scrollback is intentionally unused in the fixed - * layout, so the transcript buffer here is the only history. - */ - -import { - type Component, - type Container, - sliceByColumn, - visibleWidth, -} from '@earendil-works/pi-tui'; - -/** A cell position inside the transcript buffer (0-based row/column). */ -export interface ViewportCell { - readonly row: number; - readonly col: number; -} - -/** An ordered selection range within the transcript buffer. */ -export interface ViewportSelection { - readonly start: ViewportCell; - readonly end: ViewportCell; -} - -const INVERSE_ON = '\u001B[7m'; -const INVERSE_OFF = '\u001B[27m'; - -// SGR / private CSI sequences and OSC strings (e.g. hyperlinks). Used to -// recover plain text for clipboard copies. -// oxlint-disable-next-line no-control-regex -- ESC/BEL are required to match ANSI sequences -const ANSI_PATTERN = /\u001B\[[0-9;?]*[a-zA-Z]|\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/gu; - -/** Removes SGR/OSC escape sequences from a line, keeping plain text for clipboard copies. */ -export function stripAnsi(line: string): string { - return line.replace(ANSI_PATTERN, ''); -} - -export class TranscriptViewport implements Component { - private height = 1; - /** Lines the window is shifted up from the tail; 0 pins it to the bottom. */ - private scrollOffset = 0; - private lastBuffer: string[] = []; - private anchor: ViewportCell | undefined; - private active: ViewportCell | undefined; - /** 0-based screen column range of the "N more" chip, on the region's last row. */ - private chipCols: { start: number; end: number } | undefined; - - constructor(private readonly transcript: Container) {} - - setHeight(height: number): void { - this.height = Math.max(1, height); - } - - getHeight(): number { - return this.height; - } - - getScrollOffset(): number { - return this.scrollOffset; - } - - isPinned(): boolean { - return this.scrollOffset === 0; - } - - scrollBy(delta: number): void { - this.scrollOffset = this.clampOffset(this.scrollOffset + delta); - } - - scrollToBottom(): void { - this.scrollOffset = 0; - } - - private clampOffset(offset: number): number { - const max = Math.max(0, this.lastBuffer.length - this.height); - return Math.min(Math.max(0, offset), max); - } - - // --- Selection ------------------------------------------------------- - - setSelection(anchor: ViewportCell, active: ViewportCell): void { - this.anchor = anchor; - this.active = active; - } - - extendSelection(active: ViewportCell): void { - if (this.anchor === undefined) return; - this.active = active; - } - - clearSelection(): void { - this.anchor = undefined; - this.active = undefined; - } - - hasSelection(): boolean { - return this.orderedSelection() !== undefined; - } - - private orderedSelection(): ViewportSelection | undefined { - if (this.anchor === undefined || this.active === undefined) return undefined; - const before = (a: ViewportCell, b: ViewportCell): number => a.row - b.row || a.col - b.col; - const ordered = - before(this.anchor, this.active) <= 0 - ? { start: this.anchor, end: this.active } - : { start: this.active, end: this.anchor }; - // A bare click (anchor == active) is not a selection. - if (ordered.start.row === ordered.end.row && ordered.start.col === ordered.end.col) { - return undefined; - } - return ordered; - } - - /** Map a 1-based screen position to transcript buffer coordinates. */ - screenToBuffer(screenRow: number, screenCol: number): ViewportCell | undefined { - if (screenRow < 1 || screenRow > this.height) return undefined; - if (this.lastBuffer.length === 0) return undefined; - // Derive the window top from the last buffer each call so wheel scrolls - // apply even before the next render refreshes `lastBuffer`. - const top = Math.max(0, this.lastBuffer.length - this.height) - this.scrollOffset; - const row = Math.min(top + (screenRow - 1), this.lastBuffer.length - 1); - return { row, col: Math.max(0, screenCol - 1) }; - } - - /** True when the 1-based screen position lands on the "N more" chip. */ - chipHit(screenRow: number, screenCol: number): boolean { - if (this.scrollOffset === 0 || this.chipCols === undefined) return false; - if (screenRow !== this.height) return false; - const col = screenCol - 1; - return col >= this.chipCols.start && col < this.chipCols.end; - } - - /** Plain-text contents of the current selection, for the clipboard. */ - extractSelectionText(): string { - const selection = this.orderedSelection(); - if (selection === undefined) return ''; - const lines: string[] = []; - const lastRow = Math.min(selection.end.row, this.lastBuffer.length - 1); - for (let row = selection.start.row; row <= lastRow; row++) { - const line = this.lastBuffer[row] ?? ''; - const lineWidth = visibleWidth(line); - const from = row === selection.start.row ? Math.min(selection.start.col, lineWidth) : 0; - const to = row === selection.end.row ? Math.min(selection.end.col, lineWidth) : lineWidth; - lines.push(stripAnsi(sliceByColumn(line, from, Math.max(0, to - from))).trimEnd()); - } - return lines.join('\n').trimEnd(); - } - - private applySelectionHighlight( - line: string, - bufferRow: number, - selection: ViewportSelection, - ): string { - if (bufferRow < selection.start.row || bufferRow > selection.end.row) return line; - const lineWidth = visibleWidth(line); - if (lineWidth === 0) return line; - const from = bufferRow === selection.start.row ? Math.min(selection.start.col, lineWidth) : 0; - const to = bufferRow === selection.end.row ? Math.min(selection.end.col, lineWidth) : lineWidth; - if (to <= from) return line; - return ( - sliceByColumn(line, 0, from) + - INVERSE_ON + - sliceByColumn(line, from, to - from) + - INVERSE_OFF + - sliceByColumn(line, to, lineWidth - to) - ); - } - - // --- Render ---------------------------------------------------------- - - render(width: number): string[] { - const buffer = this.transcript.render(width); - // While scrolled up, appended lines would otherwise shift the visible - // window (top = maxTop - offset); grow the offset to hold it steady. - if (this.scrollOffset > 0 && buffer.length > this.lastBuffer.length) { - this.scrollOffset += buffer.length - this.lastBuffer.length; - } - this.lastBuffer = buffer; - this.scrollOffset = this.clampOffset(this.scrollOffset); - const top = Math.max(0, buffer.length - this.height) - this.scrollOffset; - - const selection = this.orderedSelection(); - const lines: string[] = []; - for (let i = 0; i < this.height; i++) { - const bufferRow = top + i; - let line = bufferRow < buffer.length ? (buffer[bufferRow] ?? '') : ''; - if (selection !== undefined) { - line = this.applySelectionHighlight(line, bufferRow, selection); - } - lines.push(line); - } - - if (this.scrollOffset > 0 && this.height > 0) { - const label = ` ▼ ${String(this.scrollOffset)} more `; - const labelWidth = visibleWidth(label); - const last = lines[this.height - 1] ?? ''; - const padded = last + ' '.repeat(Math.max(0, width - visibleWidth(last))); - lines[this.height - 1] = - sliceByColumn(padded, 0, Math.max(0, width - labelWidth)) + INVERSE_ON + label + INVERSE_OFF; - this.chipCols = { start: Math.max(0, width - labelWidth), end: width }; - } else { - this.chipCols = undefined; - } - - return lines; - } - - invalidate(): void { - this.transcript.invalidate(); - } -} diff --git a/apps/pythinker-code/src/tui/components/chrome/viewport-layout.ts b/apps/pythinker-code/src/tui/components/chrome/viewport-layout.ts deleted file mode 100644 index 19b488f5..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/viewport-layout.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * ViewportLayoutRoot — fixed full-height layout root (`layout = "fixed"`). - * - * Emits exactly `terminal.rows` lines every frame: the transcript - * viewport on top, then the chrome (activity / todo / queue / btw panels - * and the editor), then the footer. The editor therefore stays pinned to - * the bottom of the screen from the first frame, and transcript content - * scrolls inside the viewport region above it. The footer stays hidden - * until init succeeds (same rule as the legacy inline layout). - */ - -import type { Component, Terminal } from '@earendil-works/pi-tui'; - -import type { TranscriptViewport } from './transcript-viewport'; - -// Never let the chrome squeeze the transcript away entirely; on very -// short terminals the overflow falls back to pi-tui's normal scrolling. -const MIN_VIEWPORT_HEIGHT = 3; - -export class ViewportLayoutRoot implements Component { - private footerMounted = false; - - constructor( - private readonly terminal: Terminal, - private readonly viewport: TranscriptViewport, - private readonly chrome: readonly Component[], - private readonly footer: Component, - ) {} - - setFooterMounted(mounted: boolean): void { - this.footerMounted = mounted; - } - - render(width: number): string[] { - const { chromeLines, footerLines } = this.renderChromeAndFooter(width); - const height = Math.max( - MIN_VIEWPORT_HEIGHT, - this.terminal.rows - chromeLines.length - footerLines.length, - ); - this.viewport.setHeight(height); - return [...this.viewport.render(width), ...chromeLines, ...footerLines]; - } - - /** Rows rendered below the transcript region (chrome + mounted footer). */ - followingRows(width: number): number { - const { chromeLines, footerLines } = this.renderChromeAndFooter(width); - return chromeLines.length + footerLines.length; - } - - private renderChromeAndFooter(width: number): { - chromeLines: string[]; - footerLines: string[]; - } { - const chromeLines: string[] = []; - for (const component of this.chrome) { - chromeLines.push(...component.render(width)); - } - const footerLines = this.footerMounted ? this.footer.render(width) : []; - return { chromeLines, footerLines }; - } - - invalidate(): void { - this.viewport.invalidate(); - for (const component of this.chrome) { - component.invalidate(); - } - this.footer.invalidate(); - } -} diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts deleted file mode 100644 index cdf89a07..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts +++ /dev/null @@ -1,464 +0,0 @@ -/** - * Welcome banner layout — mirrors the Python shell `_print_welcome_info` design: - * panel title, robot mark, facts grid, and optional tips column. - */ - -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; - -import { createGitStatusCache, type GitStatusCache } from '#/utils/git/git-status'; -import { currentTheme } from '#/tui/theme'; -import type { AppState } from '#/tui/types'; - -import { - PYTHINKER_LOGO_COLORS, - renderPythinkerLogo, -} from './pythinker-logo'; - -const WELCOME_LABEL_WIDTH = 10; -const WELCOME_PANEL_CHROME_WIDTH = 4; -const WELCOME_COLUMNS_MIN_WIDTH = 84; -const WELCOME_LEFT_COLUMN_WIDTH = 52; -const WELCOME_LEFT_COLUMN_MAX_WIDTH = 64; -const WELCOME_TIPS_MIN_WIDTH = 24; -const WELCOME_COLUMNS_CHROME_WIDTH = 3; -const LOGO_STACKED_MIN_WIDTH = 68; - -const ASCII_FALLBACKS: Record<string, string> = { - '✦': '*', - '↑': '^', - '•': '*', - '·': '-', - '—': '-', - '…': '~', - '─': '-', -}; - -const WELCOME_TIPS = [ - 'shift+tab cycles thinking effort, /plan toggles plan mode', - '/model switches the active model', - 'ctrl+s steers the agent mid-turn', - '/compact compacts the context window', - 'ctrl+o expands tool output', - '/tasks lists background tasks', - '@ mentions files in your prompt', - '/help shows all slash commands', -] as const; - -export type WelcomeInfoLevel = 'info' | 'warn' | 'error'; - -export interface WelcomeInfoItem { - readonly name: string; - readonly value: string; - readonly level?: WelcomeInfoLevel; -} - -export interface WelcomeBannerCopy { - readonly head: string; - readonly strapline: string; - readonly prompt: string; -} - -export interface RenderWelcomeBannerOptions { - readonly width: number; - readonly version: string; - readonly infoItems: readonly WelcomeInfoItem[]; - readonly copy: WelcomeBannerCopy; - readonly logoLines?: readonly string[]; - readonly asciiMode?: boolean; -} - -export function asciiGlyphsEnabled(): boolean { - const term = process.env['TERM'] ?? ''; - return term === 'linux' || term === 'dumb'; -} - -function applyAsciiFallback(text: string): string { - return text.replaceAll(/[✦↑•·—…─]/g, (char) => ASCII_FALLBACKS[char] ?? char); -} - -function borderPaint(text: string): string { - return chalk.hex(currentTheme.palette.border)(text); -} - -function paintWithSlashAccent(text: string, baseHex: string, accentHex: string): string { - const base = chalk.hex(baseHex); - const accent = chalk.hex(accentHex); - return text - .split(/(\/[A-Za-z][A-Za-z0-9_-]*)/g) - .map((part) => (part.startsWith('/') ? accent(part) : base(part))) - .join(''); -} - -function padRight(text: string, targetWidth: number): string { - const vis = visibleWidth(text); - if (vis >= targetWidth) return text; - return text + ' '.repeat(targetWidth - vis); -} - -function takeCellsLeft(text: string, maxWidth: number): string { - if (maxWidth <= 0) return ''; - let used = 0; - let out = ''; - for (const char of text) { - const width = visibleWidth(char); - if (used + width > maxWidth) break; - out += char; - used += width; - } - return out; -} - -function takeCellsRight(text: string, maxWidth: number): string { - if (maxWidth <= 0) return ''; - const chars = Array.from(text); - let used = 0; - const out: string[] = []; - for (let index = chars.length - 1; index >= 0; index--) { - const char = chars[index]!; - const width = visibleWidth(char); - if (used + width > maxWidth) break; - out.unshift(char); - used += width; - } - return out.join(''); -} - -function truncateMiddle(text: string, maxWidth: number, ellipsis = '…'): string { - if (maxWidth <= 0) return ''; - const cleaned = text.replaceAll('\r', ' ').replaceAll('\n', ' '); - if (visibleWidth(cleaned) <= maxWidth) return cleaned; - if (maxWidth <= 1) return truncateToWidth(cleaned, maxWidth, ellipsis); - const leftWidth = Math.max(1, Math.floor((maxWidth - 1) / 2)); - const rightWidth = Math.max(1, maxWidth - 1 - leftWidth); - return `${takeCellsLeft(cleaned, leftWidth)}${ellipsis}${takeCellsRight(cleaned, rightWidth)}`; -} - -function wrapPlain(text: string, maxWidth: number): string[] { - const cleaned = text.replaceAll('\r', ' ').replaceAll('\n', ' ').trim(); - if (!cleaned) return ['']; - const words = cleaned.split(/\s+/); - const lines: string[] = []; - let current = ''; - for (const word of words) { - const candidate = current ? `${current} ${word}` : word; - if (visibleWidth(candidate) <= maxWidth) { - current = candidate; - continue; - } - if (current) lines.push(current); - current = visibleWidth(word) <= maxWidth ? word : truncateToWidth(word, maxWidth, '…'); - } - if (current) lines.push(current); - return lines.length > 0 ? lines : ['']; -} - -function valueStyleForLabel(label: string, level: WelcomeInfoLevel): string { - const palette = currentTheme.palette; - if (level === 'warn') return palette.warning; - if (level === 'error') return palette.error; - switch (label.trim()) { - case 'Directory': - return palette.accent; - case 'Session': - return palette.textDim; - case 'Model': - return palette.warning; - case 'Branch': - return palette.textDim; - case 'Auto-save': - return palette.textMuted; - default: - return palette.textDim; - } -} - -function formatWelcomeValue( - label: string, - value: string, - maxWidth: number, - ellipsis: string, -): string { - const cleaned = value.replaceAll('\r', ' ').replaceAll('\n', ' '); - if (['Directory', 'Auto-save', 'Session'].includes(label.trim())) { - return truncateMiddle(cleaned, maxWidth, ellipsis); - } - return truncateToWidth(cleaned, maxWidth, ellipsis); -} - -function renderFactsRows(items: readonly WelcomeInfoItem[], width: number, ellipsis: string): string[] { - const labelWidth = Math.min(WELCOME_LABEL_WIDTH, Math.max(4, Math.floor(width / 3))); - const valueWidth = Math.max(4, width - labelWidth - 2); - const labelStyle = chalk.bold.hex(currentTheme.palette.textMuted); - const rows: string[] = []; - - for (const item of items) { - const level = item.level ?? 'info'; - const valueStyle = valueStyleForLabel(item.name, level); - const value = formatWelcomeValue(item.name, item.value, valueWidth, ellipsis); - const labelText = truncateToWidth(item.name, labelWidth, ellipsis); - const label = labelStyle( - ' '.repeat(Math.max(0, labelWidth - visibleWidth(labelText))) + labelText, - ); - rows.push(`${label} ${chalk.hex(valueStyle)(value)}`); - } - return rows; -} - -function renderTipsBlock( - tips: readonly WelcomeInfoItem[], - width: number, - withRule: boolean, - asciiMode: boolean, -): string[] { - const gutter = 2; - const tipWidth = Math.max(4, width - gutter); - const muted = chalk.hex(currentTheme.palette.textMuted); - const accent = PYTHINKER_LOGO_COLORS.accent; - const bullet = asciiMode ? '* ' : '• '; - const lines: string[] = [muted('Tips')]; - - if (withRule) { - const ruleChar = asciiMode ? '-' : '─'; - lines.push(muted(ruleChar.repeat(tipWidth))); - } - - for (const item of tips) { - const wrapped = wrapPlain(item.value, tipWidth).map((line) => - truncateToWidth(line, tipWidth, asciiMode ? '~' : '…'), - ); - for (let index = 0; index < wrapped.length; index++) { - const prefix = index === 0 ? bullet : ' '; - const level = item.level ?? 'info'; - const levelHex = - level === 'warn' - ? currentTheme.palette.warning - : level === 'error' - ? currentTheme.palette.error - : currentTheme.palette.textDim; - const styled = paintWithSlashAccent(wrapped[index]!, levelHex, accent); - lines.push(`${muted(prefix)}${styled}`); - } - } - return lines; -} - -function centerBlock(lines: readonly string[], width: number): string[] { - if (lines.length === 0) return []; - const maxVis = Math.max(...lines.map((line) => visibleWidth(line))); - const pad = Math.max(0, Math.floor((width - maxVis) / 2)); - const prefix = ' '.repeat(pad); - return lines.map((line) => prefix + line); -} - -function renderStackedLogoHeader( - logoLines: readonly string[], - copy: WelcomeBannerCopy, - innerWidth: number, - ellipsis: string, - centerLogo: boolean, -): string[] { - const copyLines = [copy.head, copy.strapline, copy.prompt]; - const lines: string[] = []; - - if (logoLines.length > 0) { - lines.push(...(centerLogo ? centerBlock(logoLines, innerWidth) : logoLines), ''); - } - - lines.push(...copyLines.map((line) => truncateToWidth(line, innerWidth, ellipsis))); - return lines; -} - -function renderTwoColumns( - leftLines: readonly string[], - rightLines: readonly string[], - leftWidth: number, - rightWidth: number, -): string[] { - const divider = chalk.hex(currentTheme.palette.border)(' │ '); - const maxRows = Math.max(leftLines.length, rightLines.length); - const rows: string[] = []; - - for (let row = 0; row < maxRows; row++) { - const left = padRight(truncateToWidth(leftLines[row] ?? '', leftWidth, '…'), leftWidth); - const right = padRight(truncateToWidth(rightLines[row] ?? '', rightWidth, '…'), rightWidth); - rows.push(left + divider + right); - } - - return rows; -} - -function renderPanelTopBorder(title: string, width: number): string { - const inner = Math.max(0, width - 2); - const titlePart = ` ${title} `; - const titleWidth = visibleWidth(titlePart); - const dashCount = Math.max(0, inner - titleWidth); - return borderPaint('╭') + titlePart + borderPaint('─'.repeat(dashCount)) + borderPaint('╮'); -} - -export function buildWelcomeCopy(isLoggedOut: boolean): WelcomeBannerCopy { - const palette = currentTheme.palette; - const accent = PYTHINKER_LOGO_COLORS.accent; - const head = chalk.bold.hex(palette.textStrong)('Welcome to Pythinker — think first, then code.'); - const strapline = chalk.hex(palette.textMuted)( - 'Review · Secure · Diagnose · Build with confidence.', - ); - const promptText = isLoggedOut - ? 'Run /login or /provider to get started.' - : 'Type /help for commands.'; - const prompt = paintWithSlashAccent(promptText, palette.textMuted, accent); - return { head, strapline, prompt }; -} - -export function buildWelcomeInfoItems( - state: AppState, - gitCache: GitStatusCache | null, -): WelcomeInfoItem[] { - const isLoggedOut = !state.model; - const activeModel = state.availableModels[state.model]; - const modelValue = isLoggedOut - ? 'not set, run /login or /provider' - : (activeModel?.displayName ?? activeModel?.model ?? state.model); - - const gitStatus = gitCache?.getStatus(); - const items: WelcomeInfoItem[] = [{ name: 'Directory', value: state.workDir }]; - if (gitStatus?.branch) { - items.push({ name: 'Branch', value: gitStatus.branch }); - } - - items.push( - { - name: 'Model', - value: modelValue, - level: isLoggedOut ? 'warn' : 'info', - }, - { name: 'Session', value: state.sessionId || 'pending' }, - { name: 'Auto-save', value: 'on' }, - ); - - if (state.mcpServersSummary) { - items.push({ name: 'MCP', value: state.mcpServersSummary }); - } - - return items; -} - -export function buildWelcomeTips(): WelcomeInfoItem[] { - return WELCOME_TIPS.map((value) => ({ name: 'Tip', value })); -} - -export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string[] { - const safeWidth = Math.max(0, options.width); - if (safeWidth < 24) { - return [ - '', - truncateToWidth(options.copy.head, safeWidth, '…'), - truncateToWidth(options.copy.prompt, safeWidth, '…'), - ]; - } - - const asciiMode = options.asciiMode ?? false; - const ellipsis = asciiMode ? '~' : '…'; - const panelWidth = safeWidth; - const innerWidth = Math.max(1, panelWidth - WELCOME_PANEL_CHROME_WIDTH); - const pad = ' '; - - const copy: WelcomeBannerCopy = asciiMode - ? { - head: applyAsciiFallback(options.copy.head), - strapline: applyAsciiFallback(options.copy.strapline), - prompt: applyAsciiFallback(options.copy.prompt), - } - : options.copy; - - const facts = options.infoItems - .filter((item) => item.name.trim() !== 'Tip') - .map((item) => - asciiMode - ? { ...item, name: applyAsciiFallback(item.name), value: applyAsciiFallback(item.value) } - : item, - ); - const resolvedTips = buildWelcomeTips().map((item) => - asciiMode - ? { ...item, name: applyAsciiFallback(item.name), value: applyAsciiFallback(item.value) } - : item, - ); - const showLogo = !asciiMode; - const useColumns = resolvedTips.length > 0 && innerWidth >= WELCOME_COLUMNS_MIN_WIDTH; - const centerLogo = showLogo && innerWidth < LOGO_STACKED_MIN_WIDTH; - - const logoLines = - options.logoLines ?? - (showLogo ? renderPythinkerLogo() : []); - - const contentLines: string[] = []; - - if (useColumns) { - let wantedLeft = WELCOME_LEFT_COLUMN_WIDTH; - if (facts.length > 0) { - const longestFact = Math.max(...facts.map((item) => visibleWidth(item.value))); - wantedLeft = Math.max( - wantedLeft, - Math.min(WELCOME_LEFT_COLUMN_MAX_WIDTH, longestFact + WELCOME_LABEL_WIDTH + 2), - ); - } - const leftWidth = Math.max( - WELCOME_LEFT_COLUMN_WIDTH, - Math.min( - wantedLeft, - innerWidth - WELCOME_COLUMNS_CHROME_WIDTH - WELCOME_TIPS_MIN_WIDTH, - ), - ); - const tipsWidth = innerWidth - WELCOME_COLUMNS_CHROME_WIDTH - leftWidth; - - const leftFactsRows = facts.length > 0 ? renderFactsRows(facts, leftWidth, ellipsis) : []; - const leftLines = [ - ...[copy.head, copy.strapline, copy.prompt].map((line) => - truncateToWidth(line, leftWidth, ellipsis), - ), - '', - ...centerBlock(logoLines, leftWidth), - '', - ...leftFactsRows, - ]; - const rightTipsRows = renderTipsBlock(resolvedTips, tipsWidth, true, asciiMode); - - contentLines.push(...renderTwoColumns(leftLines, rightTipsRows, leftWidth, tipsWidth)); - } else { - contentLines.push( - ...renderStackedLogoHeader(logoLines, copy, innerWidth, ellipsis, centerLogo), - ); - if (facts.length > 0) { - contentLines.push('', ...renderFactsRows(facts, innerWidth, ellipsis)); - } - if (resolvedTips.length > 0) { - contentLines.push('', ...renderTipsBlock(resolvedTips, innerWidth, false, asciiMode)); - } - } - - const versionTitle = - chalk.hex(currentTheme.palette.textMuted)('Pythinker Code') + - chalk.hex(currentTheme.palette.textDim)(` v${options.version}`); - - const lines: string[] = [ - '', - renderPanelTopBorder(versionTitle, panelWidth), - borderPaint('│') + ' '.repeat(panelWidth - 2) + borderPaint('│'), - ]; - - for (const content of contentLines) { - const truncated = truncateToWidth(content, innerWidth, ellipsis); - const rightPad = Math.max(0, innerWidth - visibleWidth(truncated)); - lines.push( - borderPaint('│') + pad + truncated + ' '.repeat(rightPad) + borderPaint('│'), - ); - } - - lines.push(borderPaint('│') + ' '.repeat(panelWidth - 2) + borderPaint('│'), borderPaint('╰' + '─'.repeat(panelWidth - 2) + '╯'), ''); - - return lines.map((line) => truncateToWidth(line, panelWidth, ellipsis)); -} - -export function createWelcomeGitCache(workDir: string): GitStatusCache { - return createGitStatusCache(workDir); -} diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts deleted file mode 100644 index 5e473e11..00000000 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-logo-animation.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Welcome-banner animation — eye blink with the same phases as install.ps1 - * `Blink-Eye` / install.sh `_blink_eyes` (longer holds so the closed eye - * registers in the TUI), plus a one-shot antenna spin while the banner - * first loads. - */ - -import { asciiGlyphsEnabled } from './welcome-banner'; -import { - ANTENNA_SPINNER_FRAMES, - LOGO_EYES_OPEN, - type EyeBlinkPhase, - type LogoEyeBlinkState, -} from './pythinker-logo'; - -/** One step in the installer blink sequence for a single eye. */ -interface EyeBlinkStep { - readonly phase: EyeBlinkPhase; - readonly delayMs: number; -} - -/** Left eye, then right eye — slower than the installer intro so the blink reads in the TUI. */ -const SINGLE_EYE_BLINK: readonly EyeBlinkStep[] = [ - { phase: 'glance', delayMs: 90 }, - { phase: 'closed', delayMs: 120 }, - { phase: 'closed', delayMs: 180 }, - { phase: 'open-shine', delayMs: 90 }, - { phase: 'open', delayMs: 60 }, -]; - -export function welcomeLogoAnimationEnabled(): boolean { - if (process.env['PYTHINKER_NO_ANIMATION']) return false; - if (process.env['CI']) return false; - if (process.env['NO_COLOR']) return false; - if (asciiGlyphsEnabled()) return false; - return true; -} - -export interface WelcomeLogoAnimationHost { - setEyeBlinkState(state: LogoEyeBlinkState): void; - /** `null` restores the static ● bulb. */ - setAntennaFrame(frame: number | null): void; -} - -/** Idle time between blink sequences. */ -export const WELCOME_BLINK_INTERVAL_MS = 5000; - -/** Antenna spin cadence and total length — one spin at banner load. */ -export const WELCOME_ANTENNA_SPIN_TICK_MS = 120; -export const WELCOME_ANTENNA_SPIN_DURATION_MS = 6000; - -export class WelcomeLogoAnimator { - private eyeState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private antennaFrameIndex = 0; - private blinkTimer: ReturnType<typeof setTimeout> | null = null; - private spinTimer: ReturnType<typeof setTimeout> | null = null; - private disposed = false; - - constructor( - private readonly host: WelcomeLogoAnimationHost, - private readonly requestRender: () => void, - ) {} - - getEyeBlinkState(): LogoEyeBlinkState { - return this.eyeState; - } - - start(): void { - if (!welcomeLogoAnimationEnabled() || this.disposed) return; - this.spinAntenna(0); - this.playBlink(); - } - - private playBlink(): void { - this.runEye('left', 0, () => { - this.runEye('right', 0, () => { - this.applyState(LOGO_EYES_OPEN); - this.scheduleNextBlink(); - }); - }); - } - - private scheduleNextBlink(): void { - if (this.disposed) return; - this.blinkTimer = setTimeout(() => { - this.blinkTimer = null; - this.playBlink(); - }, WELCOME_BLINK_INTERVAL_MS); - } - - private spinAntenna(elapsedMs: number): void { - if (this.disposed) return; - if (elapsedMs >= WELCOME_ANTENNA_SPIN_DURATION_MS) { - this.applyAntennaFrame(null); - return; - } - this.applyAntennaFrame(this.antennaFrameIndex); - this.antennaFrameIndex = (this.antennaFrameIndex + 1) % ANTENNA_SPINNER_FRAMES.length; - this.spinTimer = setTimeout(() => { - this.spinTimer = null; - this.spinAntenna(elapsedMs + WELCOME_ANTENNA_SPIN_TICK_MS); - }, WELCOME_ANTENNA_SPIN_TICK_MS); - } - - private applyAntennaFrame(frame: number | null): void { - this.host.setAntennaFrame(frame); - this.requestRender(); - } - - dispose(): void { - this.disposed = true; - if (this.blinkTimer !== null) { - clearTimeout(this.blinkTimer); - this.blinkTimer = null; - } - if (this.spinTimer !== null) { - clearTimeout(this.spinTimer); - this.spinTimer = null; - } - this.applyState(LOGO_EYES_OPEN); - this.applyAntennaFrame(null); - } - - private runEye(side: 'left' | 'right', stepIndex: number, done: () => void): void { - if (this.disposed) return; - const step = SINGLE_EYE_BLINK[stepIndex]; - if (step === undefined) { - done(); - return; - } - this.applyState({ - left: side === 'left' ? step.phase : 'open', - right: side === 'right' ? step.phase : 'open', - }); - if (step.delayMs <= 0) { - this.runEye(side, stepIndex + 1, done); - return; - } - this.blinkTimer = setTimeout(() => { - this.blinkTimer = null; - this.runEye(side, stepIndex + 1, done); - }, step.delayMs); - } - - private applyState(state: LogoEyeBlinkState): void { - this.eyeState = state; - this.host.setEyeBlinkState(state); - this.requestRender(); - } -} diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index f3a40da9..8826c1f8 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -1,87 +1,111 @@ /** * Welcome panel shown at the top of the TUI. - * Layout matches the Python shell welcome banner (`_print_welcome_info`). + * Renders a round-bordered box with the logo, session, model, and version. */ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import chalk from 'chalk'; -import { - isRainbowColorActive, - renderRainbowWelcomeCopy, - renderRainbowWelcomeLogo, -} from '#/tui/easter-eggs/rainbow-colors'; +import { effectiveModelAlias } from '@pymodel/pythinker-code-sdk'; + +import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; import type { AppState } from '#/tui/types'; -import type { GitStatusCache } from '#/utils/git/git-status'; - -import { - LOGO_EYES_OPEN, - renderPythinkerLogoWithEyes, - type LogoEyeBlinkState, -} from './pythinker-logo'; -import { - WelcomeLogoAnimator, - welcomeLogoAnimationEnabled, - type WelcomeLogoAnimationHost, -} from './welcome-logo-animation'; -import { - asciiGlyphsEnabled, - buildWelcomeCopy, - buildWelcomeInfoItems, - createWelcomeGitCache, - renderWelcomeBanner, -} from './welcome-banner'; - -export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { - private state: AppState; - private readonly gitCache: GitStatusCache; - private eyeBlinkState: LogoEyeBlinkState = LOGO_EYES_OPEN; - private antennaFrame: number | null = null; - private eyeAnimator: WelcomeLogoAnimator | null = null; - - constructor( - state: AppState, - requestRender?: () => void, - ) { - this.state = state; - this.gitCache = createWelcomeGitCache(state.workDir); - if (requestRender !== undefined && welcomeLogoAnimationEnabled() && !isRainbowColorActive()) { - this.eyeAnimator = new WelcomeLogoAnimator(this, requestRender); - queueMicrotask(() => this.eyeAnimator?.start()); - } - } +import { currentTheme } from '#/tui/theme'; - setEyeBlinkState(state: LogoEyeBlinkState): void { - this.eyeBlinkState = state; - } +export class WelcomeComponent implements Component { + private state: AppState; - setAntennaFrame(frame: number | null): void { - this.antennaFrame = frame; + constructor(state: AppState) { + this.state = state; } invalidate(): void {} - dispose(): void { - this.eyeAnimator?.dispose(); - this.eyeAnimator = null; - } - render(width: number): string[] { + const safeWidth = Math.max(0, width); + const primary = (s: string): string => chalk.hex(currentTheme.palette.primary)(s); const isLoggedOut = !this.state.model; - const copy = isRainbowColorActive() - ? renderRainbowWelcomeCopy(isLoggedOut) - : buildWelcomeCopy(isLoggedOut); - - const logoLines = isRainbowColorActive() - ? renderRainbowWelcomeLogo() - : renderPythinkerLogoWithEyes(this.eyeBlinkState, this.antennaFrame ?? undefined); - - return renderWelcomeBanner({ - width, - version: this.state.version, - infoItems: buildWelcomeInfoItems(this.state, this.gitCache), - copy, - logoLines, - asciiMode: asciiGlyphsEnabled(), - }); + const activeModel = this.state.availableModels[this.state.model]; + const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); + + if (safeWidth < 24) { + const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Pythinker Code!'); + const prompt = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') + : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); + const model = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + return ['', title, prompt, `Model: ${model}`].map((line) => + truncateToWidth(line, safeWidth, '…'), + ); + } + + const innerWidth = Math.max(1, safeWidth - 4); + const pad = ' '; + + // Logo + side-by-side text. + const logo = ['▐█▛█▛█▌', '▐█████▌'] as const; + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); + + const rightRow0 = truncateToWidth( + chalk.bold.hex(currentTheme.palette.primary)('Welcome to Pythinker Code!'), + textWidth, + '…', + ); + const dim = chalk.hex(currentTheme.palette.textDim); + const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); + const rightRow1 = truncateToWidth( + dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), + textWidth, + '…', + ); + + let renderedHeaderLines = [ + primary(logo[0].padEnd(logoWidth)) + gap + rightRow0, + primary(logo[1].padEnd(logoWidth)) + gap + rightRow1, + ]; + if (isRainbowDancing()) { + renderedHeaderLines = renderDanceWelcomeHeader(logo, textWidth, rightRow1); + } + + const modelValue = isLoggedOut + ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); + + const infoLines = [ + labelStyle('Directory: ') + this.state.workDir, + labelStyle('Session: ') + this.state.sessionId, + labelStyle('Model: ') + modelValue, + labelStyle('Version: ') + this.state.version, + ]; + + if (this.state.mcpServersSummary) { + infoLines.push(labelStyle('MCP: ') + this.state.mcpServersSummary); + } + + const contentLines: string[] = [...renderedHeaderLines, '', ...infoLines]; + + const lines: string[] = [ + '', + primary('╭' + '─'.repeat(safeWidth - 2) + '╮'), + primary('│') + ' '.repeat(safeWidth - 2) + primary('│'), + ]; + + for (const content of contentLines) { + const truncated = truncateToWidth(content, innerWidth, '…'); + const vis = visibleWidth(truncated); + const rightPad = Math.max(0, innerWidth - vis); + lines.push(primary('│') + pad + truncated + ' '.repeat(rightPad) + primary('│')); + } + + lines.push(primary('│') + ' '.repeat(safeWidth - 2) + primary('│')); + lines.push(primary('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); + + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } } diff --git a/apps/pythinker-code/src/tui/components/chrome/working-tips.ts b/apps/pythinker-code/src/tui/components/chrome/working-tips.ts new file mode 100644 index 00000000..23d00273 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/chrome/working-tips.ts @@ -0,0 +1,31 @@ +import { WORKING_TIPS, type ToolbarTip } from '#/tui/constant/tips'; + +import { buildWeightedTips } from './footer'; + +export { WORKING_TIPS }; + +const TIP_ROTATE_INTERVAL_MS = 10_000; + +const WORKING_TIP_ROTATION = buildWeightedTips(WORKING_TIPS); + +export function currentWorkingTip(now = Date.now()): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const index = Math.floor(now / TIP_ROTATE_INTERVAL_MS) % WORKING_TIP_ROTATION.length; + return WORKING_TIP_ROTATION[index]; +} + +/** + * Pick a random tip from the weighted working-tip rotation. + * If `excludeText` is provided and there are other tips available, avoid + * returning the same text twice in a row. + */ +export function pickRandomWorkingTip(excludeText?: string): ToolbarTip | undefined { + if (WORKING_TIP_ROTATION.length === 0) return undefined; + const candidates = + excludeText === undefined || WORKING_TIP_ROTATION.length === 1 + ? WORKING_TIP_ROTATION + : WORKING_TIP_ROTATION.filter((t) => t.text !== excludeText); + const pool = candidates.length > 0 ? candidates : WORKING_TIP_ROTATION; + const index = Math.floor(Math.random() * pool.length); + return pool[index]; +} diff --git a/apps/pythinker-code/src/tui/components/dialogs/agent-activity-viewer.ts b/apps/pythinker-code/src/tui/components/dialogs/agent-activity-viewer.ts new file mode 100644 index 00000000..52d1b37c --- /dev/null +++ b/apps/pythinker-code/src/tui/components/dialogs/agent-activity-viewer.ts @@ -0,0 +1,434 @@ +/** + * AgentActivityViewer — full-screen detail view for a background agent task. + * + * Same full-screen skeleton as `TaskOutputViewer` (header / scrolling body / + * footer, tail-follow), but the body is assembled from the in-memory + * `SubagentActivityRecord` instead of the task's captured output: recent + * steps with their assistant text (Markdown, same as the main transcript) + * and tool calls rendered through the main-flow result renderers + * (`pickResultRenderer` / `pickChip` / `extractKeyArgument`). `ToolCallComponent` + * itself is not reused — it is a live, event-driven component, while this + * view renders a snapshot. + * + * Ctrl+O toggles a global expand of every tool result (same semantics as the + * main transcript's `toolOutputExpanded`), capped by what the store retained. + */ + +import { + Container, + Key, + matchesKey, + type Focusable, + type Terminal, + truncateToWidth, + visibleWidth, +} from '@pymodel/pi-tui'; +import type { BackgroundTaskInfo } from '@pymodel/pythinker-code-sdk'; + +import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { STATUS_BULLET } from '#/tui/constant/symbols'; +import type { + SubagentActivityRecord, + SubToolCallActivity, +} from '#/tui/controllers/subagent-activity-store'; +import { currentTheme } from '#/tui/theme'; +import type { ToolCallBlockData } from '#/tui/types'; +import { printableChar } from '#/tui/utils/printable-key'; +import { AssistantMessageComponent } from '../messages/assistant-message'; +import { extractKeyArgument } from '../messages/tool-call'; +import { pickChip } from '../messages/tool-renderers/chip'; +import { pickResultRenderer } from '../messages/tool-renderers/registry'; +import { STATUS_LABEL, statusColor } from './task-output-viewer'; + +const ELLIPSIS = '…'; + +export interface AgentActivityViewerProps { + readonly taskId: string; + readonly info: BackgroundTaskInfo | undefined; + readonly record: SubagentActivityRecord | undefined; + readonly onClose: () => void; +} + +function padToWidth(line: string, width: number): string { + const w = visibleWidth(line); + if (w === width) return line; + if (w > width) return truncateToWidth(line, width, ELLIPSIS); + return line + ' '.repeat(width - w); +} + +function fitExactly(line: string, width: number): string { + let s = line; + if (visibleWidth(s) > width) s = truncateToWidth(s, width, ELLIPSIS); + return padToWidth(s, width); +} + +export class AgentActivityViewer extends Container implements Focusable { + focused = false; + + private props: AgentActivityViewerProps; + private readonly terminal: Terminal; + private expanded = false; + /** Index of the topmost visible body line. */ + private scrollTop = 0; + /** Stick to the bottom on updates until the user scrolls away. */ + private followTail = true; + private lines: string[] = []; + private lastCacheKey = ''; + + constructor(props: AgentActivityViewerProps, terminal: Terminal) { + super(); + this.props = props; + this.terminal = terminal; + } + + setProps(next: AgentActivityViewerProps): void { + this.props = next; + this.invalidate(); + } + + override invalidate(): void { + // Theme switches arrive as a tree-wide invalidate; the styled body lines + // are cached, so drop the cache here to pick up the new palette. + this.lastCacheKey = ''; + super.invalidate(); + } + + // ── input ────────────────────────────────────────────────────────── + + handleInput(data: string): void { + const visible = this.viewableRows(); + const k = printableChar(data); + + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { + this.props.onClose(); + return; + } + if (matchesKey(data, Key.ctrl('o'))) { + this.expanded = !this.expanded; + this.lastCacheKey = ''; + this.invalidate(); + return; + } + if (matchesKey(data, Key.up) || k === 'k') { + this.scrollBy(-1); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + this.scrollBy(1); + return; + } + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { + this.scrollBy(-Math.max(1, visible - 1)); + return; + } + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { + this.scrollBy(Math.max(1, visible - 1)); + return; + } + if (matchesKey(data, Key.home) || k === 'g') { + this.scrollTo(0); + return; + } + if (matchesKey(data, Key.end) || k === 'G') { + this.scrollTo(this.maxScroll()); + return; + } + } + + private scrollBy(delta: number): void { + this.scrollTo(this.scrollTop + delta); + } + + private scrollTo(target: number): void { + this.scrollTop = Math.max(0, Math.min(target, this.maxScroll())); + this.followTail = this.scrollTop >= this.maxScroll(); + this.invalidate(); + } + + private maxScroll(): number { + return Math.max(0, this.lines.length - this.viewableRows()); + } + + /** Content rows inside the body frame: total rows minus header(1) + + * footer(1) + top border(1) + bottom border(1). */ + private viewableRows(): number { + return Math.max(1, this.terminal.rows - 4); + } + + // ── body assembly ────────────────────────────────────────────────── + + private cacheKey(innerWidth: number): string { + const record = this.props.record; + return [ + String(innerWidth), + this.expanded ? 'x' : 'c', + record?.agentId ?? '', + String(record?.version ?? -1), + ].join('|'); + } + + private buildLines(innerWidth: number): string[] { + const record = this.props.record; + if (record === undefined) { + return [currentTheme.dim(`${MESSAGE_INDENT}[no activity recorded]`)]; + } + + const out: string[] = []; + for (const step of record.steps) { + out.push(currentTheme.dim(`── step ${String(step.step)} ──`)); + if (step.retrying !== undefined) { + out.push(currentTheme.fg('warning', `${MESSAGE_INDENT}↻ ${step.retrying}`)); + } + if (step.textTail.trim().length > 0) { + const message = new AssistantMessageComponent(); + message.updateContent(step.textTail); + out.push(...message.render(innerWidth)); + } + for (const call of step.toolCalls) { + out.push(this.buildToolCallHeader(call)); + out.push(...this.renderToolCallBody(call, innerWidth)); + } + out.push(''); + } + + if (record.error !== undefined && record.error.length > 0) { + out.push(currentTheme.fg('error', 'Failed')); + const message = new AssistantMessageComponent(); + message.updateContent(record.error); + out.push(...message.render(innerWidth)); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + out.push(currentTheme.boldFg('primary', 'Result')); + const message = new AssistantMessageComponent(); + message.updateContent(record.resultSummary); + out.push(...message.render(innerWidth)); + } + + if (out.length === 0) { + out.push(currentTheme.dim(`${MESSAGE_INDENT}Waiting for activity…`)); + } + return out; + } + + /** Same shape as the main flow's generic header (`tool-call.ts` + * `buildHeader`): bullet + verb + name + key argument + chip. Custom + * per-tool label wording (e.g. "Ran a command") is intentionally not + * mirrored — the per-tool *body* renderers carry the specialization. */ + private buildToolCallHeader(call: SubToolCallActivity): string { + let bullet: string; + if (call.status === 'error') { + bullet = currentTheme.fg('error', '✗ '); + } else if (call.status === 'done') { + bullet = currentTheme.fg('success', STATUS_BULLET); + } else { + bullet = currentTheme.fg('text', STATUS_BULLET); + } + const verb = call.status === 'running' ? 'Using' : 'Used'; + const name = currentTheme.boldFg('primary', call.name); + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : currentTheme.dim(` (${keyArg})`); + + let chipStr = ''; + if (call.result !== undefined) { + const provider = pickChip(call.name); + const text = provider?.(this.toToolCallBlockData(call), call.result) ?? ''; + if (text.length > 0) { + chipStr = + call.result.is_error === true + ? currentTheme.fg('error', ` · ${text}`) + : currentTheme.dim(` · ${text}`); + } + } + return `${bullet}${verb} ${name}${argStr}${chipStr}`; + } + + private renderToolCallBody(call: SubToolCallActivity, innerWidth: number): string[] { + if (call.result === undefined) { + return call.liveOutputTail === undefined || call.liveOutputTail.length === 0 + ? [] + : [currentTheme.dim(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`)]; + } + // The store caps retained output, which cannot survive as a parseable + // media envelope (base64) — show a marker instead of dumping the blob. + if (call.name === 'ReadMediaFile' && call.result.is_error !== true) { + return [currentTheme.dim(`${MESSAGE_INDENT}[media output omitted]`)]; + } + const components = pickResultRenderer(call.name)( + this.toToolCallBlockData(call), + call.result, + { expanded: this.expanded }, + ); + const out: string[] = []; + for (const component of components) { + out.push(...component.render(innerWidth)); + } + return out; + } + + private toToolCallBlockData(call: SubToolCallActivity): ToolCallBlockData { + return { id: call.id, name: call.name, args: call.args }; + } + + // ── render ───────────────────────────────────────────────────────── + + override render(width: number): string[] { + const rows = Math.max(3, this.terminal.rows); + const bodyHeight = rows - 2; + const innerWidth = Math.max(1, width - 4); + + const key = this.cacheKey(innerWidth); + if (key !== this.lastCacheKey) { + this.lines = this.buildLines(innerWidth); + this.lastCacheKey = key; + } + if (this.followTail) this.scrollTop = this.maxScroll(); + + const header = this.renderHeader(width); + const body = this.renderBody(width, bodyHeight); + const footer = this.renderFooter(width, bodyHeight); + + const out: string[] = [header]; + for (const line of body) out.push(line); + out.push(footer); + return out; + } + + private renderHeader(width: number): string { + const title = currentTheme.boldFg('primary', ' Agent activity '); + const record = this.props.record; + const info = this.props.info; + const segments: string[] = []; + + if (record !== undefined) { + const label = + record.description !== undefined && record.description.length > 0 + ? `${record.agentName} › ${record.description}` + : record.agentName; + segments.push(currentTheme.boldFg('text', label)); + } else { + segments.push(currentTheme.boldFg('text', this.props.taskId)); + } + if (info !== undefined) { + segments.push(currentTheme.fg(statusColor(info.status), STATUS_LABEL[info.status])); + } + if (record !== undefined && record.steps.length > 0) { + const from = record.steps[0]!.step; + const to = record.steps.at(-1)!.step; + let range = `step ${String(from)}–${String(to)} / ${String(record.totalSteps)}`; + if (record.totalSteps > record.steps.length) range += ' · earlier steps discarded'; + segments.push(currentTheme.fg('textMuted', range)); + } + + const composed = title + segments.join(' '); + return fitExactly(composed, width); + } + + private renderBody(width: number, bodyHeight: number): string[] { + const innerWidth = Math.max(1, width - 4); + + const max = this.maxScroll(); + if (this.scrollTop > max) this.scrollTop = max; + if (this.scrollTop < 0) this.scrollTop = 0; + + const viewRows = Math.max(1, bodyHeight - 2); + const top = currentTheme.fg('primary', '┌' + '─'.repeat(Math.max(0, width - 2)) + '┐'); + const bottom = currentTheme.fg('primary', '└' + '─'.repeat(Math.max(0, width - 2)) + '┘'); + + const out: string[] = [top]; + for (let i = 0; i < viewRows; i++) { + const lineIndex = this.scrollTop + i; + const raw = this.lines[lineIndex] ?? ''; + const inner = fitExactly(raw, innerWidth); + out.push(currentTheme.fg('primary', '│ ') + inner + currentTheme.fg('primary', ' │')); + } + out.push(bottom); + return out; + } + + private renderFooter(width: number, bodyHeight: number): string { + const key = (text: string): string => currentTheme.boldFg('primary', text); + const dim = (text: string): string => currentTheme.fg('textMuted', text); + + const total = this.lines.length; + const viewRows = Math.max(1, bodyHeight - 2); + const maxScroll = Math.max(0, total - viewRows); + const percent = + maxScroll === 0 ? 100 : Math.round((this.scrollTop / maxScroll) * 100); + const lineFrom = total === 0 ? 0 : this.scrollTop + 1; + const lineTo = Math.min(total, this.scrollTop + viewRows); + + const position = currentTheme.fg( + 'textMuted', + ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, + ); + const keys = + `${key('↑↓')} ${dim('line')} ` + + `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('g/G')} ${dim('top/bot')} ` + + `${key('Ctrl+O')} ${dim(this.expanded ? 'collapse' : 'expand')} ` + + `${key('Q/Esc')} ${dim('cancel')}`; + const left = ` ${keys}`; + const leftW = visibleWidth(left); + const rightW = visibleWidth(position); + if (leftW + 2 + rightW <= width) { + return left + ' '.repeat(width - leftW - rightW) + position; + } + return fitExactly(left, width); + } +} + +/** + * Plain-text preview of a record for the tasks browser's Preview frame (the + * frame styles whole lines itself, so this stays ANSI-free). The frame shows + * the tail of the string, so the full retained activity is returned. + */ +export function formatSubagentActivityPreview(record: SubagentActivityRecord): string { + const lines: string[] = []; + for (const step of record.steps) { + lines.push(`── step ${String(step.step)} ──`); + if (step.retrying !== undefined) lines.push(`${MESSAGE_INDENT}↻ ${step.retrying}`); + if (step.textTail.trim().length > 0) lines.push(...step.textTail.trimEnd().split('\n')); + for (const call of step.toolCalls) { + lines.push(formatPreviewToolCall(call)); + if ( + call.result === undefined && + call.liveOutputTail !== undefined && + call.liveOutputTail.length > 0 + ) { + lines.push(`${MESSAGE_INDENT}│ ${call.liveOutputTail}`); + } + } + } + if (record.error !== undefined && record.error.length > 0) { + lines.push('Failed:', ...record.error.trimEnd().split('\n')); + } else if (record.resultSummary !== undefined && record.resultSummary.length > 0) { + lines.push('Result:', ...record.resultSummary.trimEnd().split('\n')); + } + if (lines.length === 0) { + return record.status === 'running' ? 'Waiting for activity…' : ''; + } + return lines.join('\n'); +} + +function formatPreviewToolCall(call: SubToolCallActivity): string { + const mark = call.status === 'done' ? '✓' : call.status === 'error' ? '✗' : '●'; + const verb = call.status === 'running' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(call.name, call.args); + const argStr = keyArg === null || keyArg.length === 0 ? '' : ` (${keyArg})`; + + let chip = ''; + if (call.result !== undefined) { + const callData: ToolCallBlockData = { id: call.id, name: call.name, args: call.args }; + const text = pickChip(call.name)?.(callData, call.result) ?? ''; + if (text.length > 0) chip = ` · ${text}`; + } + return `${mark} ${verb} ${call.name}${argStr}${chip}`; +} diff --git a/apps/pythinker-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/pythinker-code/src/tui/components/dialogs/api-key-input-dialog.ts index bb059477..0f896229 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -3,27 +3,25 @@ import { Input, Key, matchesKey, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; import { currentTheme } from '#/tui/theme'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; -import { isPrintableChar, printableChar } from '#/tui/utils/printable-key'; export type ApiKeyInputResult = | { readonly kind: 'ok'; readonly value: string } | { readonly kind: 'cancel' }; +export interface ApiKeyInputDialogOptions { + readonly title?: string; + readonly mask?: boolean; + readonly emptyHint?: string; +} + +const FOOTER = 'Enter to submit · Esc to cancel'; + function maskInputLine(raw: string): string { const prefix = '> '; if (!raw.startsWith(prefix)) return raw; @@ -49,30 +47,6 @@ function maskInputLine(raw: string): string { return prefix + maskedContent + padding; } -export interface ApiKeyInputDialogOptions { - readonly title?: string | undefined; - readonly subtitleLines?: readonly string[] | undefined; - readonly secret?: boolean | undefined; - readonly emptyMessage?: string | undefined; -} - -function textInputBindings(bindings: readonly ParsedKeybinding[]): ParsedKeybinding[] { - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - return [...winners.values()].filter( - (binding) => binding.action !== 'confirm:no' || !startsWithPrintableKey(binding), - ); -} - -function startsWithPrintableKey(binding: ParsedKeybinding): boolean { - const first = binding.chord[0]; - if (first === undefined) return false; - if (first === 'space' || isPrintableChar(first)) return true; - return first.startsWith('shift+') && isPrintableChar(first.slice('shift+'.length)); -} - export class ApiKeyInputDialogComponent extends Container implements Focusable { focused = false; @@ -80,67 +54,41 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly onDone: (result: ApiKeyInputResult) => void; private readonly title: string; private readonly subtitleLines: readonly string[]; - private readonly secret: boolean; - private readonly emptyMessage: string; + private readonly mask: boolean; + private readonly emptyHint: string; private done = false; private emptyHinted = false; - private bindings = textInputBindings(defaultKeybindings()); - private keybindings = new KeybindingResolver( - this.bindings.filter((binding) => binding.action === 'confirm:no'), - ); constructor( platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, - options: ApiKeyInputDialogOptions = {}, + options?: ApiKeyInputDialogOptions, ) { super(); this.onDone = onDone; - this.title = options.title ?? `Enter API key for ${platformName}`; - this.subtitleLines = options.subtitleLines ?? subtitleLines; - this.secret = options.secret ?? true; - this.emptyMessage = options.emptyMessage ?? 'API key cannot be empty.'; + this.title = options?.title ?? `Enter API key for ${platformName}`; + this.subtitleLines = subtitleLines; + this.mask = options?.mask ?? true; + this.emptyHint = options?.emptyHint ?? 'API key cannot be empty.'; this.input.onSubmit = (value) => { this.submit(value); }; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = textInputBindings(bindings); - this.keybindings = new KeybindingResolver( - this.bindings.filter((binding) => binding.action === 'confirm:no'), - ); - } - handleInput(data: string): void { if (this.done) return; - if (isPrintableChar(printableChar(data))) { - this.emptyHinted = false; - this.input.handleInput(data); - return; - } - const keyId = parseKey(data); if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) ) { this.cancel(); return; } - const handlers: KeybindingHandlers = { 'confirm:no': () => this.cancel() }; - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { - return; - } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.cancel(); - return; + if (this.emptyHinted) { + this.emptyHinted = false; } - this.emptyHinted = false; this.input.handleInput(data); } @@ -159,24 +107,17 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { const border = (s: string): string => currentTheme.fg('primary', s); const titleStyled = currentTheme.boldFg('textStrong', this.title); - const subtitleSource = this.emptyHinted ? [this.emptyMessage] : this.subtitleLines; + const subtitleSource = this.emptyHinted ? [this.emptyHint] : this.subtitleLines; const subtitleLines = subtitleSource.map((line) => truncateToWidth(currentTheme.fg('textDim', line), innerWidth, '…'), ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const footer = [ - 'Enter to submit', - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} to cancel`, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); - const footerStyled = currentTheme.fg('textDim', footer); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const rawInputLine = this.input.render(innerWidth)[0] ?? '> '; const inputLine = - this.secret && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine; + this.mask && this.input.getValue() !== '' ? maskInputLine(rawInputLine) : rawInputLine; const contentLines: string[] = [ titleLine, @@ -204,7 +145,9 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); } - lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'), border('╰' + '─'.repeat(safeWidth - 2) + '╯'), ''); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts b/apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts index fcf1b6ec..b61045d2 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/approval-panel.ts @@ -4,29 +4,19 @@ * Container-based component with keyboard navigation. */ -import { stripVTControlCharacters } from 'node:util'; - import { Container, Input, matchesKey, Key, - parseKey, + decodeKittyPrintable, type Focusable, truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { ApprovalPanelChoice, @@ -34,9 +24,7 @@ import type { DisplayBlock, FileContentDisplayBlock, PendingApproval, - WorkflowPlanDisplayBlock, } from '#/tui/reverse-rpc/types'; -import { printableChar } from '#/tui/utils/printable-key'; export interface ApprovalPanelResponse { readonly response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; @@ -170,8 +158,6 @@ function renderDisplayBlock( } return lines; } - case 'workflow_plan': - return renderWorkflowPlanDisplayBlock(block, s); case 'brief': return block.text ? block.text.split('\n').map((line) => (line.length > 0 ? s.strong(line) : '')) @@ -187,57 +173,6 @@ function renderDisplayBlock( } } -/** - * A workflow can carry up to 128 items. Listing all of them would push the - * buttons off the screen, so the panel shows enough to judge the shape of the - * fan-out and says how many it held back. - */ -const MAX_PREVIEW_ITEMS = 10; - -/** - * The plan is the thing being approved, and every field in it came from the - * model. Escape sequences would let that text repaint the panel it is being - * judged in — hide a line, redraw the buttons, or reverse the reading order — - * so they are removed rather than styled. `stripVTControlCharacters` takes the - * CSI and OSC sequences; the class escape then takes the bare control and - * format characters it leaves behind, which include the bidi overrides. - */ -function sanitizePlanText(text: string): string { - return stripVTControlCharacters(text).replaceAll(/[\p{Cc}\p{Cf}]/gu, ' '); -} - -function renderWorkflowPlanDisplayBlock( - block: WorkflowPlanDisplayBlock, - s: BlockStyles, -): string[] { - const plural = block.agent_count === 1 ? 'subagent' : 'subagents'; - const summary = [ - `${String(block.agent_count)} ${plural}`, - `~${String(block.prompt_tokens)} prompt tokens`, - ]; - if (block.model !== undefined && block.model.length > 0) { - summary.push(`model: ${sanitizePlanText(block.model)}`); - } - const lines = [s.strong(summary.join(' '))]; - - if (block.prompt_template !== undefined && block.prompt_template.length > 0) { - lines.push( - `${s.accent('prompt')} ${s.dim(truncateOneLine(sanitizePlanText(block.prompt_template), 200))}`, - ); - } - - for (const [index, item] of block.items.slice(0, MAX_PREVIEW_ITEMS).entries()) { - lines.push( - s.dim(`${String(index + 1).padStart(3)}. ${truncateOneLine(sanitizePlanText(item), 120)}`), - ); - } - const hidden = block.items.length - MAX_PREVIEW_ITEMS; - if (hidden > 0) { - lines.push(s.dim(` +${String(hidden)} more`)); - } - return lines; -} - function normalizeApprovalText(text: string): string { return text.replaceAll('\r\n', '\n').trim(); } @@ -265,8 +200,6 @@ function headerFor(toolName: string): string { return 'Stop this task?'; case 'ExitPlanMode': return 'Ready to build with this plan?'; - case 'DynamicWorkflow': - return 'Run this Dynamic Workflow?'; default: return `Approve ${toolName}?`; } @@ -274,21 +207,6 @@ function headerFor(toolName: string): string { export class ApprovalPanelComponent extends Container implements Focusable { focused = false; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver( - this.bindings.filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:toggle' || - binding.action === 'confirm:toggleExplanation', - ), - ); - private feedbackKeybindings = new KeybindingResolver( - this.bindings.filter((binding) => binding.action === 'confirm:no'), - ); private selectedIndex = 0; private feedbackMode = false; private readonly feedbackInput = new Input(); @@ -340,111 +258,63 @@ export class ApprovalPanelComponent extends Container implements Focusable { } } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:toggle' || - binding.action === 'confirm:toggleExplanation', - ), - ); - this.feedbackKeybindings = new KeybindingResolver( - [...winners.values()].filter((binding) => binding.action === 'confirm:no'), - ); - } - handleInput(data: string): void { - if (this.feedbackMode) { - this.handleFeedbackInput(data); - return; - } - - const handlers = this.handlers(); - const keyId = parseKey(data); if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) ) { this.onResponse({ response: 'rejected' }); return; } - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { - return; - } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.onResponse({ response: 'rejected' }); + + if (matchesKey(data, Key.ctrl('e'))) { + const previewable = this.findPreviewableBlock(); + if (previewable !== undefined && this.onOpenPreview !== undefined) { + this.onOpenPreview(previewable); + } return; } + if (matchesKey(data, Key.ctrl('o'))) { this.onToggleToolOutput?.(); return; } - const printable = printableChar(data); - const numericIndex = Number(printable) - 1; - if (Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < this.choiceCount()) { - this.selectAndSubmit(numericIndex); + + if (this.feedbackMode) { + if (matchesKey(data, Key.up)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); + return; + } + if (matchesKey(data, Key.down)) { + this.feedbackMode = false; + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); + return; + } + this.feedbackInput.handleInput(data); + return; } - } - private handleFeedbackInput(data: string): void { - const keyId = parseKey(data); - if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined - ) { - this.onResponse({ response: 'rejected' }); + if (this.choiceCount() === 0) return; + if (matchesKey(data, Key.up)) { + this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); return; } - const handlers: KeybindingHandlers = { - 'confirm:no': () => this.onResponse({ response: 'rejected' }), - }; - if ( - keyId === undefined - ? this.feedbackKeybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.feedbackKeybindings.dispatch(data, ['Confirmation'], handlers) - ) { + if (matchesKey(data, Key.down)) { + this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); return; } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.onResponse({ response: 'rejected' }); + if (matchesKey(data, Key.enter)) { + this.selectAndSubmit(this.selectedIndex); return; } - this.feedbackInput.handleInput(data); - } - private handlers(): KeybindingHandlers { - return { - 'confirm:yes': () => this.selectAndSubmit(this.selectedIndex), - 'confirm:no': () => this.onResponse({ response: 'rejected' }), - 'confirm:previous': () => { - if (this.choiceCount() > 0) { - this.selectedIndex = (this.selectedIndex - 1 + this.choiceCount()) % this.choiceCount(); - } - }, - 'confirm:next': () => { - if (this.choiceCount() > 0) this.selectedIndex = (this.selectedIndex + 1) % this.choiceCount(); - }, - 'confirm:toggle': () => this.selectAndSubmit(this.selectedIndex), - 'confirm:toggleExplanation': () => { - const previewable = this.findPreviewableBlock(); - if (previewable !== undefined && this.onOpenPreview !== undefined) { - this.onOpenPreview(previewable); - } - }, - }; + const printable = decodeKittyPrintable(data) ?? data; + const numericIndex = Number(printable) - 1; + if (Number.isInteger(numericIndex) && numericIndex >= 0 && numericIndex < this.choiceCount()) { + this.selectAndSubmit(numericIndex); + } } override render(width: number): string[] { @@ -509,42 +379,30 @@ export class ApprovalPanelComponent extends Container implements Focusable { } else { lines.push(indent(strong(` ${labelWithNum}`))); } + + // Optional helper text under the label, aligned past the pointer/number. + // Choices without a description render exactly as before. + if ( + option.description !== undefined && + option.description.length > 0 && + !(this.feedbackMode && option.requires_feedback === true && isSelected) + ) { + for (const descLine of wrapTextWithAnsi(option.description, Math.max(20, width - 7))) { + lines.push(indent(` ${dim(descLine)}`)); + } + } } lines.push(''); if (this.feedbackMode) { - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const hint = [ - 'Type feedback', - '↵ submit', - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} reject`, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); - lines.push(indent(dim(`${hint}.`))); + lines.push(indent(dim('Type feedback · ↵ submit.'))); } else { - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previous'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:next'), - 'select', - ); - const confirm = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:yes'); - const preview = keybindingDisplayText( - this.bindings, - 'Confirmation', - 'confirm:toggleExplanation', - ); - const hint = [ - navigation, - `${buildNumericHint(data.choices.length)} choose`, - confirm === undefined ? undefined : `${formatBindingKeys(confirm)} confirm`, - hasPreviewable && preview !== undefined ? `${formatBindingKeys(preview)} preview` : undefined, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); + const expandHint = hasPreviewable ? ' · ctrl+e preview' : ''; lines.push( indent( - dim(hint), + dim( + `↑/↓ select · ${buildNumericHint(data.choices.length)} choose · ↵ confirm${expandHint}`, + ), ), ); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/approval-preview.ts b/apps/pythinker-code/src/tui/components/dialogs/approval-preview.ts index 3b9105f7..83f0f388 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/approval-preview.ts @@ -24,10 +24,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; -import { renderDiffLines } from '#/tui/components/media/diff-preview'; +import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; @@ -96,11 +96,11 @@ export class ApprovalPreviewViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || matchesKey(data, Key.ctrl('b'))) { + if (matchesKey(data, Key.pageUp) || k === ' ' || data === '\x02') { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl('f'))) { + if (matchesKey(data, Key.pageDown) || data === '\x06') { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -218,17 +218,19 @@ function buildBody(block: ApprovalPreviewBlock): BuiltBody { } function buildDiffBody(block: DiffDisplayBlock): BuiltBody { - // renderDiffLines emits a `+N -M path` header on its first line followed - // by every changed line. We pull the header out into the viewer chrome so - // the body is purely scrollable diff content; this also means we don't - // double-render the path. - const rendered = renderDiffLines( + // renderDiffLinesClustered emits a `+N -M path` header on its first line + // followed by every changed line plus surrounding context. We pull the + // header out into the viewer chrome so the body is purely scrollable diff + // content; this also means we don't double-render the path. + const rendered = renderDiffLinesClustered( block.old_text, block.new_text, block.path, - false, - block.old_start ?? 1, - block.new_start ?? 1, + { + contextLines: 3, + oldStart: block.old_start ?? 1, + newStart: block.new_start ?? 1, + }, ); const [header = '', ...rest] = rendered; return { lines: rest, title: stripLeadingSpace(header) }; diff --git a/apps/pythinker-code/src/tui/components/dialogs/cache-hint-dialog.ts b/apps/pythinker-code/src/tui/components/dialogs/cache-hint-dialog.ts new file mode 100644 index 00000000..937e911c --- /dev/null +++ b/apps/pythinker-code/src/tui/components/dialogs/cache-hint-dialog.ts @@ -0,0 +1,121 @@ +/** + * CacheHintDialog — shown when a resumed (or long-idle) session's context + * cache has almost certainly expired, so the next turn re-sends the whole + * history uncached. Offers compact / new session / continue / never. + * + * Layout mirrors the list-dialog spec (DESIGN.md): top border, title, hint, + * body line, then options with right-column descriptions. + */ + +import { + Container, + matchesKey, + Key, + truncateToWidth, + visibleWidth, + type Focusable, +} from '@pymodel/pi-tui'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import { formatIdleDuration } from '#/tui/utils/cache-hint'; +import { SearchableList } from '#/tui/utils/searchable-list'; +import { formatTokenCount } from '#/utils/usage/usage-format'; + +export type CacheHintAction = 'compact' | 'new' | 'continue' | 'never'; + +interface CacheHintOption { + readonly value: CacheHintAction; + readonly label: string; + readonly description?: string; +} + +const OPTIONS: readonly CacheHintOption[] = [ + { + value: 'compact', + label: 'Compact and continue', + description: 'one-time compact cost · cheapest way to keep this topic', + }, + { + value: 'new', + label: 'Start a new session', + description: 'zero context cost · best for a new task', + }, + { + value: 'continue', + label: 'Continue as-is', + description: 'full history kept · highest cost per turn', + }, + { value: 'never', label: "Don't ask me again" }, +]; + +export interface CacheHintDialogOptions { + readonly idleSeconds: number; + readonly totalTokens: number; + readonly onSelect: (action: CacheHintAction) => void; + readonly onCancel: () => void; +} + +export class CacheHintDialogComponent extends Container implements Focusable { + focused = false; + private readonly opts: CacheHintDialogOptions; + private readonly list: SearchableList<CacheHintOption>; + + constructor(opts: CacheHintDialogOptions) { + super(); + this.opts = opts; + this.list = new SearchableList({ + items: OPTIONS, + toSearchText: (o) => o.label, + initialIndex: 0, + searchable: false, + }); + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.enter)) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSelect(chosen.value); + return; + } + this.list.handleKey(data); + } + + override render(width: number): string[] { + const view = this.list.view(); + const title = `This session has been idle for ${formatIdleDuration(this.opts.idleSeconds)} and is ~${formatTokenCount(this.opts.totalTokens)} tokens.`; + const lines: string[] = [ + currentTheme.fg('primary', '─'.repeat(width)), + currentTheme.boldFg('primary', ` ${title}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), + '', + currentTheme.fg( + 'text', + ' Cache expired — the next message re-sends the entire history at full price.', + ), + ]; + + const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label))); + for (let i = view.page.start; i < view.page.end; i++) { + const opt = view.items[i]!; + const isSelected = i === view.selectedIndex; + const pointer = isSelected ? SELECT_POINTER : ' '; + let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); + line += isSelected + ? currentTheme.boldFg('primary', opt.label) + : currentTheme.fg('text', opt.label); + if (opt.description !== undefined) { + const gap = maxLabelWidth - visibleWidth(opt.label) + 4; + line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description); + } + lines.push(line); + } + + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts b/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts index b5795fb6..2b89f7bb 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/choice-picker.ts @@ -12,20 +12,11 @@ import { Container, matchesKey, Key, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingContext, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme, type ColorToken } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -39,6 +30,9 @@ export interface ChoiceOption { readonly tone?: 'danger'; /** Optional explanatory text shown below the label. */ readonly description?: string | undefined; + /** Color token applied to the description while this option is selected, drawing + * attention to important details. Falls back to `textMuted` when unset or not selected. */ + readonly descriptionTone?: ColorToken; } export interface ChoicePickerOptions { @@ -46,24 +40,18 @@ export interface ChoicePickerOptions { readonly hint?: string; readonly formatHint?: (text: string) => string; readonly notice?: string; - readonly noticeTone?: ColorToken; + /** Color tone for the notice line. Defaults to 'success'. */ + readonly noticeTone?: 'success' | 'warning'; readonly options: readonly ChoiceOption[]; readonly currentValue?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate. */ readonly pageSize?: number; - readonly keybindingContext?: 'Select' | 'HistorySearch' | 'MessageActions'; - readonly onExecute?: (value: string) => void; - readonly isUserOption?: (option: ChoiceOption) => boolean; - readonly onCopy?: (value: string) => void; - readonly onPrimaryInput?: (value: string) => void; - readonly secondaryAction?: { - readonly key: string; - readonly label: string; - readonly onSelect: (value: string) => void; - }; readonly onSelect: (value: string) => void; + /** When provided, Alt+S invokes this with the selected value instead of + * onSelect — used to apply the choice to the current session only. */ + readonly onSessionOnlySelect?: (value: string) => void; readonly onCancel: () => void; } @@ -94,8 +82,6 @@ export class ChoicePickerComponent extends Container implements Focusable { focused = false; private readonly opts: ChoicePickerOptions; private readonly list: SearchableList<ChoiceOption>; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver([]); constructor(opts: ChoicePickerOptions) { super(); @@ -108,41 +94,17 @@ export class ChoicePickerComponent extends Container implements Focusable { initialIndex: Math.max(currentIdx, 0), searchable: opts.searchable === true, }); - this.setKeybindings(this.bindings); - } - - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const context = this.opts.keybindingContext ?? 'Select'; - const actions = new Set(Object.keys(this.handlers(context))); - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === null - ? binding.context === context && - binding.chord.length === 1 && - (binding.chord[0] === 'enter' || - (binding.chord[0] === 'space' && - context !== 'MessageActions' && - this.opts.searchable !== true)) - : actions.has(binding.action), - ), - ); } handleInput(data: string): void { - const context = this.opts.keybindingContext ?? 'Select'; - const handlers = this.handlers(context); - const keyId = parseKey(data); - if ( - keyId?.includes('+') === true - ? this.keybindings.dispatch(data, [context], handlers) - : this.keybindings.dispatchKeyId(keyId ?? data, [context], handlers) - ) { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const chosen = this.list.selected(); + if (chosen !== undefined) this.opts.onSessionOnlySelect(chosen.value); return; } // Left/Right page through the list (this picker has no horizontal control). @@ -154,27 +116,15 @@ export class ChoicePickerComponent extends Container implements Focusable { this.list.pageDown(); return; } - const secondaryAction = this.opts.secondaryAction; - if ( - secondaryAction !== undefined && - printableChar(data)?.toLowerCase() === secondaryAction.key.toLowerCase() - ) { - const chosen = this.list.selected(); - if (chosen !== undefined) secondaryAction.onSelect(chosen.value); - return; - } - // Keep the legacy native selection fallback outside MessageActions. Its - // configurable Enter action must remain inactive when explicitly unbound. + // Enter always selects. Space selects too — but only when the list is not + // searchable; in a searchable list a space must reach the query instead. const isSpace = matchesKey(data, Key.space) || printableChar(data) === ' '; - if ( - context !== 'MessageActions' && - (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) - ) { + if (matchesKey(data, Key.enter) || (isSpace && this.opts.searchable !== true)) { const chosen = this.list.selected(); if (chosen !== undefined) this.opts.onSelect(chosen.value); return; } - this.list.handleSearchKey(data); + this.list.handleKey(data); } override render(width: number): string[] { @@ -185,26 +135,33 @@ export class ChoicePickerComponent extends Container implements Focusable { // Header mirrors the model dialog (see model-selector.ts): border, title // with a "(type to search)" suffix until you type, the hint, a blank, then // the search line. Key vocabulary is lowercase to match every list dialog. - const navParts = this.bindingHints(); + const navParts = ['↑↓ navigate']; if (view.page.pageCount > 1) navParts.push('←→ page'); - if (this.opts.secondaryAction !== undefined) { - navParts.push( - `${this.opts.secondaryAction.key.toUpperCase()} ${this.opts.secondaryAction.label}`, - ); - } - const hint = navParts.join(' · '); + navParts.push('Enter select', 'Esc cancel'); + const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; + const hintLines = hint.split(/\r?\n/); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix, - this.opts.formatHint === undefined - ? currentTheme.fg('textMuted', ` ${hint}`) - : this.opts.formatHint(` ${hint}`), ]; + for (const hintLine of hintLines) { + lines.push( + this.opts.formatHint === undefined + ? currentTheme.fg('textMuted', ` ${hintLine}`) + : this.opts.formatHint(` ${hintLine}`), + ); + } if (this.opts.notice !== undefined) { - lines.push(currentTheme.fg(this.opts.noticeTone ?? 'success', ` ${this.opts.notice}`)); + const tone = this.opts.noticeTone ?? 'success'; + const noticeWidth = Math.max(1, width - 1); + for (const noticeLine of this.opts.notice.split(/\r?\n/)) { + for (const wrapped of wrapDescription(noticeLine, noticeWidth)) { + lines.push(currentTheme.fg(tone, ` ${wrapped}`)); + } + } } lines.push(''); if (searchable && view.query.length > 0) { @@ -228,8 +185,10 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(line); if (opt.description !== undefined && opt.description.length > 0) { const descriptionWidth = Math.max(1, width - 4); + const descriptionColor = + isSelected && opt.descriptionTone !== undefined ? opt.descriptionTone : 'textMuted'; for (const descLine of wrapDescription(opt.description, descriptionWidth)) { - lines.push(currentTheme.fg('textMuted', ` ${descLine}`)); + lines.push(currentTheme.fg(descriptionColor, ` ${descLine}`)); } } } @@ -245,158 +204,6 @@ export class ChoicePickerComponent extends Container implements Focusable { lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } - - private handlers(context: KeybindingContext): KeybindingHandlers { - const accept = (): void => { - const chosen = this.list.selected(); - if (chosen !== undefined) this.opts.onSelect(chosen.value); - }; - const cancel = (): void => { - if (!this.list.clearQuery()) this.opts.onCancel(); - }; - switch (context) { - case 'HistorySearch': - return { - 'historySearch:next': () => this.list.moveDown(), - 'historySearch:accept': accept, - 'historySearch:cancel': () => this.opts.onCancel(), - 'historySearch:execute': () => { - const chosen = this.list.selected(); - if (chosen !== undefined) this.opts.onExecute?.(chosen.value); - }, - }; - case 'MessageActions': - const handlers: Record<string, () => void> = { - 'messageActions:prev': () => this.list.moveUp(), - 'messageActions:next': () => this.list.moveDown(), - 'messageActions:prevUser': () => { - if (this.opts.isUserOption !== undefined) { - this.list.moveToPrevious(this.opts.isUserOption); - } - }, - 'messageActions:nextUser': () => { - if (this.opts.isUserOption !== undefined) this.list.moveToNext(this.opts.isUserOption); - }, - 'messageActions:top': () => this.list.moveToStart(), - 'messageActions:bottom': () => this.list.moveToEnd(), - 'messageActions:escape': cancel, - 'messageActions:ctrlc': cancel, - 'messageActions:enter': accept, - }; - if (this.opts.onCopy !== undefined) { - handlers['messageActions:c'] = () => { - const chosen = this.list.selected(); - if (chosen !== undefined) this.opts.onCopy?.(chosen.value); - }; - } - if (this.opts.onPrimaryInput !== undefined) { - handlers['messageActions:p'] = () => { - const chosen = this.list.selected(); - if (chosen !== undefined) this.opts.onPrimaryInput?.(chosen.value); - }; - } - return handlers; - case 'Select': - return { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:accept': accept, - 'select:cancel': cancel, - }; - default: - return {}; - } - } - - private bindingHints(): string[] { - const context = this.opts.keybindingContext ?? 'Select'; - const hint = ( - action: Parameters<typeof keybindingDisplayText>[2], - description: string, - ): string | undefined => { - const keys = keybindingDisplayText(this.bindings, context, action); - return keys === undefined ? undefined : `${formatBindingKeys(keys)} ${description}`; - }; - const hints = - context === 'HistorySearch' - ? [ - hint('historySearch:next', 'next'), - hint('historySearch:accept', 'accept'), - hint('historySearch:cancel', 'cancel'), - hint('historySearch:execute', 'execute'), - ] - : context === 'MessageActions' - ? [ - hint('messageActions:prev', 'previous'), - hint('messageActions:next', 'next'), - this.opts.onCopy === undefined ? undefined : hint('messageActions:c', 'copy'), - this.opts.onPrimaryInput === undefined - ? undefined - : hint('messageActions:p', 'copy input'), - hint('messageActions:enter', 'select'), - hint('messageActions:escape', 'cancel'), - ] - : [ - combinedBindingHint( - keybindingDisplayText(this.bindings, context, 'select:previous'), - keybindingDisplayText(this.bindings, context, 'select:next'), - 'navigate', - ), - hint('select:accept', 'select'), - hint('select:cancel', 'cancel'), - ]; - return hints.filter((value): value is string => value !== undefined); - } -} - -export function formatBindingKeys(keys: string): string { - const labels: Readonly<Record<string, string>> = { - up: '↑', - down: '↓', - left: '←', - right: '→', - enter: 'Enter', - escape: 'Esc', - tab: 'Tab', - 'shift+tab': 'Shift+Tab', - backspace: 'Backspace', - pageup: 'PgUp', - pagedown: 'PgDn', - space: 'Space', - }; - return keys - .split(' / ') - .map((key) => labels[key] ?? key) - .join(' / '); -} - -export function combinedBindingHint( - first: string | undefined, - second: string | undefined, - description: string, -): string | undefined { - const keys = [first, second].filter((value): value is string => value !== undefined); - if (keys.length === 0) return undefined; - const formatted = keys.map((value) => formatBindingKeys(value).split(' / ')); - if ( - formatted.length === 2 && - formatted[0]?.join(' / ') === '↑ / k / ctrl+p' && - formatted[1]?.join(' / ') === '↓ / j / ctrl+n' - ) { - return `↑↓ ${description}`; - } - const leadingPair = `${formatted[0]?.[0] ?? ''}${formatted[1]?.[0] ?? ''}`; - if (leadingPair === '↑↓' || leadingPair === '←→') { - return `${[leadingPair, ...formatted.flatMap((value) => value.slice(1))].join(' / ')} ${description}`; - } - if ( - formatted.length === 2 && - formatted[0]?.join(' / ') === 'Tab / →' && - formatted[1]?.join(' / ') === 'Shift+Tab / ←' - ) { - return `Tab ${description}`; - } - return `${formatted.flat().join(' / ')} ${description}`; } function optionLabelStyle( diff --git a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts index 45d49a7b..c0fa67ca 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/compaction.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/compaction.ts @@ -2,204 +2,186 @@ * Renders a compaction block in the transcript. * * Lifecycle: - * - constructed on `compaction.started` → elapsed-time label, - * a progress bar, and optional custom - * instruction - * - `markDone()` on `compaction.completed` → collapsed - * "└ Compacted (ctrl+o to see full summary)"; ctrl+o (the shared - * `Expandable` contract) reveals the token counts and the summary text + * - constructed on `compaction.started` → blinking white bullet + + * "Compacting context..." and optional custom instruction + * - `markDone()` on `compaction.completed` → solid green bullet + + * "Compaction complete (X → Y tokens)" * - `markCanceled()` on `compaction.cancelled` → solid warning bullet + * "Compaction cancelled" + * + * Bullet animation mirrors `ToolCallComponent` (500ms blink) so the user + * reads the same "work in progress" signal across the UI. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; -import type { TUI } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@pymodel/pi-tui'; +import type { TUI } from '@pymodel/pi-tui'; -import { BRAILLE_SPINNER_INTERVAL_MS, MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; -import { shimmerText } from '#/tui/utils/shimmer'; - -/** Summary body sits under the "└ " marker the done line opens with. */ -const SUMMARY_INDENT = `${MESSAGE_INDENT} `; - -const BAR_FILLED = '▰'; -const BAR_EMPTY = '▱'; -const BAR_MAX_WIDTH = 40; -/** - * The agent emits no progress events between `compaction.started` and - * `compaction.completed`, so elapsed seconds double as the percentage. The - * ceiling keeps it short of full so only the completion event can finish it. - */ -const PROGRESS_CEILING = 0.95; +const BLINK_INTERVAL = 500; export class CompactionComponent extends Container { private readonly ui: TUI | undefined; + private readonly headerText: Text; + private instructionText: Text | undefined; private readonly instruction: string | undefined; - private readonly startedAt = Date.now(); - private headerText: Text; - private barText: Text | undefined; - private barWidth = BAR_MAX_WIDTH; - private animationFrame = 0; - private animationTimer: ReturnType<typeof setInterval> | null = null; + private readonly tip: string | undefined; + private blinkOn = true; + private blinkTimer: ReturnType<typeof setInterval> | null = null; private done = false; private canceled = false; - private expanded = false; private tokensBefore: number | undefined; private tokensAfter: number | undefined; private summary: string | undefined; + private summaryText: Text | undefined; + private expanded = false; - constructor(ui?: TUI, instruction?: string | undefined) { + constructor(ui?: TUI, instruction?: string | undefined, tip?: string) { super(); this.ui = ui; this.instruction = instruction; - this.headerText = new Text(this.buildHeader(), 0, 0); - this.rebuildChildren(); - this.startAnimation(); - } - - private get running(): boolean { - return !this.done && !this.canceled; - } + this.tip = tip; - /** - * Rebuilds the child list. Called on construction and on state changes - * (done/cancelled/theme switch) — animation ticks reuse the existing Text - * nodes via `setText` instead. - */ - private rebuildChildren(): void { - this.clear(); - // Top margin so the block isn't glued to the previous transcript entry. + // Top margin so the block isn't glued to the previous transcript + // entry (status line, tool result, etc.). this.addChild(new Spacer(1)); this.headerText = new Text(this.buildHeader(), 0, 0); this.addChild(this.headerText); + this.addInstructionChild(); - if (this.running) { - this.barText = new Text(this.buildBar(), 0, 0); - this.addChild(this.barText); - } else { - this.barText = undefined; - } - - if (this.done && this.expanded && this.summary !== undefined) { - for (const line of this.summary.split('\n')) { - this.addChild(new Text(currentTheme.dim(`${SUMMARY_INDENT}${line}`), 0, 0)); - } - } + this.startBlink(); + } + private addInstructionChild(): void { if (this.instruction !== undefined) { - this.addChild(new Text(currentTheme.dim(` ${this.instruction}`), 0, 0)); + this.instructionText = new Text(currentTheme.dim(` ${this.instruction}`), 0, 0); + this.addChild(this.instructionText); } } - override render(width: number): string[] { - // Leave room for the two-space indent and the trailing " 100%". - const nextBarWidth = Math.max(8, Math.min(BAR_MAX_WIDTH, width - 10)); - if (nextBarWidth !== this.barWidth) { - this.barWidth = nextBarWidth; - } - if (this.running) { - this.headerText.setText(this.buildHeader()); - this.barText?.setText(this.buildBar()); + private removeInstructionChild(): void { + if (this.instructionText === undefined) return; + const index = this.children.indexOf(this.instructionText); + if (index !== -1) { + this.children.splice(index, 1); } - return super.render(width); + this.instructionText = undefined; } override invalidate(): void { - // Text nodes cache ANSI codes, so a palette switch needs fresh ones. - this.rebuildChildren(); + // Repaint the header with the active palette (it caches ANSI codes). + this.headerText.setText(this.buildHeader()); + // Rebuild instruction and summary text with fresh theme colours, preserving + // header → instruction → summary child order. + const expanded = this.expanded; + this.removeInstructionChild(); + if (expanded) { + this.removeSummaryChild(); + } + this.addInstructionChild(); + if (expanded) { + this.addSummaryChild(); + } super.invalidate(); } markDone(tokensBefore?: number, tokensAfter?: number, summary?: string): void { - if (!this.running) return; + if (this.done || this.canceled) return; this.done = true; this.tokensBefore = tokensBefore; this.tokensAfter = tokensAfter; - const trimmed = summary?.trim(); - this.summary = trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; - this.stopAnimation(); - this.rebuildChildren(); + this.summary = summary; + this.stopBlink(); + this.headerText.setText(this.buildHeader()); + if (this.expanded) { + this.addSummaryChild(); + } this.ui?.requestRender(); } - /** Shared ctrl+o contract — see `utils/component-capabilities`. */ - setExpanded(expanded: boolean): void { - if (this.expanded === expanded) return; - this.expanded = expanded; - if (this.done) this.rebuildChildren(); - } - markCanceled(): void { - if (!this.running) return; + if (this.done || this.canceled) return; this.canceled = true; - this.stopAnimation(); - this.rebuildChildren(); + this.stopBlink(); + this.headerText.setText(this.buildHeader()); this.ui?.requestRender(); } - dispose(): void { - this.stopAnimation(); + setExpanded(expanded: boolean): void { + if (this.expanded === expanded) return; + this.expanded = expanded; + if (expanded) { + this.addSummaryChild(); + } else { + this.removeSummaryChild(); + } + this.headerText.setText(this.buildHeader()); + this.ui?.requestRender(); } - /** Elapsed-time estimate in the range [0, PROGRESS_CEILING]. */ - private progressRatio(): number { - return Math.min(PROGRESS_CEILING, this.elapsedSeconds() / 100); + private addSummaryChild(): void { + if (this.summaryText !== undefined || this.summary === undefined || this.summary.length === 0) { + return; + } + const indentedSummary = this.summary + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + this.summaryText = new Text(currentTheme.dim(indentedSummary), 0, 0); + this.addChild(this.summaryText); } - private elapsedSeconds(): number { - return Math.max(0, Math.floor((Date.now() - this.startedAt) / 1_000)); + private removeSummaryChild(): void { + if (this.summaryText === undefined) return; + const index = this.children.indexOf(this.summaryText); + if (index !== -1) { + this.children.splice(index, 1); + } + this.summaryText = undefined; } - private buildBar(): string { - const ratio = this.progressRatio(); - const filled = Math.min(this.barWidth, Math.round(ratio * this.barWidth)); - const filledBar = currentTheme.fg('primary', BAR_FILLED.repeat(filled)); - const emptyBar = currentTheme.fg('progressEmpty', BAR_EMPTY.repeat(this.barWidth - filled)); - const percent = currentTheme.dim(` ${String(Math.round(ratio * 100))}%`); - return ` ${filledBar}${emptyBar}${percent}`; + dispose(): void { + this.stopBlink(); } private buildHeader(): string { if (this.done) { - const tokens = + const bullet = currentTheme.fg('success', STATUS_BULLET); + const label = currentTheme.boldFg('success', 'Compaction complete'); + const detail = this.tokensBefore !== undefined && this.tokensAfter !== undefined - ? ` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)` + ? currentTheme.dim(` (${String(this.tokensBefore)} → ${String(this.tokensAfter)} tokens)`) : ''; - // Collapsed, the line advertises ctrl+o; expanded it yields that space - // to the token counts, with the summary rendered underneath. - const detail = - this.summary !== undefined && !this.expanded ? ' (ctrl+o to see full summary)' : tokens; - return `${MESSAGE_INDENT}${currentTheme.dim('└')} ${currentTheme.dim(`Compacted${detail}`)}`; + const shortcutHint = + this.summary !== undefined && this.summary.length > 0 + ? currentTheme.dim(` (Ctrl-O to ${this.expanded ? 'hide' : 'show'} compaction summary)`) + : ''; + return `${bullet}${label}${detail}${shortcutHint}`; } if (this.canceled) { const bullet = currentTheme.fg('warning', STATUS_BULLET); const label = currentTheme.boldFg('warning', 'Compaction cancelled'); return `${bullet}${label}`; } - const label = currentTheme.bold( - shimmerText('Compacting conversation…', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - }), - ); - return `${label}${currentTheme.dim(` (${String(this.elapsedSeconds())}s)`)}`; + const bullet = this.blinkOn ? currentTheme.fg('text', STATUS_BULLET) : ' '; + const label = currentTheme.boldFg('primary', 'Compacting context...'); + const tip = this.tip ? currentTheme.fg('textDim', ` · Tip: ${this.tip}`) : ''; + return `${bullet}${label}${tip}`; } - private startAnimation(): void { - this.animationTimer = setInterval(() => { - this.animationFrame += 1; + private startBlink(): void { + this.blinkTimer = setInterval(() => { + this.blinkOn = !this.blinkOn; this.headerText.setText(this.buildHeader()); - this.barText?.setText(this.buildBar()); this.ui?.requestRender(); - }, BRAILLE_SPINNER_INTERVAL_MS); + }, BLINK_INTERVAL); } - private stopAnimation(): void { - if (this.animationTimer !== null) { - clearInterval(this.animationTimer); - this.animationTimer = null; + private stopBlink(): void { + if (this.blinkTimer !== null) { + clearInterval(this.blinkTimer); + this.blinkTimer = null; } } } diff --git a/apps/pythinker-code/src/tui/components/dialogs/custom-registry-import.ts b/apps/pythinker-code/src/tui/components/dialogs/custom-registry-import.ts index df59790b..6ee29c54 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/custom-registry-import.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/custom-registry-import.ts @@ -14,21 +14,12 @@ import { Input, Key, matchesKey, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; export interface CustomRegistryImportValue { readonly url: string; @@ -43,6 +34,9 @@ const TITLE = 'Import custom provider registry'; const SUBTITLE_DEFAULT = 'Paste an api.json URL and its Bearer token.'; const SUBTITLE_URL_EMPTY = 'Registry URL cannot be empty.'; const SUBTITLE_TOKEN_EMPTY = 'Bearer token cannot be empty.'; +const FOOTER_NOT_LAST = 'Tab / ↑↓ to switch · Enter for next field · Esc to cancel'; +const FOOTER_LAST = 'Tab / ↑↓ to switch · Enter to submit · Esc to cancel'; + type FieldId = 'url' | 'token'; function maskInputLine(raw: string): string { @@ -79,15 +73,6 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo private activeField: FieldId = 'url'; private done = false; private hint: 'none' | 'url-empty' | 'token-empty' = 'none'; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver( - this.bindings.filter( - (binding) => - binding.action === 'confirm:no' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField', - ), - ); constructor( onDone: (result: CustomRegistryImportResult) => void, @@ -106,56 +91,29 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo }; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === 'confirm:no' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField', - ), - ); - } - handleInput(data: string): void { if (this.done) return; - const handlers: KeybindingHandlers = { - 'confirm:no': () => this.cancel(), - 'confirm:nextField': () => this.focusField('token'), - 'confirm:previousField': () => this.focusField('url'), - }; - const keyId = parseKey(data); if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) ) { this.cancel(); return; } - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { + + if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) { + this.toggleField(); return; } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.cancel(); + if (matchesKey(data, Key.down)) { + this.focusField('token'); return; } if (matchesKey(data, Key.up)) { this.focusField('url'); return; } - if (matchesKey(data, Key.down)) { - this.focusField('token'); - return; - } if (this.hint !== 'none') { this.hint = 'none'; @@ -193,20 +151,10 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo ? SUBTITLE_TOKEN_EMPTY : SUBTITLE_DEFAULT; const subtitleStyled = currentTheme.fg('textDim', subtitleText); - const fieldSwitch = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previousField'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:nextField'), - 'switch', + const footerStyled = currentTheme.fg( + 'textDim', + this.activeField === 'url' ? FOOTER_NOT_LAST : FOOTER_LAST, ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const footer = [ - fieldSwitch, - this.activeField === 'url' ? 'Enter for next field' : 'Enter to submit', - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); - const footerStyled = currentTheme.fg('textDim', footer); const urlLabelText = 'Registry URL'; const tokenLabelText = 'Bearer token'; @@ -258,7 +206,9 @@ export class CustomRegistryImportDialogComponent extends Container implements Fo lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); } - lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'), border('╰' + '─'.repeat(safeWidth - 2) + '╯'), ''); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/dynamic-workflow-start-permission-prompt.ts b/apps/pythinker-code/src/tui/components/dialogs/dynamic-workflow-start-permission-prompt.ts index faadad13..66d9cd9c 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/dynamic-workflow-start-permission-prompt.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/dynamic-workflow-start-permission-prompt.ts @@ -15,7 +15,7 @@ const OPTIONS: readonly StartPermissionOption<DynamicWorkflowStartPermissionChoi value: 'auto', label: 'Switch to Auto and start', description: - 'Best for Dynamic Workflow tasks. Tools are approved automatically, and questions are skipped.', + 'Best for dynamic_workflow tasks. Tools are approved automatically, and questions are skipped.', }, { value: 'yolo', @@ -27,20 +27,20 @@ const OPTIONS: readonly StartPermissionOption<DynamicWorkflowStartPermissionChoi value: 'manual', label: 'Start in Manual', description: - 'Keep approvals on. Pythinker Code may stop and wait for you during the Dynamic Workflow task.', + 'Keep approvals on. Pythinker Code may stop and wait for you during the dynamic_workflow task.', }, ]; const NOTICE_LINES = [ 'Manual mode asks you before Pythinker Code runs commands, edits files, or takes other risky actions.', - 'Manual mode can block Dynamic Workflow work while agents are running.', + 'Manual mode can block dynamic_workflow work while agents are running.', 'You can go back without losing your command.', ] as const; export class DynamicWorkflowStartPermissionPromptComponent extends StartPermissionPromptComponent<DynamicWorkflowStartPermissionChoice> { constructor(opts: DynamicWorkflowStartPermissionPromptOptions) { super({ - title: 'Start a Dynamic Workflow task with approvals on?', + title: 'Start a dynamic_workflow task with approvals on?', noticeLines: NOTICE_LINES, options: OPTIONS, onSelect: opts.onSelect, diff --git a/apps/pythinker-code/src/tui/components/dialogs/effort-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/effort-selector.ts index 81f71bb2..1cbc801b 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/effort-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/effort-selector.ts @@ -1,126 +1,104 @@ -/** - * EffortSelector — small list dialog for picking the thinking effort level of - * the current model (mounted by `/effort` with no argument). Follows the - * standard list-dialog layout in .agents/skills/write-tui/DESIGN.md. - */ - import { Container, Key, matchesKey, truncateToWidth, + wrapTextWithAnsi, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; + +import type { ThinkingEffort } from '@pymodel/pythinker-code-sdk'; -import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; -import { SearchableList } from '#/tui/utils/searchable-list'; + +import { effortLabel } from './model-selector'; export interface EffortSelectorOptions { - /** Selectable effort levels for the current model, in order. */ - readonly levels: readonly string[]; - /** Effort level currently in effect (gets the trailing current marker). */ - readonly currentValue: string; - /** Current model display name, shown as the title suffix. */ - readonly modelName: string; - readonly onSelect: (effort: string) => void; + readonly title?: string; + /** Selectable thinking efforts for the current model (e.g. ["off","low","high","max"]). */ + readonly efforts: readonly ThinkingEffort[]; + /** Currently active effort (highlighted). */ + readonly currentValue: ThinkingEffort; + readonly onSelect: (effort: ThinkingEffort) => void; + /** When provided, Alt+S applies the choice to the current session only. */ + readonly onSessionOnlySelect?: (effort: ThinkingEffort) => void; readonly onCancel: () => void; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; } +/** + * Horizontal segmented picker for the `/effort` command. + * + * Mirrors the thinking control rendered under `/model` (see + * `renderThinkingControl` in model-selector.ts): a single row of segments, + * the active one wrapped in `[ ]`. ←/→ step the active segment, Enter + * commits, and Alt+S (when provided) applies session-only. + */ export class EffortSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: EffortSelectorOptions; - private readonly list: SearchableList<string>; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); + private activeIndex: number; constructor(opts: EffortSelectorOptions) { super(); this.opts = opts; - const currentIdx = opts.levels.indexOf(opts.currentValue); - this.list = new SearchableList({ - items: opts.levels, - toSearchText: (level) => level, - initialIndex: Math.max(currentIdx, 0), - searchable: false, - }); - } - - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); + const idx = opts.efforts.indexOf(opts.currentValue); + this.activeIndex = Math.max(idx, 0); } handleInput(data: string): void { - const handlers = { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:accept': () => { - const selected = this.list.selected(); - if (selected !== undefined) this.opts.onSelect(selected); - }, - 'select:cancel': () => this.opts.onCancel(), - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.left)) { + this.activeIndex = Math.max(0, this.activeIndex - 1); + return; + } + if (matchesKey(data, Key.right)) { + this.activeIndex = Math.min(this.opts.efforts.length - 1, this.activeIndex + 1); return; } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + this.opts.onSessionOnlySelect(this.opts.efforts[this.activeIndex]!); + return; + } + if (matchesKey(data, Key.enter)) { + this.opts.onSelect(this.opts.efforts[this.activeIndex]!); + return; } } override render(width: number): string[] { - const view = this.list.view(); + const hintParts = ['←→ switch', 'Enter select']; + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); + const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Thinking effort') + - currentTheme.fg('textMuted', ` ${this.opts.modelName}`), - currentTheme.fg('textMuted', ` ${this.bindingHints().join(' · ')}`), - '', + currentTheme.boldFg('primary', ` ${this.opts.title ?? 'Select thinking effort'}`), + currentTheme.fg('textMuted', ` ${hintParts.join(' · ')}`), ]; - - for (let i = view.page.start; i < view.page.end; i++) { - const level = view.items[i]; - if (level === undefined) continue; - const isSelected = i === view.selectedIndex; - const isCurrent = level === this.opts.currentValue; - const pointer = isSelected ? SELECT_POINTER : ' '; - let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `); - line += isSelected ? currentTheme.boldFg('primary', level) : currentTheme.fg('text', level); - if (isCurrent) { - line += ' ' + currentTheme.fg('success', CURRENT_MARK); + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); } - lines.push(line); } + lines.push(''); - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width)); - } + const segments = this.opts.efforts.map((effort, index) => { + const label = effortLabel(effort); + return index === this.activeIndex + ? currentTheme.boldFg('primary', `[ ${label} ]`) + : currentTheme.fg('text', ` ${label} `); + }); + lines.push(` ${segments.join(' ')}`); - private bindingHints(): string[] { - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ); - const accept = keybindingDisplayText(this.bindings, 'Select', 'select:accept'); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - return [ - navigation, - accept === undefined ? undefined : `${formatBindingKeys(accept)} select`, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ].filter((hint): hint is string => hint !== undefined); + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width)); } } diff --git a/apps/pythinker-code/src/tui/components/dialogs/experiments-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/experiments-selector.ts index 36566bf2..2b40e4c3 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/experiments-selector.ts @@ -5,17 +5,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import type { ExperimentalFeatureState } from '@pymodel/pythinker-code-sdk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -39,8 +32,6 @@ export class ExperimentsSelectorComponent extends Container implements Focusable private readonly opts: ExperimentsSelectorOptions; private readonly list: SearchableList<ExperimentalFeatureState>; private readonly draft = new Map<ExperimentalFeatureState['id'], boolean>(); - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); constructor(opts: ExperimentsSelectorOptions) { super(); @@ -52,33 +43,15 @@ export class ExperimentsSelectorComponent extends Container implements Focusable }); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - handleInput(data: string): void { - const handlers = { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:accept': () => { - const changes = this.draftChanges(); - if (changes.length > 0) this.opts.onApply(changes); - }, - 'select:cancel': () => { - if (!this.list.clearQuery()) this.opts.onCancel(); - }, - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); return; } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); + if (matchesKey(data, Key.enter)) { + const changes = this.draftChanges(); + if (changes.length > 0) this.opts.onApply(changes); return; } const decoded = printableChar(data); @@ -87,26 +60,16 @@ export class ExperimentsSelectorComponent extends Container implements Focusable if (selected !== undefined) this.toggleDraft(selected); return; } - this.list.handleSearchKey(data); + this.list.handleKey(data); } override render(width: number): string[] { const view = this.list.view(); const titleSuffix = view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; - const hintParts: string[] = []; - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ); - if (navigation !== undefined) hintParts.push(navigation); + const hintParts = ['↑↓ navigate']; if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); - hintParts.push('Space toggle'); - const accept = keybindingDisplayText(this.bindings, 'Select', 'select:accept'); - if (accept !== undefined) hintParts.push(`${formatBindingKeys(accept)} apply`); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - if (cancel !== undefined) hintParts.push(`${formatBindingKeys(cancel)} cancel`); + hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); if (view.query.length > 0) hintParts.push('Backspace clear'); const lines: string[] = [ @@ -146,7 +109,8 @@ export class ExperimentsSelectorComponent extends Container implements Focusable ), ); } - lines.push(this.renderApplyButton(), currentTheme.fg('primary', '─'.repeat(width))); + lines.push(this.renderApplyButton()); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/feedback-input-dialog.ts b/apps/pythinker-code/src/tui/components/dialogs/feedback-input-dialog.ts index f46b761e..51cddfdc 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/feedback-input-dialog.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/feedback-input-dialog.ts @@ -5,6 +5,10 @@ * Geometry mirrors `DeviceCodeBox` so the chrome stays consistent with * the OAuth login flow. The box embeds a `pi-tui` Input for the actual * text entry; cursor visibility tracks the dialog's `focused` flag. + * + * This is stage 1 of the feedback flow: it collects the free-form text + * only. Whether to attach diagnostic logs / codebase is decided in a + * follow-up stage (see `promptFeedbackAttachment`). */ import { @@ -12,20 +16,11 @@ import { Input, Key, matchesKey, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import { formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; +} from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; export type FeedbackInputDialogResult = | { readonly kind: 'ok'; readonly value: string } @@ -34,6 +29,8 @@ export type FeedbackInputDialogResult = const TITLE = 'Send feedback to Pythinker Code'; const SUBTITLE_DEFAULT = "Tell us what's working or what's not."; const SUBTITLE_EMPTY = 'Feedback cannot be empty.'; +const FOOTER = 'Enter to submit · Esc to cancel'; + export class FeedbackInputDialogComponent extends Container implements Focusable { focused = false; @@ -41,10 +38,6 @@ export class FeedbackInputDialogComponent extends Container implements Focusable private readonly onDone: (result: FeedbackInputDialogResult) => void; private done = false; private emptyHinted = false; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver( - this.bindings.filter((binding) => binding.action === 'confirm:no'), - ); constructor(onDone: (result: FeedbackInputDialogResult) => void) { super(); @@ -54,39 +47,16 @@ export class FeedbackInputDialogComponent extends Container implements Focusable }; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter((binding) => binding.action === 'confirm:no'), - ); - } - handleInput(data: string): void { if (this.done) return; - const keyId = parseKey(data); if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined + matchesKey(data, Key.escape) || + matchesKey(data, Key.ctrl('c')) || + matchesKey(data, Key.ctrl('d')) ) { this.cancel(); return; } - const handlers: KeybindingHandlers = { 'confirm:no': () => this.cancel() }; - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { - return; - } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.cancel(); - return; - } if (this.emptyHinted) { this.emptyHinted = false; } @@ -110,21 +80,22 @@ export class FeedbackInputDialogComponent extends Container implements Focusable const titleStyled = currentTheme.boldFg('textStrong', TITLE); const subtitleText = this.emptyHinted ? SUBTITLE_EMPTY : SUBTITLE_DEFAULT; const subtitleStyled = currentTheme.fg('textDim', subtitleText); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const footer = [ - 'Enter to submit', - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} to cancel`, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); - const footerStyled = currentTheme.fg('textDim', footer); + const footerStyled = currentTheme.fg('textDim', FOOTER); const titleLine = truncateToWidth(titleStyled, innerWidth, '…'); const subtitleLine = truncateToWidth(subtitleStyled, innerWidth, '…'); const footerLine = truncateToWidth(footerStyled, innerWidth, '…'); const inputLine = this.input.render(innerWidth)[0] ?? '> '; - const contentLines: string[] = [titleLine, '', subtitleLine, '', inputLine, '', footerLine]; + const contentLines: string[] = [ + titleLine, + '', + subtitleLine, + '', + inputLine, + '', + footerLine, + ]; if (safeWidth < 4) { return ['', ...contentLines.map((line) => truncateToWidth(line, safeWidth, '…'))]; @@ -142,7 +113,9 @@ export class FeedbackInputDialogComponent extends Container implements Focusable lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); } - lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'), border('╰' + '─'.repeat(safeWidth - 2) + '╯'), ''); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); return lines.map((line) => truncateToWidth(line, safeWidth, '…')); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/pythinker-code/src/tui/components/dialogs/goal-queue-manager.ts index 50085488..b753eb07 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -6,17 +6,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import type { GoalQueueMoveDirection, GoalQueueSnapshot, @@ -73,8 +66,6 @@ export class GoalQueueManagerComponent extends Container implements Focusable { private list: SearchableList<UpcomingGoal>; private movingGoalId: string | undefined; private busy = false; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); constructor(opts: GoalQueueManagerOptions) { super(); @@ -83,32 +74,9 @@ export class GoalQueueManagerComponent extends Container implements Focusable { this.list = this.createList(opts.selectedGoalId); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - handleInput(data: string): void { if (this.busy) return; - if (this.movingGoalId === undefined) { - const handlers = { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:cancel': () => this.opts.onCancel(), - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); - return; - } - } else if (matchesKey(data, Key.escape)) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } @@ -139,33 +107,15 @@ export class GoalQueueManagerComponent extends Container implements Focusable { void this.applyQueueAction({ kind: 'move', goalId: this.movingGoalId, direction: 'down' }); return; } - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); - return; - } } - this.list.handleSearchKey(data); + if (this.list.handleKey(data)) return; } override render(width: number): string[] { const view = this.list.view(); const hint = this.movingGoalId === undefined - ? [ - combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ), - 'Space select', - 'E edit', - 'D delete', - this.selectHint('select:cancel', 'cancel'), - ].filter((part): part is string => part !== undefined).join(' · ') + ? '↑↓ navigate · Space select · E edit · D delete · Esc cancel' : '↑↓ reorder · Space done · E edit · D delete · Esc cancel'; const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), @@ -185,11 +135,13 @@ export class GoalQueueManagerComponent extends Container implements Focusable { const below = view.items.length - view.page.end; if (below > 0) { - lines.push('', currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } @@ -219,11 +171,6 @@ export class GoalQueueManagerComponent extends Container implements Focusable { return this.list.selected(); } - private selectHint(action: 'select:cancel', description: string): string | undefined { - const binding = keybindingDisplayText(this.bindings, 'Select', action); - return binding === undefined ? undefined : `${formatBindingKeys(binding)} ${description}`; - } - private async applyQueueAction(action: Exclude<GoalQueueManagerAction, { kind: 'edit' }>) { this.busy = true; try { @@ -334,7 +281,9 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable lines.push(border('│') + pad + content + ' '.repeat(rightPad) + border('│')); } - lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'), border('╰' + '─'.repeat(safeWidth - 2) + '╯'), ''); + lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│')); + lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯')); + lines.push(''); return lines.map((line) => truncateToWidth(line, safeWidth, ELLIPSIS)); } @@ -346,7 +295,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable return; } if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { - this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters.`; + this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters; put long content in a file and reference the file path.`; return; } this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); diff --git a/apps/pythinker-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/pythinker-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index 8737ccdd..b5950ec0 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -11,7 +11,7 @@ export interface GoalStartPermissionPromptOptions { readonly onCancel: () => void; } -const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_MANUAL_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -37,7 +37,7 @@ const MANUAL_OPTIONS: readonly StartPermissionOption[] = [ }, ]; -const YOLO_OPTIONS: readonly StartPermissionOption[] = [ +export const GOAL_START_YOLO_OPTIONS: readonly StartPermissionOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -57,6 +57,14 @@ const YOLO_OPTIONS: readonly StartPermissionOption[] = [ }, ]; +export function goalStartOptions(mode: 'manual' | 'yolo'): readonly StartPermissionOption[] { + return mode === 'yolo' ? GOAL_START_YOLO_OPTIONS : GOAL_START_MANUAL_OPTIONS; +} + +const MANUAL_OPTIONS = GOAL_START_MANUAL_OPTIONS; + +const YOLO_OPTIONS = GOAL_START_YOLO_OPTIONS; + const MANUAL_NOTICE_LINES = [ 'Manual mode asks you before Pythinker Code runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', diff --git a/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts b/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts index b068be3f..81434f36 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/help-panel.ts @@ -15,17 +15,9 @@ import { decodeKittyPrintable, type Focusable, truncateToWidth, -} from '@earendil-works/pi-tui'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; +} from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; -import { formatBindingKeys } from './choice-picker'; - export interface KeyboardShortcut { readonly keys: string; readonly description: string; @@ -39,12 +31,10 @@ export interface HelpPanelCommand { /** Static list — keep in sync with the global editor bindings. */ export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ + { keys: 'Shift-Tab', description: 'Toggle plan mode' }, { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, - { - keys: 'Shift-Tab / Ctrl-T', - description: 'Cycle thinking effort (see /plan for plan mode)', - }, + { keys: 'Ctrl-O', description: 'Toggle tool output / compaction summary expansion' }, + { keys: 'Ctrl-T', description: 'Expand / collapse the todo list (when truncated)' }, { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, @@ -66,31 +56,20 @@ export class HelpPanelComponent extends Container implements Focusable { focused = false; private readonly opts: HelpPanelOptions; private scrollTop = 0; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); constructor(opts: HelpPanelOptions) { super(); this.opts = opts; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - handleInput(data: string): void { - const handlers = { - 'help:dismiss': () => this.opts.onClose(), - } as const; + const printable = decodeKittyPrintable(data) ?? data; if ( - this.keybindings.dispatch(data, ['Help'], handlers) || - this.keybindings.dispatchKeyId(data, ['Help'], handlers) + matchesKey(data, Key.escape) || + matchesKey(data, Key.enter) || + printable === 'q' || + printable === 'Q' ) { - return; - } - const printable = decodeKittyPrintable(data) ?? data; - if (matchesKey(data, Key.enter) || printable === 'q' || printable === 'Q') { this.opts.onClose(); return; } @@ -126,16 +105,9 @@ export class HelpPanelComponent extends Container implements Focusable { return `/${c.name}${aliases}`; }); const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); - const configuredDismiss = keybindingDisplayText(this.bindings, 'Help', 'help:dismiss'); - const dismissKeys = [ - configuredDismiss === undefined ? undefined : formatBindingKeys(configuredDismiss), - 'Enter', - 'q', - ].filter((key): key is string => key !== undefined); const lines: string[] = [ accent('─'.repeat(width)), - currentTheme.boldFg('primary', ' help ') + - muted(`· ${dismissKeys.join(' / ')} to cancel · ↑↓ scroll`), + currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), '', // Greeting ` ${dim('Sure, Pythinker is ready to help! Just send a message to get started.')}`, diff --git a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts index 92a9802d..c6086edd 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/model-selector.ts @@ -1,8 +1,8 @@ import { coerceEffortForModel, - effortLevelsForModel, - thinkingAvailability, + effectiveModelAlias, type ModelAlias, + type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; import { Container, @@ -10,25 +10,18 @@ import { matchesKey, truncateToWidth, visibleWidth, + wrapTextWithAnsi, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; +import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; -import { shortEffortLabel } from '#/tui/utils/thinking-levels'; -import { - combinedBindingHint, - formatBindingKeys, - type ChoiceOption, -} from './choice-picker'; +import type { ChoiceOption } from './choice-picker'; + +type ThinkingAvailability = 'toggle' | 'always-on' | 'unsupported'; interface ModelChoice { readonly alias: string; @@ -43,103 +36,44 @@ interface ModelChoice { export interface ModelSelection { readonly alias: string; - readonly effort: string; -} - -export interface NormalizedModelChoices { - readonly models: Record<string, ModelAlias>; - readonly aliasMap: Record<string, string>; - readonly identityAliases: Record<string, string>; + /** Chosen thinking effort: 'off', or a concrete effort such as 'low' / + * 'high' / 'max'. Boolean 'on' is normalized to the model's default effort + * before the selection is committed (see commitEffort). */ + readonly thinking: ThinkingEffort; } export function modelDisplayName(alias: string, model: ModelAlias | undefined): string { - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } export function providerDisplayName(provider: string): string { + if (provider === DEFAULT_OAUTH_PROVIDER_NAME) return PRODUCT_NAME; if (provider.startsWith('managed:')) return provider.slice('managed:'.length); return provider; } -export function canonicalModelAlias(model: Pick<ModelAlias, 'provider' | 'model'>): string { - return `${model.provider}/${model.model}`; -} - -export function modelIdentity( - model: Pick<ModelAlias, 'provider' | 'model'> | undefined, -): string | undefined { - return model === undefined ? undefined : `${model.provider}\u0000${model.model}`; -} - -export function normalizeModelChoices( - models: Record<string, ModelAlias>, -): NormalizedModelChoices { - const aliasMap: Record<string, string> = {}; - const identityAliases: Record<string, string> = {}; - const sourceAliasesByIdentity = new Map<string, string[]>(); - const representativeByIdentity = new Map<string, { alias: string; model: ModelAlias }>(); - const identityOrder: string[] = []; - - for (const [alias, cfg] of Object.entries(models)) { - const identity = modelIdentity(cfg); - if (identity === undefined) continue; - const sourceAliases = sourceAliasesByIdentity.get(identity); - if (sourceAliases === undefined) { - sourceAliasesByIdentity.set(identity, [alias]); - representativeByIdentity.set(identity, { alias, model: cfg }); - identityOrder.push(identity); - continue; - } - - sourceAliases.push(alias); - const representative = representativeByIdentity.get(identity); - if (representative !== undefined && alias === canonicalModelAlias(cfg)) { - representative.alias = alias; - representative.model = cfg; - } - } - - const normalized: Record<string, ModelAlias> = {}; - for (const identity of identityOrder) { - const representative = representativeByIdentity.get(identity); - if (representative === undefined) continue; - normalized[representative.alias] = representative.model; - identityAliases[identity] = representative.alias; - for (const sourceAlias of sourceAliasesByIdentity.get(identity) ?? []) { - aliasMap[sourceAlias] = representative.alias; - } - aliasMap[representative.alias] = representative.alias; - } - - return { models: normalized, aliasMap, identityAliases }; -} - -export function resolveNormalizedModelAlias( - normalized: NormalizedModelChoices, - alias: string, - fallbackModel?: Pick<ModelAlias, 'provider' | 'model'>, -): string | undefined { - const mapped = normalized.aliasMap[alias]; - if (mapped !== undefined) return mapped; - const identity = modelIdentity(fallbackModel); - return identity === undefined ? undefined : normalized.identityAliases[identity]; -} - export function createModelChoiceOptions( models: Record<string, ModelAlias>, ): readonly ChoiceOption[] { - const normalized = normalizeModelChoices(models); - return Object.entries(normalized.models).map(([alias, cfg]) => ({ - value: alias, - label: `${modelDisplayName(alias, cfg)} (${providerDisplayName(cfg.provider)})`, - })); + return Object.entries(models).map(([alias, cfg]) => { + const effective = effectiveModelAlias(cfg); + return { + value: alias, + label: `${modelDisplayName(alias, effective)} (${providerDisplayName(effective.provider)})`, + }; + }); } export interface ModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentEffort: string; + /** Live thinking effort of the currently active model (e.g. 'off', 'on', + * 'high'). Used to highlight the active segment for the current model. */ + readonly currentThinkingEffort: ThinkingEffort; + /** Overrides the default ' Select a model' title line. */ + readonly title?: string; /** When true, typed characters filter the list (fuzzy) and a search line is shown. */ readonly searchable?: boolean; /** Items per page. Lists longer than this paginate (PgUp/PgDn). */ @@ -147,57 +81,107 @@ export interface ModelSelectorOptions { /** When true, the hint line mentions the Tab provider switch — set by * TabbedModelSelectorComponent so the inner list advertises the tab keys. */ readonly providerSwitchHint?: boolean; + /** When set, rendered as warning-colored lines directly below the key-hint + * line; wraps instead of truncating when it exceeds the width (e.g. the + * mid-conversation switch cost notice). */ + readonly warning?: string; + /** Set to false to hide the Thinking footer and disable ←/→ effort + * switching — for pickers whose selection carries no thinking level. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** When provided, Alt+S invokes this instead of onSelect — used to apply the + * choice to the current session only, without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } function createModelChoices(models: Record<string, ModelAlias>): readonly ModelChoice[] { return Object.entries(models).map(([alias, cfg]) => { - const name = modelDisplayName(alias, cfg); - const provider = providerDisplayName(cfg.provider); - return { alias, model: cfg, name, provider, label: `${name} (${provider})` }; + const effective = effectiveModelAlias(cfg); + const name = modelDisplayName(alias, effective); + const provider = providerDisplayName(effective.provider); + return { alias, model: effective, name, provider, label: `${name} (${provider})` }; }); } +export function thinkingAvailability(model: ModelAlias): ThinkingAvailability { + const caps = model.capabilities ?? []; + if (caps.includes('always_thinking')) return 'always-on'; + if (caps.includes('thinking') || model.adaptiveThinking === true) return 'toggle'; + return 'unsupported'; +} + +export function effortsOf(model: ModelAlias): readonly string[] { + return model.supportEfforts ?? []; +} + +/** + * Ordered list of selectable thinking efforts for a model. Effort-capable models + * expose their declared efforts (with an 'off' entry when the model is not + * always-on); legacy boolean models expose 'on'/'off'; single-segment lists + * mean the control is effectively locked. + */ +export function segmentsFor(model: ModelAlias): readonly string[] { + const efforts = effortsOf(model); + const availability = thinkingAvailability(model); + if (efforts.length > 0) { + return availability === 'always-on' ? efforts : ['off', ...efforts]; + } + if (availability === 'always-on') return ['on']; + if (availability === 'unsupported') return ['off']; + return ['on', 'off']; +} + +export function effortLabel(effort: string): string { + if (effort.length === 0) return effort; + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +/** + * Default thinking effort for a model: declared `default_effort`, else the + * middle `support_efforts` entry, else `'on'` for boolean models, `'off'` when + * thinking is unsupported. + */ +export function defaultThinkingEffortFor(model: ModelAlias): ThinkingEffort { + if (thinkingAvailability(model) === 'unsupported') return 'off'; + const efforts = effortsOf(model); + if (efforts.length > 0) { + return model.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Normalize a draft effort before committing a selection. A boolean `'on'` + * never leaks past the UI boundary — it becomes the model's default effort + * (a concrete effort for effort-capable models, `'on'` only for genuine + * boolean models). + */ +function commitEffort(choice: ModelChoice, draft: ThinkingEffort): ThinkingEffort { + if (draft === 'on') return defaultThinkingEffortFor(choice.model); + return draft; +} + /** * Flat, searchable single-list model picker. * * One navigation axis: ↑/↓ move the cursor (PgUp/PgDn page), typing fuzzy-filters - * across every provider (provider name included), and ←/→ move the thinking - * effort draft within the selected model's supported levels. There are no - * provider tabs — filtering by typing a provider name replaces them. - * See .agents/skills/write-tui/DESIGN.md. + * across every provider (provider name included), and ←/→ toggle the thinking + * draft for models that support it. There are no provider tabs — filtering by + * typing a provider name replaces them. See .agents/skills/write-tui/DESIGN.md. */ export class ModelSelectorComponent extends Container implements Focusable { focused = false; private readonly opts: ModelSelectorOptions; - private readonly models: Record<string, ModelAlias>; - private readonly currentValue: string; private readonly list: SearchableList<ModelChoice>; - /** Per-model effort override set by ←/→; absent → the default draft. */ - private readonly effortOverrides = new Map<string, string>(); - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); + /** Per-model thinking-effort override set by ←/→; absent → the live effort. */ + private readonly thinkingOverrides = new Map<string, string>(); constructor(opts: ModelSelectorOptions) { super(); this.opts = opts; - const normalized = normalizeModelChoices(opts.models); - this.models = normalized.models; - this.currentValue = - resolveNormalizedModelAlias( - normalized, - opts.currentValue, - opts.models[opts.currentValue], - ) ?? opts.currentValue; - const choices = createModelChoices(this.models); - const selectedCandidate = opts.selectedValue ?? opts.currentValue; - const selectedValue = - resolveNormalizedModelAlias( - normalized, - selectedCandidate, - opts.models[selectedCandidate], - ) ?? this.currentValue; + const choices = createModelChoices(opts.models); + const selectedValue = opts.selectedValue ?? opts.currentValue; const selectedIdx = choices.findIndex((choice) => choice.alias === selectedValue); this.list = new SearchableList({ items: choices, @@ -208,56 +192,88 @@ export class ModelSelectorComponent extends Container implements Focusable { }); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - /** - * Effort draft for a model: an explicit ←/→ override when set, otherwise the - * live effort level coerced to what the model supports. Defaulting other - * models to their first level instead would silently persist that level as - * the new startup default on switch, clobbering the user's saved effort. + * Thinking effort for a model: an explicit ←/→ override when set, otherwise + * the live effort coerced to the selected model's supported efforts. */ private draftFor(choice: ModelChoice): string { - const override = this.effortOverrides.get(choice.alias); + const override = this.thinkingOverrides.get(choice.alias); if (override !== undefined) return override; - return coerceEffortForModel(choice.model, this.opts.currentEffort); + return coerceEffortForModel(choice.model, this.opts.currentThinkingEffort); + } + + /** Draft coerced onto the model's segment list so rendering/selection never + * reference a effort the model cannot actually select. */ + private effectiveEffort(choice: ModelChoice): string { + const draft = this.draftFor(choice); + const segments = segmentsFor(choice.model); + return segments.includes(draft) ? draft : segments[0]!; } - handleInput(data: string): boolean { - const handlers = { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:accept': () => this.selectCurrent(), - 'select:cancel': () => { - if (!this.list.clearQuery()) this.opts.onCancel(); - }, - 'modelPicker:decreaseEffort': () => this.moveEffort(-1), - 'modelPicker:increaseEffort': () => this.moveEffort(1), - } as const; + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) return; + this.opts.onCancel(); + return; + } + + // ↑/↓, PgUp/PgDn, and — when searchable — typing + Backspace. + if (this.list.handleKey(data)) { + return; + } + + // Left/Right move the active thinking effort within the model's segments. if ( - this.keybindings.dispatch(data, ['Select', 'ModelPicker'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select', 'ModelPicker'], handlers) + this.opts.thinkingControl !== false && + (matchesKey(data, Key.left) || matchesKey(data, Key.right)) ) { - return true; + const selected = this.selectedChoice(); + if (selected !== undefined) { + const segments = segmentsFor(selected.model); + if (segments.length > 1) { + const current = this.effectiveEffort(selected); + const idx = segments.indexOf(current); + // The two-segment case is the legacy boolean On/Off control: both + // arrows flip it. With more segments (efforts), ←/→ step. + let next: number; + if (segments.length === 2) { + next = idx === 0 ? 1 : 0; + } else { + const delta = matchesKey(data, Key.left) ? -1 : 1; + next = Math.max(0, Math.min(segments.length - 1, idx + delta)); + } + if (next !== idx) { + this.thinkingOverrides.set(selected.alias, segments[next]!); + } + } + } + return; } - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - return true; + if (matchesKey(data, Key.enter)) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); + return; } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); - return true; + + if (matchesKey(data, Key.alt('s')) && this.opts.onSessionOnlySelect !== undefined) { + const selected = this.selectedChoice(); + if (selected === undefined) return; + this.opts.onSessionOnlySelect({ + alias: selected.alias, + thinking: commitEffort(selected, this.effectiveEffort(selected)), + }); } - return this.list.handleSearchKey(data); } override render(width: number): string[] { const searchable = this.opts.searchable === true; const view = this.list.view(); - const totalCount = Object.keys(this.models).length; + const totalCount = Object.keys(this.opts.models).length; const titleSuffix = searchable && view.query.length === 0 @@ -267,32 +283,24 @@ export class ModelSelectorComponent extends Container implements Focusable { // "type to search" already lives in the title suffix, so the hint only // surfaces the backspace shortcut once a query is active. const hintParts: string[] = []; - if (this.opts.providerSwitchHint) { - const providerHint = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Tabs', 'tabs:next'), - keybindingDisplayText(this.bindings, 'Tabs', 'tabs:previous'), - 'toggle provider', - ); - if (providerHint !== undefined) hintParts.push(providerHint); - } - const navigationHint = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ); - if (navigationHint !== undefined) hintParts.push(navigationHint); + if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); + hintParts.push('↑↓ navigate'); if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - const accept = keybindingDisplayText(this.bindings, 'Select', 'select:accept'); - if (accept !== undefined) hintParts.push(`${formatBindingKeys(accept)} select`); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - if (cancel !== undefined) hintParts.push(`${formatBindingKeys(cancel)} cancel`); + hintParts.push('Enter select'); + if (this.opts.onSessionOnlySelect !== undefined) hintParts.push('Alt+S session-only'); + hintParts.push('Esc cancel'); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Select a model') + titleSuffix, + currentTheme.boldFg('primary', this.opts.title ?? ' Select a model') + titleSuffix, currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), - '', ]; + if (this.opts.warning !== undefined) { + for (const line of wrapTextWithAnsi(this.opts.warning, Math.max(1, width - 1))) { + lines.push(currentTheme.fg('warning', ` ${line}`)); + } + } + lines.push(''); if (searchable && view.query.length > 0) { lines.push(currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query)); @@ -315,7 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable { const choice = view.items[i]; if (choice === undefined) continue; const isSelected = i === view.selectedIndex; - const isCurrent = choice.alias === this.currentValue; + const isCurrent = choice.alias === this.opts.currentValue; const pointer = isSelected ? SELECT_POINTER : ' '; const truncatedName = truncateToWidth(choice.name, nameWidth, '…'); const namePad = ' '.repeat(Math.max(0, nameWidth - visibleWidth(truncatedName))); @@ -331,72 +339,59 @@ export class ModelSelectorComponent extends Container implements Focusable { // Scroll / match indicator. if (view.query.length > 0) { - lines.push('', currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`)); + lines.push(''); + lines.push( + currentTheme.fg('textMuted', ` ${String(view.items.length)} / ${String(totalCount)}`), + ); } else { const below = view.items.length - view.page.end; if (below > 0) { - lines.push('', currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); + lines.push(''); + lines.push(currentTheme.fg('textMuted', ` ▼ ${String(below)} more`)); } } lines.push(''); const selected = this.selectedChoice(); - if (selected !== undefined) { - const levels = effortLevelsForModel(selected.model); - const effortHint = combinedBindingHint( - keybindingDisplayText(this.bindings, 'ModelPicker', 'modelPicker:decreaseEffort'), - keybindingDisplayText(this.bindings, 'ModelPicker', 'modelPicker:increaseEffort'), - 'to switch', - ); - const thinkingHeader = - levels.length > 1 && effortHint !== undefined - ? ` Thinking (${effortHint})` - : ' Thinking'; - lines.push(currentTheme.fg('textMuted', thinkingHeader), this.renderThinkingControl(selected)); + if (selected !== undefined && this.opts.thinkingControl !== false) { + const canSwitch = segmentsFor(selected.model).length > 1; + const thinkingHeader = canSwitch ? ' Thinking (←→ to switch)' : ' Thinking'; + lines.push(currentTheme.fg('textMuted', thinkingHeader)); + lines.push(this.renderThinkingControl(selected)); + lines.push(''); } - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } - selectedAlias(): string | undefined { - return this.selectedChoice()?.alias; - } - private selectedChoice(): ModelChoice | undefined { return this.list.selected(); } - private selectCurrent(): void { - const selected = this.selectedChoice(); - if (selected === undefined) return; - this.opts.onSelect({ - alias: selected.alias, - effort: this.draftFor(selected), - }); - } - - private moveEffort(delta: -1 | 1): void { - const selected = this.selectedChoice(); - if (selected === undefined) return; - const levels = effortLevelsForModel(selected.model); - const current = levels.indexOf(this.draftFor(selected)); - const next = current + delta; - if (current >= 0 && next >= 0 && next < levels.length) { - this.effortOverrides.set(selected.alias, levels[next]!); - } - } - private renderThinkingControl(choice: ModelChoice): string { - if (thinkingAvailability(choice.model) === 'unsupported') { - return currentTheme.fg('textMuted', ' Off (Unsupported)'); - } - const draft = this.draftFor(choice); - const segments = effortLevelsForModel(choice.model).map((level) => { - const label = shortEffortLabel(level); - return level === draft + const segment = (label: string, active: boolean): string => + active ? currentTheme.boldFg('primary', `[ ${label} ]`) : currentTheme.fg('text', ` ${label} `); - }); - return ` ${segments.join(' ')}`; + // The whole segment is muted, suffix included, so the disabled side reads + // as a single greyed-out control rather than a selectable option. + const unavailable = (label: string): string => + currentTheme.fg('textMuted', ` ${label} (Unsupported) `); + + // Non-effort always-on / unsupported models keep the original On/Off layout + // so the control never shifts while moving across legacy models. + const efforts = effortsOf(choice.model); + const availability = thinkingAvailability(choice.model); + if (efforts.length === 0 && availability === 'always-on') { + return ` ${segment('On', true)} ${unavailable('Off')}`; + } + if (efforts.length === 0 && availability === 'unsupported') { + return ` ${unavailable('On')} ${segment('Off', true)}`; + } + + const segments = segmentsFor(choice.model); + const active = this.effectiveEffort(choice); + const rendered = segments.map((effort) => segment(effortLabel(effort), effort === active)); + return ` ${rendered.join(' ')}`; } } diff --git a/apps/pythinker-code/src/tui/components/dialogs/permission-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/permission-selector.ts index 638c68a5..24aad03b 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/permission-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/permission-selector.ts @@ -6,8 +6,7 @@ const PERMISSION_OPTIONS: readonly ChoiceOption[] = [ { value: 'manual', label: 'Manual', - description: - 'Ask before commands, edits, and other risky actions. Read/search tools run directly; session approval rules are respected.', + description: 'Approve every action yourself.', }, { value: 'yolo', diff --git a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts index fd012817..5ec0e7e0 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/platform-selector.ts @@ -1,10 +1,19 @@ -import { buildPlatformOptions } from '@pymodel/pythinker-code-sdk'; -import type { Catalog } from '@pymodel/pythinker-code-sdk'; +import { + OPENAI_CODEX_OAUTH_PLATFORM_ID, + OPEN_PLATFORMS, +} from '@pymodel/pythinker-code-oauth'; -import { ChoicePickerComponent } from './choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +const PLATFORM_OPTIONS: readonly ChoiceOption[] = [ + { value: OPENAI_CODEX_OAUTH_PLATFORM_ID, label: 'OpenAI Codex (OAuth)' }, + ...OPEN_PLATFORMS.map((platform) => ({ + value: platform.id, + label: platform.name, + })), +]; export interface PlatformSelectorOptions { - readonly catalog?: Catalog; readonly onSelect: (platformId: string) => void; readonly onCancel: () => void; } @@ -13,8 +22,7 @@ export class PlatformSelectorComponent extends ChoicePickerComponent { constructor(opts: PlatformSelectorOptions) { super({ title: 'Select a platform', - options: [...buildPlatformOptions(opts.catalog ?? {})], - searchable: true, + options: [...PLATFORM_OPTIONS], onSelect: opts.onSelect, onCancel: opts.onCancel, }); diff --git a/apps/pythinker-code/src/tui/components/dialogs/plugins-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/plugins-selector.ts index a1ef86e0..afac16c1 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/plugins-selector.ts @@ -1,52 +1,60 @@ import { Container, + Input, Key, matchesKey, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@pymodel/pythinker-code-sdk'; +} from '@pymodel/pi-tui'; +import type { + CapabilityStatus, + PluginInfo, + PluginMcpServerInfo, + PluginSummary, +} from '@pymodel/pythinker-code-sdk'; +import chalk from 'chalk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; -import { currentTheme, type ColorToken } from '#/tui/theme'; -import { - formatPluginSourceLabel, - pluginSourceTrustLabel, - pluginTrustLabel, -} from '#/tui/utils/plugin-source-label'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; -import { SearchableList } from '#/tui/utils/searchable-list'; -import { - computeMarketplaceEntryStatus, - type PluginMarketplace, - type PluginMarketplaceEntry, -} from '#/utils/plugin-marketplace'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; +import { computeUpdateStatus, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; + +import { ChoicePickerComponent } from './choice-picker'; -import { - ChoicePickerComponent, - combinedBindingHint, - formatBindingKeys, -} from './choice-picker'; - -const OVERVIEW_MARKETPLACE = 'marketplace'; -const OVERVIEW_RELOAD = 'reload'; -const OVERVIEW_SHOW_LIST = 'show-list'; -const OVERVIEW_PLUGIN_PREFIX = 'plugin:'; const MCP_SERVER_PREFIX = 'mcp:'; const REMOVE_CONFIRM_CANCEL = 'cancel'; const REMOVE_CONFIRM_REMOVE = 'remove'; +const INSTALL_TRUST_EXIT = 'exit'; +const INSTALL_TRUST_TRUST = 'trust'; const ELLIPSIS = '…'; +// Hardcoded Web Bridge promotion: a built-in fallback shown only while the +// marketplace catalog is loading, unreachable, or predates the real +// `pythinker-webbridge` entry. Selecting it opens the install page in the browser; +// once the catalog carries the real entry, that row wins and installs +// normally. +const WEB_BRIDGE_URL = 'https://www.kimi.com/features/webbridge#local-agent'; +const WEB_BRIDGE_ENTRY: PluginMarketplaceEntry = { + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + source: WEB_BRIDGE_URL, + tier: 'official', + homepage: WEB_BRIDGE_URL, + description: 'Control your real browser from Pythinker Code — navigate, click, type, and screenshot', +}; + +// Only the hardcoded pinned row should open the WebBridge install page. Match +// by reference (not id) so a catalog entry on another tab that happens to +// reuse the same id still installs normally instead of being hijacked. +function isPinnedWebBridgeEntry(entry: PluginMarketplaceEntry): boolean { + return entry === WEB_BRIDGE_ENTRY; +} + interface PluginsOverviewItem { readonly value: string; readonly kind: 'plugin' | 'action'; @@ -55,366 +63,6 @@ interface PluginsOverviewItem { readonly description: string; } -export type PluginsOverviewSelection = - | { readonly kind: 'marketplace' } - | { readonly kind: 'reload' } - | { readonly kind: 'show-list' } - | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } - | { readonly kind: 'mcp'; readonly id: string } - | { readonly kind: 'remove'; readonly id: string } - | { readonly kind: 'info'; readonly id: string }; - -export interface PluginsOverviewSelectorOptions { - readonly plugins: readonly PluginSummary[]; - readonly selectedId?: string; - readonly pluginHint?: { - readonly id: string; - readonly text: string; - }; - readonly onSelect: (selection: PluginsOverviewSelection) => void; - readonly onCancel: () => void; -} - -export class PluginsOverviewSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginsOverviewSelectorOptions; - private readonly items: readonly PluginsOverviewItem[]; - private selectedIndex = 0; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver([]); - - constructor(opts: PluginsOverviewSelectorOptions) { - super(); - this.opts = opts; - this.items = buildOverviewItems(opts.plugins); - const selectedIndex = this.items.findIndex( - (item) => item.value === `${OVERVIEW_PLUGIN_PREFIX}${opts.selectedId}`, - ); - this.selectedIndex = Math.max(0, selectedIndex); - this.setKeybindings(this.bindings); - } - - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - const actions = new Set(['select:previous', 'select:next', 'select:accept', 'select:cancel', 'plugin:toggle']); - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => binding.action !== null && actions.has(binding.action), - ), - ); - } - - handleInput(data: string): void { - const handlers: KeybindingHandlers = { - 'select:previous': () => this.moveUp(), - 'select:next': () => this.moveDown(), - 'select:accept': () => this.accept(), - 'select:cancel': () => this.opts.onCancel(), - 'plugin:toggle': () => this.toggle(), - }; - const keyId = parseKey(data); - if ( - keyId?.includes('+') === true - ? this.keybindings.dispatch(data, ['Plugin', 'Select'], handlers) - : this.keybindings.dispatchKeyId(keyId ?? data, ['Plugin', 'Select'], handlers) - ) { - return; - } - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - const pluginId = overviewItemPluginId(chosen); - const decoded = printableChar(data); - if (decoded === 'd' || decoded === 'D') { - if (pluginId !== undefined) this.opts.onSelect({ kind: 'remove', id: pluginId }); - return; - } - if (decoded === 'm' || decoded === 'M') { - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined && plugin.mcpServerCount > 0) { - this.opts.onSelect({ kind: 'mcp', id: pluginId }); - } - return; - } - } - - override render(width: number): string[] { - const { plugins } = this.opts; - const hint = [ - combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ), - pluginBindingHint(this.bindings, 'plugin:toggle', 'toggle'), - 'M MCP servers', - 'D remove', - pluginBindingHint(this.bindings, 'select:accept', 'details'), - pluginBindingHint(this.bindings, 'select:cancel', 'cancel'), - ].filter((part): part is string => part !== undefined).join(' · '); - const pluginItems = this.items.filter((item) => item.kind === 'plugin'); - const actionItems = this.items.filter((item) => item.kind === 'action'); - const lines: string[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Plugins'), - mutedHintLine(` ${hint}`), - '', - sectionLabel(`Installed plugins (${plugins.length})`), - ]; - - if (pluginItems.length === 0) { - lines.push(currentTheme.fg('textMuted', ' No plugins installed.')); - } else { - let absoluteIndex = 0; - for (const item of pluginItems) { - lines.push(...this.renderItem(item, absoluteIndex, width)); - absoluteIndex++; - } - } - - lines.push('', sectionLabel('Actions')); - for (let i = 0; i < actionItems.length; i++) { - lines.push(...this.renderItem(actionItems[i]!, pluginItems.length + i, width)); - } - - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { - const selected = index === this.selectedIndex; - const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected - ? (text: string) => currentTheme.boldFg('primary', text) - : (text: string) => currentTheme.fg('text', text); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); - let line = prefix + labelStyle(item.label); - if (item.status !== undefined) { - line += ' ' + statusStyle(item)(item.status); - } - const pluginId = overviewItemPluginId(item); - if (pluginId !== undefined && this.opts.pluginHint?.id === pluginId) { - line += ' ' + currentTheme.fg('warning', this.opts.pluginHint.text); - } - - const descriptionWidth = Math.max(1, width - 4); - const lines = [line]; - for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`)); - } - return lines; - } - - private moveUp(): void { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - } - - private moveDown(): void { - this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); - } - - private toggle(): void { - const chosen = this.items[this.selectedIndex]; - const pluginId = chosen === undefined ? undefined : overviewItemPluginId(chosen); - if (pluginId === undefined) return; - const plugin = this.opts.plugins.find((item) => item.id === pluginId); - if (plugin !== undefined) { - this.opts.onSelect({ kind: 'toggle', id: pluginId, enabled: !plugin.enabled }); - } - } - - private accept(): void { - const chosen = this.items[this.selectedIndex]; - if (chosen === undefined) return; - const pluginId = overviewItemPluginId(chosen); - if (pluginId !== undefined) { - this.opts.onSelect({ kind: 'info', id: pluginId }); - return; - } - const selection = parseOverviewSelection(chosen.value); - if (selection !== undefined) this.opts.onSelect(selection); - } -} - -const MARKETPLACE_PAGE_SIZE = 4; - -export type PluginMarketplaceSelection = - | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } - | { - readonly kind: 'unavailable'; - readonly entry: PluginMarketplaceEntry; - readonly reason: string; - }; - -export interface PluginMarketplaceSelectorOptions { - readonly marketplace: PluginMarketplace; - readonly installed: ReadonlyMap<string, PluginSummary>; - readonly onSelect: (selection: PluginMarketplaceSelection) => void; - readonly onCancel: () => void; -} - -export class PluginMarketplaceSelectorComponent extends Container implements Focusable { - focused = false; - - private readonly opts: PluginMarketplaceSelectorOptions; - private readonly list: SearchableList<PluginMarketplaceEntry>; - private submitted = false; - private keybindings = new KeybindingResolver([]); - - constructor(opts: PluginMarketplaceSelectorOptions) { - super(); - this.opts = opts; - this.list = new SearchableList({ - items: opts.marketplace.plugins, - toSearchText: marketplaceSearchText, - pageSize: MARKETPLACE_PAGE_SIZE, - searchable: true, - }); - this.setKeybindings(defaultKeybindings()); - } - - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - const actions = new Set([ - 'select:previous', - 'select:next', - 'select:accept', - 'select:cancel', - ]); - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => binding.action !== null && actions.has(binding.action), - ), - ); - } - - handleInput(data: string): void { - if (this.list.handleSearchKey(data)) return; - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); - return; - } - - const handlers: KeybindingHandlers = { - 'select:previous': () => this.list.moveUp(), - 'select:next': () => this.list.moveDown(), - 'select:accept': () => this.activate(), - 'select:cancel': () => this.cancel(), - }; - const keyId = parseKey(data); - if ( - keyId?.includes('+') === true - ? this.keybindings.dispatch(data, ['Select'], handlers) - : this.keybindings.dispatchKeyId(keyId ?? data, ['Select'], handlers) - ) return; - - if (matchesKey(data, Key.enter)) this.activate(); - else if (matchesKey(data, Key.escape)) this.cancel(); - } - - override render(width: number): string[] { - const view = this.list.view(); - const titleSuffix = view.query.length === 0 - ? currentTheme.fg('textMuted', ' (type to search)') - : ''; - const hint = view.query.length === 0 - ? ' ↑↓ navigate · PgUp/PgDn page · Enter install · Esc cancel' - : ' ↑↓ navigate · PgUp/PgDn page · Enter install · Backspace clear · Esc cancel'; - const lines: string[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg( - 'primary', - ` ${this.opts.marketplace.name} (${this.opts.marketplace.plugins.length})`, - ) + titleSuffix, - mutedHintLine(hint), - '', - ]; - - if (view.query.length > 0) { - lines.push( - currentTheme.fg('primary', ' Search: ') + currentTheme.fg('text', view.query), - ); - } - if (view.items.length === 0) { - lines.push(currentTheme.fg('textMuted', ' No matches')); - } else { - for (let index = view.page.start; index < view.page.end; index++) { - lines.push(this.renderEntry(view.items[index]!, index === view.selectedIndex, width)); - } - } - - lines.push(''); - if (view.query.length > 0 && view.items.length > 0) { - lines.push(mutedHintLine(` ${view.selectedIndex + 1} / ${view.items.length}`)); - } else { - const remaining = view.items.length - view.page.end; - if (remaining > 0) lines.push(mutedHintLine(` ▼ ${remaining} more`)); - else if (view.page.start > 0) lines.push(mutedHintLine(` ▲ ${view.page.start} previous`)); - } - - const selected = this.list.selected(); - if (selected !== undefined) { - lines.push('', ...marketplaceDetailLines(selected, this.opts.marketplace, width)); - } - lines.push(currentTheme.fg('primary', '─'.repeat(width))); - return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); - } - - private renderEntry(entry: PluginMarketplaceEntry, selected: boolean, width: number): string { - const status = marketplaceStatus(entry, this.opts.installed.get(entry.id)); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${selected ? SELECT_POINTER : ' '} `); - const statusWidth = visibleWidth(status.text) + 2; - const nameWidth = Math.max(1, width - visibleWidth(` ${SELECT_POINTER} `) - statusWidth); - const name = truncateToWidth(entry.displayName, nameWidth, ELLIPSIS); - const styledName = selected - ? currentTheme.boldFg('primary', name) - : currentTheme.fg('text', name); - return prefix + styledName + ' ' + currentTheme.fg(status.tone, status.text); - } - - private cancel(): void { - if (!this.list.clearQuery()) this.opts.onCancel(); - } - - private activate(): void { - const entry = this.list.selected(); - if (entry === undefined) return; - if (entry.install.kind === 'unsupported') { - this.opts.onSelect({ - kind: 'unavailable', - entry, - reason: entry.install.reason, - }); - return; - } - if (this.submitted) return; - this.submitted = true; - this.opts.onSelect({ kind: 'install', entry }); - } -} - -function pluginBindingHint( - bindings: readonly ParsedKeybinding[], - action: 'plugin:toggle' | 'select:accept' | 'select:cancel', - label: string, -): string | undefined { - const context = action.startsWith('plugin:') ? 'Plugin' : 'Select'; - const keys = keybindingDisplayText(bindings, context, action); - return keys === undefined ? undefined : `${formatBindingKeys(keys)} ${label}`; -} - export type PluginMcpSelection = | { readonly kind: 'toggle'; readonly pluginId: string; readonly server: string; readonly enabled: boolean } | { readonly kind: 'back'; readonly pluginId: string }; @@ -482,52 +130,54 @@ export class PluginMcpSelectorComponent extends Container implements Focusable { override render(width: number): string[] { const { info } = this.opts; + const colors = currentTheme.palette; const serverItems = this.items.filter((item) => item.kind === 'plugin'); const actionItems = this.items.filter((item) => item.kind === 'action'); const lines: string[] = [ - currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ` MCP servers · ${info.displayName}`), - mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel'), + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(` MCP servers · ${info.displayName}`), + mutedHintLine(' ↑↓ navigate · Enter/Space enable/disable · Esc cancel', colors), '', - sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`), + sectionLabel(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled)`, colors), ]; if (serverItems.length === 0) { - lines.push(currentTheme.fg('textMuted', ' No MCP servers declared.')); + lines.push(chalk.hex(colors.textMuted)(' No MCP servers declared.')); } else { for (let i = 0; i < serverItems.length; i++) { lines.push(...this.renderItem(serverItems[i]!, i, width)); } } - lines.push('', sectionLabel('Actions')); + lines.push(''); + lines.push(sectionLabel('Actions', colors)); for (let i = 0; i < actionItems.length; i++) { lines.push(...this.renderItem(actionItems[i]!, serverItems.length + i, width)); } - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); + lines.push(''); + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); } private renderItem(item: PluginsOverviewItem, index: number, width: number): string[] { + const colors = currentTheme.palette; const selected = index === this.selectedIndex; const pointer = selected ? SELECT_POINTER : ' '; - const labelStyle = selected - ? (text: string) => currentTheme.boldFg('primary', text) - : (text: string) => currentTheme.fg('text', text); - const prefix = currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `); + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); let line = prefix + labelStyle(item.label); if (item.status !== undefined) { - line += ' ' + statusStyle(item)(item.status); + line += ' ' + statusStyle(item, colors)(item.status); } const serverName = mcpItemServerName(item); if (serverName !== undefined && this.opts.serverHint?.server === serverName) { - line += ' ' + currentTheme.fg('warning', this.opts.serverHint.text); + line += ' ' + chalk.hex(colors.warning)(this.opts.serverHint.text); } const descriptionWidth = Math.max(1, width - 4); const lines = [line]; for (const descLine of wrapOverviewDescription(item.description, descriptionWidth)) { - lines.push(mutedHintLine(` ${descLine}`)); + lines.push(mutedHintLine(` ${descLine}`, colors)); } return lines; } @@ -572,35 +222,53 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { } } -function buildOverviewItems(plugins: readonly PluginSummary[]): PluginsOverviewItem[] { - const options: PluginsOverviewItem[] = plugins.map((plugin) => ({ - value: `${OVERVIEW_PLUGIN_PREFIX}${plugin.id}`, - kind: 'plugin', - label: plugin.displayName, - status: pluginStatus(plugin), - description: overviewPluginDescription(plugin), - })); - options.push( - { - value: OVERVIEW_MARKETPLACE, - kind: 'action', - label: 'Marketplace', - description: 'Browse official plugins.', - }, - { - value: OVERVIEW_RELOAD, - kind: 'action', - label: 'Reload', - description: 'Re-read installed plugins and manifests.', - }, - { - value: OVERVIEW_SHOW_LIST, - kind: 'action', - label: 'Summary', - description: 'Append the current plugin summary to the transcript.', - }, - ); - return options; +export type PluginInstallTrustConfirmResult = + | { readonly kind: 'confirm' } + | { readonly kind: 'cancel' }; + +export interface PluginInstallTrustConfirmOptions { + /** Plugin display name or source, shown in the title for identification. */ + readonly label: string; + readonly onDone: (result: PluginInstallTrustConfirmResult) => void; +} + +/** + * Confirmation shown before installing a third-party (unofficial) plugin. + * Defaults to "Exit" so the user must explicitly switch to "Trust and install" + * to proceed with a plugin that Pythinker has not reviewed. + */ +export class PluginInstallTrustConfirmComponent extends ChoicePickerComponent { + constructor(opts: PluginInstallTrustConfirmOptions) { + super({ + title: `Install third-party plugin ${opts.label}?`, + hint: '↑↓ navigate · Enter/Space select · ←/Esc cancel', + formatHint: mutedHintLine, + notice: + '⚠️ This is a third-party plugin that Pythinker has not reviewed. It can bundle MCP servers, ' + + 'skills, or files that run code and access your workspace. Install it only if you ' + + 'trust the source.', + noticeTone: 'warning', + options: [ + { + value: INSTALL_TRUST_EXIT, + label: 'Exit', + description: 'Cancel the installation.', + }, + { + value: INSTALL_TRUST_TRUST, + label: 'Trust and install', + tone: 'danger', + description: 'Install this third-party plugin anyway.', + }, + ], + onSelect: (value) => { + opts.onDone(value === INSTALL_TRUST_TRUST ? { kind: 'confirm' } : { kind: 'cancel' }); + }, + onCancel: () => { + opts.onDone({ kind: 'cancel' }); + }, + }); + } } function overviewPluginDescription(plugin: PluginSummary): string { @@ -616,143 +284,551 @@ function overviewPluginDescription(plugin: PluginSummary): string { return `id ${plugin.id} · ${skills}${mcp}${source}${trust}${state}${diagnostics}`; } -function pluginStatus(plugin: PluginSummary): string { +function pluginStatus(plugin: PluginSummary): string | undefined { if (plugin.state !== 'ok') return plugin.state; return plugin.enabled ? 'enabled' : 'disabled'; } -function parseOverviewSelection(value: string): PluginsOverviewSelection | undefined { - if (value === OVERVIEW_MARKETPLACE) return { kind: 'marketplace' }; - if (value === OVERVIEW_RELOAD) return { kind: 'reload' }; - if (value === OVERVIEW_SHOW_LIST) return { kind: 'show-list' }; - return undefined; +function marketplaceStatusStyle(status: string, colors: ColorPalette): (text: string) => string { + // States recede, actions pop: "installed …" is a quiet fact (dim), while + // "install …" (the available action) stays primary and "update …" stays a + // warning — the two used to share near-identical green-ish treatments in + // the same column and read as interchangeable. + if (status.startsWith('update')) return chalk.hex(colors.warning); + if (status.startsWith('installed')) return chalk.hex(colors.textDim); + return chalk.hex(colors.primary); } -function overviewItemPluginId(item: PluginsOverviewItem): string | undefined { - if (!item.value.startsWith(OVERVIEW_PLUGIN_PREFIX)) return undefined; - return item.value.slice(OVERVIEW_PLUGIN_PREFIX.length); +/** Rounded single-line URL input box (DESIGN §9), shared by the marketplace + * Custom tab and the unified plugins panel. */ +function renderUrlInputBox( + input: Input, + focused: boolean, + width: number, + colors: ColorPalette, +): string[] { + input.focused = focused; + const border = (s: string): string => chalk.hex(colors.primary)(s); + const boxWidth = Math.max(24, width - 2); + const innerWidth = Math.max(10, boxWidth - 4); + const inputLine = input.render(innerWidth)[0] ?? ''; + const rightPad = Math.max(0, innerWidth - visibleWidth(inputLine)); + return [ + ' ' + border('╭' + '─'.repeat(boxWidth - 2) + '╮'), + ' ' + border('│') + ' ' + inputLine + ' '.repeat(rightPad) + border('│'), + ' ' + border('╰' + '─'.repeat(boxWidth - 2) + '╯'), + ]; } -function marketplaceSearchText(entry: PluginMarketplaceEntry): string { - return [ - entry.displayName, - entry.id, - entry.description, - entry.author?.name, - entry.marketplaceName, - entry.marketplaceOwner, - entry.category, - ...(entry.keywords ?? []), - ...(entry.tags ?? []), - entry.sourceLabel, - entry.repository, - entry.homepage, - ].filter((value): value is string => value !== undefined && value.length > 0).join(' '); +// =========================================================================== +// Unified /plugins panel: Installed / Official / Curated / Custom tabs. +// =========================================================================== + +export type PluginsPanelTabId = 'installed' | 'official' | 'third-party' | 'custom'; + +export type PluginsPanelSelection = + | { readonly kind: 'toggle'; readonly id: string; readonly enabled: boolean } + | { readonly kind: 'remove'; readonly id: string } + | { readonly kind: 'mcp'; readonly id: string } + | { readonly kind: 'details'; readonly id: string } + | { readonly kind: 'reload' } + | { readonly kind: 'install'; readonly entry: PluginMarketplaceEntry } + | { readonly kind: 'install-source'; readonly source: string } + | { readonly kind: 'open-url'; readonly url: string; readonly label: string }; + +export interface PluginsPanelOptions { + readonly installed: readonly PluginSummary[]; + readonly installedIds: ReadonlySet<string>; + readonly capabilities?: readonly CapabilityStatus[]; + /** + * False when the marketplace was explicitly replaced (slash-command + * source or env override): built-in rows then stay out of the Official + * tab entirely. Undefined means the default catalog. + */ + readonly catalogIsDefault?: boolean; + readonly initialTab?: PluginsPanelTabId; + readonly selectedId?: string; + readonly pluginHint?: { readonly id: string; readonly text: string }; + readonly onSelect: (selection: PluginsPanelSelection) => void; + readonly onCancel: () => void; + /** Called the first time the Official or Curated tab needs its catalog. + * The host fetches the marketplace and calls setMarketplace / setMarketplaceError. */ + readonly onRequestMarketplace?: () => void; } -function marketplaceStatus( - entry: PluginMarketplaceEntry, - installed: PluginSummary | undefined, -): { readonly text: string; readonly tone: ColorToken } { - if (entry.install.kind === 'unsupported') return { text: 'unavailable', tone: 'error' }; - const status = computeMarketplaceEntryStatus(entry, installed); - switch (status.kind) { - case 'update': - return { - text: `update ${shortRevision(status.local)} → ${shortRevision(status.latest)}`, - tone: 'warning', - }; - case 'up-to-date': - return { - text: status.version === undefined ? 'installed' : `installed · v${status.version}`, - tone: 'success', - }; - case 'not-installed': - return { - text: entry.version === undefined ? 'install' : `install · v${entry.version}`, - tone: 'primary', - }; +type MarketState = + | { readonly status: 'idle' } + | { readonly status: 'loading' } + | { readonly status: 'error'; readonly message: string } + | { readonly status: 'loaded'; readonly entries: readonly PluginMarketplaceEntry[]; readonly source: string }; + +const PLUGINS_PANEL_TABS: readonly { id: PluginsPanelTabId; label: string }[] = [ + { id: 'installed', label: 'Installed' }, + { id: 'official', label: 'Official' }, + { id: 'third-party', label: 'Curated' }, + { id: 'custom', label: 'Custom' }, +]; + +export class PluginsPanelComponent extends Container implements Focusable { + focused = false; + + private readonly opts: PluginsPanelOptions; + private readonly customInput = new Input(); + private activeTabIndex: number; + private selectedIndex = 0; + private market: MarketState = { status: 'idle' }; + private installing: string | undefined; + + constructor(opts: PluginsPanelOptions) { + super(); + this.opts = opts; + this.activeTabIndex = Math.max( + 0, + PLUGINS_PANEL_TABS.findIndex((tab) => tab.id === (opts.initialTab ?? 'installed')), + ); + if (opts.selectedId !== undefined && this.activeTab.id === 'installed') { + const idx = opts.installed.findIndex((p) => p.id === opts.selectedId); + if (idx >= 0) this.selectedIndex = idx; + } + this.customInput.onSubmit = (value) => { + const source = value.trim(); + if (source.length > 0) this.opts.onSelect({ kind: 'install-source', source }); + }; } -} -function marketplaceDetailLines( - entry: PluginMarketplaceEntry, - marketplace: PluginMarketplace, - width: number, -): string[] { - const lines = [sectionLabel(`Details · ${entry.displayName}`)]; - if (entry.description !== undefined) { - for (const line of boundedDescription(entry.description, Math.max(1, width - 2), 2)) { - lines.push(mutedHintLine(` ${line}`)); - } - } - - const identity = [ - `id ${entry.id}`, - entry.author === undefined ? undefined : `author ${entry.author.name}`, - entry.category === undefined ? undefined : `category ${entry.category}`, - ].filter((value): value is string => value !== undefined); - lines.push(mutedHintLine(` ${identity.join(' · ')}`)); - - const revision = [ - `Source: ${entry.sourceLabel}`, - entry.declaredRef === undefined ? undefined : `ref ${entry.declaredRef}`, - entry.effectiveSha === undefined ? undefined : `SHA ${shortRevision(entry.effectiveSha)}`, - ].filter((value): value is string => value !== undefined); - lines.push(mutedHintLine(` ${revision.join(' · ')}`)); - - const links = [entry.homepage, entry.repository] - .filter((value): value is string => value !== undefined) - .filter((value, index, values) => values.indexOf(value) === index); - if (links.length > 0) lines.push(mutedHintLine(` Links: ${links.join(' · ')}`)); - - const trustSource = entry.install.kind === 'supported' ? entry.install.source : undefined; - const catalogOwner = marketplace.owner?.name ?? entry.marketplaceOwner; - lines.push(mutedHintLine( - ` Catalog: ${marketplace.name}${catalogOwner === undefined ? '' : ` · ${catalogOwner}`} · Pythinker trust ${pluginSourceTrustLabel(trustSource)}`, - )); - - const supported = entry.supportedComponents.length === 0 - ? 'discovered during installation' - : entry.supportedComponents.map(componentLabel).join(', '); - lines.push(mutedHintLine(` Supported: ${supported}`)); - - const compatibility = [ - entry.unsupportedComponents.length === 0 - ? undefined - : `not run: ${entry.unsupportedComponents.join(', ')}`, - entry.install.kind === 'unsupported' ? `unavailable: ${entry.install.reason}` : undefined, - ].filter((value): value is string => value !== undefined); - if (compatibility.length > 0) { - const tone: ColorToken = entry.install.kind === 'unsupported' ? 'error' : 'warning'; - lines.push(currentTheme.fg(tone, ` Compatibility: ${compatibility.join(' · ')}`)); + marketplaceStatus(): MarketState['status'] { + return this.market.status; } - return lines; -} -function boundedDescription(text: string, width: number, maxLines: number): string[] { - const lines = wrapOverviewDescription(text, width); - if (lines.length <= maxLines) return lines; - const out = lines.slice(0, maxLines); - out[maxLines - 1] = truncateToWidth(`${out[maxLines - 1]!}${ELLIPSIS}`, width, ELLIPSIS); - return out; -} + setMarketplaceLoading(): void { + this.market = { status: 'loading' }; + } -function componentLabel(component: PluginMarketplaceEntry['supportedComponents'][number]): string { - switch (component) { - case 'mcpServers': - return 'MCP'; - case 'lspServers': - return 'LSP'; - case 'outputStyles': - return 'output styles'; - default: - return component; + setMarketplace(entries: readonly PluginMarketplaceEntry[], source: string): void { + this.market = { status: 'loaded', entries, source }; + } + + setMarketplaceError(message: string): void { + this.market = { status: 'error', message }; + } + + setInstalling(label: string): void { + this.installing = label; + this.invalidate(); + } + + clearInstalling(): void { + this.installing = undefined; + this.invalidate(); + } + + private get activeTab(): (typeof PLUGINS_PANEL_TABS)[number] { + return PLUGINS_PANEL_TABS[this.activeTabIndex]!; + } + + private get marketplaceEntries(): readonly PluginMarketplaceEntry[] { + if (this.market.status !== 'loaded') return []; + return this.market.entries.toSorted( + (a, b) => + Number(this.isMarketplaceEntryInstalled(b)) - + Number(this.isMarketplaceEntryInstalled(a)), + ); + } + + private get installedVersions(): ReadonlyMap<string, string | undefined> { + return new Map(this.opts.installed.map((plugin) => [plugin.id, plugin.version])); + } + + private capabilityFor(id: string): CapabilityStatus | undefined { + return this.opts.capabilities?.find((capability) => capability.id === id); + } + + /** Capability state for a MARKETPLACE row: only our own injected rows + * (flagged `builtIn` — a custom catalog cannot forge the flag) may show + * capability status, matching how Enter routes them. */ + private capabilityForEntry(entry: PluginMarketplaceEntry): CapabilityStatus | undefined { + return entry.builtIn === true ? this.capabilityFor(entry.id) : undefined; + } + + private installedPluginId(entry: PluginMarketplaceEntry): string { + return this.capabilityForEntry(entry)?.pluginId ?? entry.id; + } + + private isMarketplaceEntryInstalled(entry: PluginMarketplaceEntry): boolean { + return this.opts.installedIds.has(this.installedPluginId(entry)); + } + + private get officialEntries(): readonly PluginMarketplaceEntry[] { + // While the catalog is loading or unreachable, the locally-known + // capability rows still render and install — built-in runtime setup + // must never be blocked by an unrelated catalog fetch. + if (this.market.status !== 'loaded') { + return this.pendingBuiltInEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.pendingBuiltInEntries + : [...this.pendingBuiltInEntries, WEB_BRIDGE_ENTRY]; + } + // The real catalog entry wins when present (it installs the actual + // plugin); the hardcoded promo row is only a fallback while the catalog + // is loading, unreachable, or predates it — never a duplicate row. + return this.officialCatalogEntries.some((entry) => entry.id === WEB_BRIDGE_ENTRY.id) + ? this.officialCatalogEntries + : [WEB_BRIDGE_ENTRY, ...this.officialCatalogEntries]; + } + + /** Capability rows synthesized from the engine's registry, independent of + * the marketplace state; unsupported platforms hide them entirely. Only + * the default catalog gets built-in rows — an explicitly overridden + * marketplace must be able to fully replace the Official tab. */ + private get pendingBuiltInEntries(): readonly PluginMarketplaceEntry[] { + if (this.opts.catalogIsDefault === false) return []; + return (this.opts.capabilities ?? []) + .filter((capability) => capability.supported) + .map(capabilityMarketplaceEntry); + } + + private get officialCatalogEntries(): readonly PluginMarketplaceEntry[] { + return this.marketplaceEntries.filter((entry) => { + if (entry.tier !== 'official') return false; + return this.capabilityForEntry(entry)?.supported !== false; + }); + } + + private get thirdPartyEntries(): readonly PluginMarketplaceEntry[] { + // Anything not explicitly marked official lands here: `curated` entries plus + // entries that omit `tier` (custom marketplaces often do). Without this, + // untiered entries would be invisible in both marketplace tabs. + return this.marketplaceEntries.filter((entry) => entry.tier !== 'official'); + } + + private requestMarketplaceIfNeeded(): void { + // The Installed tab also needs the catalog to render update badges; only the + // Custom tab (manual URL entry) can skip the fetch entirely. + if (this.market.status === 'idle' && this.activeTab.id !== 'custom') { + this.market = { status: 'loading' }; + this.opts.onRequestMarketplace?.(); + } + } + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); + return; + } + if (matchesKey(data, Key.tab)) { + this.activeTabIndex = (this.activeTabIndex + 1) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeTabIndex = + (this.activeTabIndex - 1 + PLUGINS_PANEL_TABS.length) % PLUGINS_PANEL_TABS.length; + this.selectedIndex = 0; + this.requestMarketplaceIfNeeded(); + return; + } + switch (this.activeTab.id) { + case 'installed': + this.handleInstalledInput(data); + return; + case 'official': + case 'third-party': + this.handleMarketplaceInput(data); + return; + case 'custom': + this.customInput.handleInput(data); + return; + } + } + + private handleInstalledInput(data: string): void { + const plugins = this.opts.installed; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(plugins.length - 1, this.selectedIndex + 1); + return; + } + const plugin = plugins[this.selectedIndex]; + const ch = printableChar(data); + // Decode Space for terminals that send printable keys via Kitty/CSI-u + // sequences (e.g. VS Code's integrated terminal); `matchesKey(Key.space)` + // alone misses those and the toggle silently stops working. + if (matchesKey(data, Key.space) || ch === ' ') { + if (plugin !== undefined) { + this.opts.onSelect({ kind: 'toggle', id: plugin.id, enabled: !plugin.enabled }); + } + return; + } + if (ch === 'd' || ch === 'D') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'remove', id: plugin.id }); + return; + } + if (ch === 'm' || ch === 'M') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'mcp', id: plugin.id }); + return; + } + if (ch === 'r' || ch === 'R') { + this.opts.onSelect({ kind: 'reload' }); + return; + } + if (matchesKey(data, Key.enter)) { + if (plugin === undefined) return; + const update = this.installedUpdateStatus(plugin); + if (update !== undefined) { + this.opts.onSelect({ kind: 'install', entry: update.entry }); + } else { + this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + return; + } + if (ch === 'i' || ch === 'I') { + if (plugin !== undefined) this.opts.onSelect({ kind: 'details', id: plugin.id }); + } + } + + private handleMarketplaceInput(data: string): void { + const entries = this.activeTab.id === 'official' ? this.officialEntries : this.thirdPartyEntries; + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + // Clamp to 0 while the catalog is still loading (entries empty); otherwise + // `entries.length - 1` is -1 and a later Enter reads `entries[-1]`. + this.selectedIndex = entries.length === 0 ? 0 : Math.min(entries.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter)) { + const entry = entries[this.selectedIndex]; + if (entry === undefined) return; + if (isPinnedWebBridgeEntry(entry)) { + this.opts.onSelect({ kind: 'open-url', url: WEB_BRIDGE_URL, label: entry.displayName }); + return; + } + this.opts.onSelect({ kind: 'install', entry }); + } + } + + override invalidate(): void { + super.invalidate(); + this.customInput.invalidate(); + } + + override render(width: number): string[] { + if (this.installing !== undefined) { + return this.renderInstalling(width); + } + const colors = currentTheme.palette; + const tab = this.activeTab.id; + const hint = + tab === 'installed' + ? this.installedHint() + : tab === 'custom' + ? ' Tab switch · Enter install · Esc cancel' + : ' Tab switch · ↑↓ navigate · Enter open/install · Esc cancel'; + const lines: string[] = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + mutedHintLine(hint, colors), + '', + renderTabStrip({ + labels: PLUGINS_PANEL_TABS.map((t) => t.label), + activeIndex: this.activeTabIndex, + width, + colors, + }), + '', + ]; + + if (tab === 'installed') this.renderInstalled(lines, width); + else if (tab === 'official') this.renderOfficial(lines, width); + else if (tab === 'third-party') this.renderThirdParty(lines, width); + else this.renderCustom(lines, width); + + lines.push(chalk.hex(colors.primary)('─'.repeat(width))); + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } + + private renderInstalled(lines: string[], width: number): void { + const { installed } = this.opts; + const colors = currentTheme.palette; + if (installed.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins installed.')); + } else { + for (let i = 0; i < installed.length; i++) { + lines.push(...this.renderInstalledRow(installed[i]!, i, width)); + } + } + lines.push(''); + lines.push(mutedHintLine(` ${installed.length} installed`, colors)); + } + + private installedHint(): string { + const plugin = this.opts.installed[this.selectedIndex]; + const hasUpdate = plugin !== undefined && this.installedUpdateStatus(plugin) !== undefined; + const enter = hasUpdate ? 'Enter update' : 'Enter details'; + return ` Tab switch · Space toggle · D remove · M MCP · ${enter} · I details · R reload · Esc cancel`; + } + + private installedUpdateStatus( + plugin: PluginSummary, + ): { entry: PluginMarketplaceEntry; local: string; latest: string } | undefined { + if (this.market.status !== 'loaded') return undefined; + const entry = this.market.entries.find( + (candidate) => + candidate.id === plugin.id || + (candidate.builtIn === true && + this.capabilityForEntry(candidate)?.pluginId === plugin.id), + ); + if (entry === undefined) return undefined; + const status = computeUpdateStatus(entry.version, plugin.version, true); + return status.kind === 'update' ? { entry, local: status.local, latest: status.latest } : undefined; + } + + private renderInstalledRow(plugin: PluginSummary, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const status = pluginStatus(plugin); + const update = this.installedUpdateStatus(plugin); + let line = prefix + labelStyle(plugin.displayName); + if (status !== undefined) { + line += ' ' + statusStyle({ kind: 'plugin', value: '', label: '', description: '', status }, colors)(status); + } + if (update !== undefined) { + const badge = `update ${update.local} → ${update.latest}`; + line += ' ' + marketplaceStatusStyle(badge, colors)(badge); + } + if (this.opts.pluginHint?.id === plugin.id) { + line += ' ' + chalk.hex(colors.warning)(this.opts.pluginHint.text); + } + const descWidth = Math.max(1, width - 4); + const out = [line]; + for (const descLine of wrapOverviewDescription(overviewPluginDescription(plugin), descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; + } + + private renderMarketplaceTab( + lines: string[], + width: number, + entries: readonly PluginMarketplaceEntry[], + indexOffset = 0, + // Counts (installed/available footer) are computed over this list: + // the Official tab renders the pinned promo as a row but excludes it + // from the catalog counts, matching its pre-catalog semantics. + entriesForCount: readonly PluginMarketplaceEntry[] = entries, + ): void { + const colors = currentTheme.palette; + if (this.market.status === 'loading' || this.market.status === 'idle') { + lines.push(chalk.hex(colors.textMuted)(' Loading marketplace…')); + return; + } + if (this.market.status === 'error') { + lines.push(chalk.hex(colors.warning)(` Marketplace unavailable: ${this.market.message}`)); + lines.push(mutedHintLine(' Use the Custom tab to install from a URL.', colors)); + return; + } + if (entries.length === 0) { + lines.push(chalk.hex(colors.textMuted)(' No plugins found.')); + } else { + for (let i = 0; i < entries.length; i++) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i + indexOffset, width)); + } + } + const installedCount = entriesForCount.filter((entry) => + this.isMarketplaceEntryInstalled(entry), + ).length; + lines.push(''); + lines.push( + mutedHintLine( + ` ${installedCount} installed · ${entriesForCount.length - installedCount} available`, + colors, + ), + ); + lines.push(mutedHintLine(` Source: ${this.market.source}`, colors)); + } + + private renderOfficial(lines: string[], width: number): void { + // Loading / error: `officialEntries` carries the locally-known + // capability rows (plus the promo fallback when webbridge is not among + // them), so built-in setup works before the catalog arrives. Once + // loaded, the promo appears only when the catalog lacks the real entry. + if (this.market.status !== 'loaded') { + const entries = this.officialEntries; + for (let i = 0; i < entries.length; i += 1) { + lines.push(...this.renderMarketplaceRow(entries[i]!, i, width)); + } + this.renderMarketplaceTab(lines, width, [], entries.length); + return; + } + this.renderMarketplaceTab(lines, width, this.officialEntries, 0, this.officialCatalogEntries); + } + + private renderThirdParty(lines: string[], width: number): void { + if (this.opts.catalogIsDefault !== false) { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Third-party plugins from our partners.', colors)); + lines.push(''); + } + this.renderMarketplaceTab(lines, width, this.thirdPartyEntries); + } + + private renderMarketplaceRow(entry: PluginMarketplaceEntry, index: number, width: number): string[] { + const colors = currentTheme.palette; + const selected = index === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const labelStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); + const prefix = chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `); + const capability = this.capabilityForEntry(entry); + const status = isPinnedWebBridgeEntry(entry) + ? 'open in browser' + : capability?.install.running === true + ? 'installing…' + : marketplaceEntryStatus( + entry, + this.installedVersions, + this.installedPluginId(entry), + ); + const line = + prefix + labelStyle(entry.displayName) + ' ' + marketplaceStatusStyle(status, colors)(status); + const descWidth = Math.max(1, width - 4); + const out = [line]; + const description = + this.activeTab.id === 'official' + ? officialMarketplaceEntryDescription(entry) + : marketplaceEntryDescription(entry); + for (const descLine of wrapOverviewDescription(description, descWidth)) { + out.push(mutedHintLine(` ${descLine}`, colors)); + } + return out; } -} -function shortRevision(value: string): string { - return /^[0-9a-f]{40}$/i.test(value) ? value.slice(0, 8) : value; + private renderCustom(lines: string[], width: number): void { + const colors = currentTheme.palette; + lines.push(mutedHintLine(' Install from a GitHub URL (or zip URL / local path):', colors)); + lines.push(''); + lines.push(...renderUrlInputBox(this.customInput, this.focused, width, colors)); + } + + private renderInstalling(width: number): string[] { + const colors = currentTheme.palette; + const lines = [ + chalk.hex(colors.primary)('─'.repeat(width)), + chalk.hex(colors.primary).bold(' Plugins'), + '', + chalk.hex(colors.textMuted)(` Installing ${this.installing}…`), + '', + chalk.hex(colors.primary)('─'.repeat(width)), + ]; + return lines.map((line) => truncateToWidth(line, width, ELLIPSIS)); + } } function buildMcpItems(info: PluginInfo): PluginsOverviewItem[] { @@ -788,23 +864,83 @@ function mcpItemServerName(item: PluginsOverviewItem): string | undefined { return item.value.slice(MCP_SERVER_PREFIX.length); } -function sectionLabel(label: string): string { - return currentTheme.boldFg('textDim', ` ${label}`); +function marketplaceEntryDescription(entry: PluginMarketplaceEntry): string { + const tier = marketplaceTierLabel(entry.tier); + const description = entry.description ?? tier; + const version = entry.version !== undefined ? ` · v${entry.version}` : ''; + const keywords = + entry.keywords !== undefined && entry.keywords.length > 0 + ? ` · ${entry.keywords.join(', ')}` + : ''; + const tierSuffix = entry.description !== undefined ? ` · ${tier}` : ''; + return `${description} · id ${entry.id}${version}${tierSuffix}${keywords}`; +} + +function officialMarketplaceEntryDescription(entry: PluginMarketplaceEntry): string { + return entry.description ?? ''; +} + +function marketplaceTierLabel(tier: PluginMarketplaceEntry['tier']): string { + if (tier === 'official') return 'Official plugin'; + if (tier === 'curated') return 'Curated plugin'; + return 'Plugin'; +} + +function capabilityMarketplaceEntry(capability: CapabilityStatus): PluginMarketplaceEntry { + return { + id: capability.id, + displayName: capability.displayName, + source: `capability:${capability.id}`, + tier: 'official', + description: capability.description, + builtIn: true, + }; +} + +function installStatus(entry: PluginMarketplaceEntry): string { + return entry.version === undefined ? 'install' : `install v${entry.version}`; +} + +function marketplaceEntryStatus( + entry: PluginMarketplaceEntry, + installed: ReadonlyMap<string, string | undefined>, + installedPluginId = entry.id, +): string { + const status = computeUpdateStatus( + entry.version, + installed.get(installedPluginId), + installed.has(installedPluginId), + ); + switch (status.kind) { + case 'update': + return `update ${status.local} → ${status.latest}`; + case 'up-to-date': + return status.version === undefined ? 'installed' : `installed · v${status.version}`; + case 'not-installed': + return installStatus(entry); + } +} + +function sectionLabel(label: string, colors: ColorPalette): string { + return chalk.hex(colors.textDim).bold(` ${label}`); } function statusStyle( item: PluginsOverviewItem, + colors: ColorPalette, ): (text: string) => string { - if (item.kind === 'action') return (text) => currentTheme.fg('textDim', text); - if (item.status?.startsWith('update')) return (text) => currentTheme.fg('warning', text); - if (item.status === 'enabled' || item.status?.startsWith('installed')) return (text) => currentTheme.fg('success', text); - if (item.status?.startsWith('install')) return (text) => currentTheme.fg('primary', text); - if (item.status === 'disabled') return (text) => currentTheme.fg('textDim', text); - if (item.status !== undefined && /^\d/.test(item.status)) return (text) => currentTheme.fg('textDim', text); - return (text) => currentTheme.fg('warning', text); + if (item.kind === 'action') return chalk.hex(colors.textDim); + if (item.status === 'enabled' || item.status === 'installed') return chalk.hex(colors.success); + if (item.status?.startsWith('install')) return chalk.hex(colors.primary); + if (item.status === 'disabled') return chalk.hex(colors.textDim); + if (item.status !== undefined && /^\d/.test(item.status)) return chalk.hex(colors.textDim); + return chalk.hex(colors.warning); } -function mutedHintLine(text: string): string { +function mutedHintLine(text: string, colors?: ColorPalette): string { + if (colors !== undefined) { + return chalk.hex(colors.textMuted)(text); + } return currentTheme.fg('textMuted', text); } diff --git a/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts b/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts index 4406004d..88c10213 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/provider-manager.ts @@ -42,16 +42,10 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { pageView, type PageView } from '#/tui/utils/paging'; @@ -97,6 +91,7 @@ type Row = SourceRow | AddRow; const ADD_ROW_LABEL = '[ Add New Platform ]'; const PAGE_SIZE = 8; +const HEADER_HINT = '↑↓ navigate · D delete · Esc cancel'; // Narrows a `ProviderConfig` blob to a `CustomRegistrySource` payload. // Mirrors `readCustomRegistrySource` in `pythinker-tui.ts`. We can't import @@ -149,6 +144,8 @@ function buildRows(opts: ProviderManagerOptions): readonly Row[] { const customRegistryIndex = new Map<string, number>(); for (const [id, cfg] of Object.entries(opts.providers)) { + if (id === DEFAULT_OAUTH_PROVIDER_NAME) continue; + const isActive = id === opts.activeProviderId; if (isOpenPlatformId(id)) { @@ -217,8 +214,6 @@ export class ProviderManagerComponent extends Container implements Focusable { private rows: readonly Row[]; private selectedIndex: number; private confirm: ConfirmState | undefined; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); constructor(opts: ProviderManagerOptions) { super(); @@ -233,11 +228,6 @@ export class ProviderManagerComponent extends Container implements Focusable { this.confirm = undefined; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - /** * Replace the props the component renders against. Existing selection * is preserved when possible (by id or first provider id) so deletions @@ -281,25 +271,25 @@ export class ProviderManagerComponent extends Container implements Focusable { return; } + if (matchesKey(data, Key.escape)) { + this.opts.onClose(); + return; + } + const rows = this.rows; - const handlers = { - 'select:previous': () => { - if (rows.length > 0) this.selectedIndex = Math.max(0, this.selectedIndex - 1); - this.invalidate(); - }, - 'select:next': () => { - if (rows.length > 0) this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + 1); - this.invalidate(); - }, - 'select:accept': () => { - if (rows[this.selectedIndex]?.kind === 'add') this.opts.onAdd(); - }, - 'select:cancel': () => this.opts.onClose(), - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; + + if (matchesKey(data, Key.up)) { + if (rows.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.invalidate(); + return; + } + if (matchesKey(data, Key.down)) { + if (rows.length === 0) return; + this.selectedIndex = Math.min(rows.length - 1, this.selectedIndex + 1); + this.invalidate(); + return; + } if (matchesKey(data, Key.left) || matchesKey(data, Key.pageUp)) { if (rows.length === 0) return; @@ -314,6 +304,14 @@ export class ProviderManagerComponent extends Container implements Focusable { return; } + if (matchesKey(data, Key.enter)) { + const selected = rows[this.selectedIndex]; + if (selected?.kind === 'add') { + this.opts.onAdd(); + } + return; + } + // Delete the highlighted provider with the D key. const ch = printableChar(data); if (ch === 'd' || ch === 'D') { @@ -361,7 +359,10 @@ export class ProviderManagerComponent extends Container implements Focusable { // top border, the title, the keymap hint, then a blank line. No inner // border under the title. const border = currentTheme.fg('primary', '─'.repeat(width)); - lines.push(border, currentTheme.boldFg('primary', ' Providers'), currentTheme.fg('textMuted', ' ' + this.headerHint()), ''); + lines.push(border); + lines.push(currentTheme.boldFg('primary', ' Providers')); + lines.push(currentTheme.fg('textMuted', ' ' + HEADER_HINT)); + lines.push(''); const rows = this.rows; if (rows.length === 0) { @@ -403,20 +404,6 @@ export class ProviderManagerComponent extends Container implements Focusable { const styled = currentTheme.boldFg('warning', ` ${prompt} [y/N]`); return truncateToWidth(styled, width, '…'); } - - private headerHint(): string { - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - return [ - navigation, - 'D delete', - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ].filter((hint): hint is string => hint !== undefined).join(' · '); - } } diff --git a/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts b/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts index 8f108fea..1da283ea 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/question-dialog.ts @@ -10,33 +10,22 @@ import { Input, matchesKey, Key, - parseKey, + decodeKittyPrintable, type Focusable, truncateToWidth, visibleWidth, wrapTextWithAnsi, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, QuestionPanelResponse, QuestionSubmissionMethod, } from '#/tui/reverse-rpc/types'; -import { printableChar } from '#/tui/utils/printable-key'; const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; -const MAX_PREVIEW_LINES = 20; -const PREVIEW_SPLIT_MIN_WIDTH = 72; const DEFAULT_OTHER_LABEL = 'Other'; const NOT_ANSWERED_LABEL = 'Not answered'; const REVIEW_TITLE = 'Review your answer before submit'; @@ -47,7 +36,6 @@ const SUBMIT_ACTIONS = ['Submit', 'Cancel'] as const; interface DisplayOption { readonly label: string; readonly description?: string | undefined; - readonly preview?: string | undefined; readonly kind: 'preset' | 'other'; } @@ -81,51 +69,6 @@ function appendWrapped( } } -function fitToWidth(line: string, width: number): string { - const fitted = - visibleWidth(line) > width ? truncateToWidth(line, width, '…') : line; - return fitted + ' '.repeat(Math.max(0, width - visibleWidth(fitted))); -} - -function renderPreviewBox(content: string, width: number): string[] { - const boxWidth = Math.max(12, width); - const innerWidth = Math.max(1, boxWidth - 4); - const wrapped = content - .split('\n') - .flatMap((line) => { - const rows = wrapTextWithAnsi(line, innerWidth); - return rows.length === 0 ? [''] : rows; - }); - const hidden = Math.max(0, wrapped.length - MAX_PREVIEW_LINES); - const visible = wrapped.slice(0, MAX_PREVIEW_LINES); - if (hidden > 0) { - visible[visible.length - 1] = `… ${String(hidden)} more lines`; - } - - const top = `┌─ Preview ${'─'.repeat(Math.max(0, boxWidth - 12))}┐`; - const bottom = `└${'─'.repeat(Math.max(0, boxWidth - 2))}┘`; - const dim = (text: string) => currentTheme.fg('textDim', text); - return [ - dim(top), - ...visible.map((line) => `${dim('│')} ${fitToWidth(line, innerWidth)} ${dim('│')}`), - dim(bottom), - ]; -} - -function joinColumns( - left: readonly string[], - right: readonly string[], - leftWidth: number, - rightWidth: number, -): string[] { - const height = Math.max(left.length, right.length); - return Array.from({ length: height }, (_, index) => { - const leftLine = fitToWidth(left[index] ?? '', leftWidth); - const rightLine = fitToWidth(right[index] ?? '', rightWidth); - return `${leftLine} ${rightLine}`; - }); -} - export class QuestionDialogComponent extends Container implements Focusable { focused = false; @@ -137,31 +80,8 @@ export class QuestionDialogComponent extends Container implements Focusable { private currentTab = 0; private submitActionIdx = 0; private editingOther = false; - private editingNotes = false; private reviewMessage: string | undefined; private lastAnswerMethod: QuestionSubmissionMethod | undefined; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver( - this.bindings.filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField' || - binding.action === 'confirm:toggle' || - binding.action === 'confirm:toggleExplanation', - ), - ); - private nestedKeybindings = new KeybindingResolver( - this.bindings.filter( - (binding) => - binding.action === 'confirm:no' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField', - ), - ); /** Per-question cursor position. */ private readonly cursors: number[]; @@ -173,8 +93,6 @@ export class QuestionDialogComponent extends Container implements Focusable { private readonly otherDrafts: string[]; /** Per-question committed Other values. */ private readonly committedOtherValues: (string | undefined)[]; - /** Per-question notes for preview choices. */ - private readonly noteDrafts: string[]; /** Per-question derived answers used by tabs + review. */ private readonly answers: (string | undefined)[]; @@ -191,10 +109,9 @@ export class QuestionDialogComponent extends Container implements Focusable { this.onAnswer = onAnswer; this.maxVisibleOptions = maxVisibleOptions; this.onToggleToolOutput = onToggleToolOutput; - this.otherInput.onSubmit = (value) => - this.isEditingNotes() - ? this.commitNotesInput(value) - : this.commitOtherInput(value, 'enter'); + this.otherInput.onSubmit = (value) => { + this.commitOtherInput(value, 'enter'); + }; const total = request.data.questions.length; this.cursors = Array.from({ length: total }, (): number => 0); @@ -202,44 +119,14 @@ export class QuestionDialogComponent extends Container implements Focusable { this.multiSelections = Array.from({ length: total }, () => new Set<number>()); this.otherDrafts = Array.from({ length: total }, (): string => ''); this.committedOtherValues = Array.from({ length: total }, (): string | undefined => undefined); - this.noteDrafts = Array.from({ length: total }, (): string => ''); this.answers = Array.from({ length: total }, (): string | undefined => undefined); } // ── Input ───────────────────────────────────────────────────────── - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField' || - binding.action === 'confirm:toggle' || - binding.action === 'confirm:toggleExplanation', - ), - ); - this.nestedKeybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === 'confirm:no' || - binding.action === 'confirm:nextField' || - binding.action === 'confirm:previousField', - ), - ); - } - handleInput(data: string): void { - if (this.isEditingNotes()) { - this.handleNotesInput(data); + if (matchesKey(data, Key.escape)) { + this.onAnswer({ answers: [] }); return; } @@ -258,62 +145,43 @@ export class QuestionDialogComponent extends Container implements Focusable { return; } - const previewQuestionIdx = this.currentQuestionIndex(); - const useLocalNotesFallback = - previewQuestionIdx !== undefined && - printableChar(data) === 'n' && - this.hasPreview(previewQuestionIdx); - const handlers: KeybindingHandlers = useLocalNotesFallback - ? { ...this.handlers(), 'confirm:no': () => false } - : this.handlers(); - const keyId = parseKey(data); - if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined - ) { - this.onAnswer({ answers: [] }); + if (this.isSubmitTab()) { + this.handleSubmitInput(data); return; } - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { + + const questionIdx = this.currentQuestionIndex(); + if (questionIdx === undefined) return; + const question = this.request.data.questions[questionIdx]; + if (question === undefined) return; + + const optionCount = this.displayOptions(questionIdx).length; + if (optionCount === 0) return; + + if (matchesKey(data, Key.up)) { + this.moveQuestionCursor(-1); return; } - if (useLocalNotesFallback) { - this.enterNotesInput(previewQuestionIdx); + if (matchesKey(data, Key.down)) { + this.moveQuestionCursor(1); return; } + if (matchesKey(data, Key.left)) { this.gotoTab(this.currentTab - 1); return; } - if (matchesKey(data, Key.right)) { + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { this.gotoTab(this.currentTab + 1); return; } - if (this.isSubmitTab()) { - const printable = printableChar(data); - if (printable === '1') { - this.submitActionIdx = 0; - this.executeSubmitAction(0, 'number_key'); - } else if (printable === '2') { - this.submitActionIdx = 1; - this.executeSubmitAction(1, 'number_key'); - } + + if (matchesKey(data, Key.enter)) { + this.activateQuestionOption(this.currentCursor(), 'enter'); return; } - const questionIdx = this.currentQuestionIndex(); - if (questionIdx === undefined) return; - const question = this.request.data.questions[questionIdx]; - if (question === undefined) return; - - const optionCount = this.displayOptions(questionIdx).length; - if (optionCount === 0) return; - - const printable = printableChar(data); + const printable = decodeKittyPrintable(data) ?? data; const numIdx = NUMBER_KEYS.indexOf(printable); if (numIdx >= 0 && numIdx < optionCount) { this.cursors[questionIdx] = numIdx; @@ -321,38 +189,19 @@ export class QuestionDialogComponent extends Container implements Focusable { return; } + if ((printable === ' ' || matchesKey(data, Key.space)) && question.multi_select) { + this.activateQuestionOption(this.currentCursor(), 'space'); + } } private handleOtherInput(data: string): void { const questionIdx = this.currentQuestionIndex(); if (questionIdx === undefined) return; - const handlers: KeybindingHandlers = { - 'confirm:no': () => this.onAnswer({ answers: [] }), - 'confirm:nextField': () => { - this.syncOtherDraft(questionIdx); - this.editingOther = false; - this.gotoTab(this.currentTab + 1); - }, - 'confirm:previousField': () => { - this.syncOtherDraft(questionIdx); - this.editingOther = false; - this.gotoTab(this.currentTab - 1); - }, - }; - const keyId = parseKey(data); - if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined - ) { - this.onAnswer({ answers: [] }); - return; - } - if ( - keyId === undefined - ? this.nestedKeybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.nestedKeybindings.dispatch(data, ['Confirmation'], handlers) - ) { + if (matchesKey(data, Key.tab)) { + this.syncOtherDraft(questionIdx); + this.editingOther = false; + this.gotoTab(this.currentTab + 1); return; } if (matchesKey(data, Key.up)) { @@ -367,98 +216,49 @@ export class QuestionDialogComponent extends Container implements Focusable { this.moveQuestionCursor(1); return; } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.onAnswer({ answers: [] }); - return; - } this.otherInput.handleInput(data); this.syncOtherDraft(questionIdx); this.reviewMessage = undefined; } - private handleNotesInput(data: string): void { - const questionIdx = this.currentQuestionIndex(); - if (questionIdx === undefined) return; - - const cancelNotes = (): void => { - this.syncNotesDraft(questionIdx); - this.editingNotes = false; - }; - const handlers: KeybindingHandlers = { - 'confirm:no': cancelNotes, - 'confirm:nextField': () => { - this.syncNotesDraft(questionIdx); - this.editingNotes = false; - this.gotoTab(this.currentTab + 1); - }, - 'confirm:previousField': () => { - this.syncNotesDraft(questionIdx); - this.editingNotes = false; - this.gotoTab(this.currentTab - 1); - }, - }; - const keyId = parseKey(data); - if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined - ) { - cancelNotes(); + private handleSubmitInput(data: string): void { + if (matchesKey(data, Key.up)) { + this.submitActionIdx = + (this.submitActionIdx - 1 + SUBMIT_ACTIONS.length) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; return; } - if ( - keyId === undefined - ? this.nestedKeybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.nestedKeybindings.dispatch(data, ['Confirmation'], handlers) - ) { + if (matchesKey(data, Key.down)) { + this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTIONS.length; + this.reviewMessage = undefined; return; } - if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) { - this.syncNotesDraft(questionIdx); - this.editingNotes = false; + + if (matchesKey(data, Key.left)) { + this.gotoTab(this.currentTab - 1); + return; + } + if (matchesKey(data, Key.right) || matchesKey(data, Key.tab)) { + this.gotoTab(this.currentTab + 1); return; } - this.otherInput.handleInput(data); - this.syncNotesDraft(questionIdx); - this.reviewMessage = undefined; - } + if (matchesKey(data, Key.enter)) { + this.executeSubmitAction(this.submitActionIdx, 'enter'); + return; + } - private handlers(): KeybindingHandlers { - return { - 'confirm:yes': () => { - if (this.isSubmitTab()) this.executeSubmitAction(this.submitActionIdx, 'enter'); - else this.activateQuestionOption(this.currentCursor(), 'enter'); - }, - 'confirm:no': () => this.onAnswer({ answers: [] }), - 'confirm:previous': () => { - if (this.isSubmitTab()) { - this.submitActionIdx = - (this.submitActionIdx - 1 + SUBMIT_ACTIONS.length) % SUBMIT_ACTIONS.length; - this.reviewMessage = undefined; - } else { - this.moveQuestionCursor(-1); - } - }, - 'confirm:next': () => { - if (this.isSubmitTab()) { - this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTIONS.length; - this.reviewMessage = undefined; - } else { - this.moveQuestionCursor(1); - } - }, - 'confirm:nextField': () => this.gotoTab(this.currentTab + 1), - 'confirm:previousField': () => this.gotoTab(this.currentTab - 1), - 'confirm:toggle': () => { - if (!this.isSubmitTab() && this.request.data.questions[this.currentTab]?.multi_select === true) { - this.activateQuestionOption(this.currentCursor(), 'space'); - } - }, - 'confirm:toggleExplanation': () => { - if (!this.isSubmitTab() && this.hasPreview(this.currentTab)) this.enterNotesInput(this.currentTab); - }, - }; + const printable = decodeKittyPrintable(data) ?? data; + if (printable === '1') { + this.submitActionIdx = 0; + this.executeSubmitAction(0, 'number_key'); + return; + } + if (printable === '2') { + this.submitActionIdx = 1; + this.executeSubmitAction(1, 'number_key'); + } } // ── State mutation ──────────────────────────────────────────────── @@ -472,7 +272,6 @@ export class QuestionDialogComponent extends Container implements Focusable { this.currentTab = wrapped; this.editingOther = false; - this.editingNotes = false; this.reviewMessage = undefined; if (this.isSubmitTab()) this.submitActionIdx = 0; } @@ -500,11 +299,8 @@ export class QuestionDialogComponent extends Container implements Focusable { this.reviewMessage = undefined; if (this.isOtherOption(questionIdx, optionIdx)) { - // Toggling a committed "Other" answer deselects it (multi-select only); - // Enter always (re)opens the custom input. - const set = this.multiSelections[questionIdx]; - if (question.multi_select && method !== 'enter' && set?.has(optionIdx) === true) { - set.delete(optionIdx); + if (question.multi_select && this.multiSelections[questionIdx]?.has(optionIdx)) { + this.multiSelections[questionIdx].delete(optionIdx); this.lastAnswerMethod = method; this.updateAnswer(questionIdx); return; @@ -537,12 +333,6 @@ export class QuestionDialogComponent extends Container implements Focusable { this.reviewMessage = undefined; } - private enterNotesInput(questionIdx: number): void { - this.editingNotes = true; - this.otherInput.setValue(this.noteDrafts[questionIdx] ?? ''); - this.reviewMessage = undefined; - } - private commitOtherInput(rawValue: string | undefined, method: QuestionSubmissionMethod): void { const questionIdx = this.currentQuestionIndex(); if (questionIdx === undefined) return; @@ -571,14 +361,6 @@ export class QuestionDialogComponent extends Container implements Focusable { if (!question.multi_select) this.advanceAfterSingleSelect(questionIdx); } - private commitNotesInput(rawValue: string | undefined): void { - const questionIdx = this.currentQuestionIndex(); - if (questionIdx === undefined) return; - this.noteDrafts[questionIdx] = rawValue ?? this.otherInput.getValue(); - this.editingNotes = false; - this.reviewMessage = undefined; - } - private advanceAfterSingleSelect(questionIdx: number): void { const next = this.findNextUnansweredAfter(questionIdx); this.currentTab = next ?? this.submitTabIndex(); @@ -644,36 +426,17 @@ export class QuestionDialogComponent extends Container implements Focusable { private emitAnswers(method: QuestionSubmissionMethod): void { const out: string[] = []; - const annotations: Record<string, { preview?: string; notes?: string }> = {}; for (let i = 0; i < this.answers.length; i++) { const answer = this.answers[i]; if (answer !== undefined && answer.length > 0) out[i] = answer; - - const question = this.request.data.questions[i]; - if (question === undefined) continue; - const selection = this.singleSelections[i]; - const preview = - selection === undefined ? undefined : question.options[selection]?.preview; - const notes = this.noteDrafts[i]?.trim(); - if ((preview !== undefined && preview.length > 0) || (notes !== undefined && notes.length > 0)) { - annotations[question.question] = { - preview, - notes: notes?.length ? notes : undefined, - }; - } } - this.onAnswer({ - answers: out, - method: this.lastAnswerMethod ?? method, - annotations: Object.keys(annotations).length > 0 ? annotations : undefined, - }); + this.onAnswer({ answers: out, method: this.lastAnswerMethod ?? method }); } // ── Render ──────────────────────────────────────────────────────── override render(width: number): string[] { - this.otherInput.focused = - this.focused && (this.isEditingOther() || this.isEditingNotes()); + this.otherInput.focused = this.focused && this.isEditingOther(); return this.isSubmitTab() ? this.renderSubmitTab(width) : this.renderQuestionTab(width); } @@ -719,13 +482,6 @@ export class QuestionDialogComponent extends Container implements Focusable { const multiSet = this.multiSelections[questionIdx] ?? new Set<number>(); const singleSelection = this.singleSelections[questionIdx]; - const previewMode = this.hasPreview(questionIdx); - const splitPreview = previewMode && renderWidth >= PREVIEW_SPLIT_MIN_WIDTH; - const optionWidth = splitPreview - ? Math.max(24, Math.floor((renderWidth - 2) * 0.4)) - : renderWidth; - const optionLines: string[] = []; - for (let i = visibleStart; i < visibleEnd; i++) { const option = options[i]; if (option === undefined) continue; @@ -735,9 +491,7 @@ export class QuestionDialogComponent extends Container implements Focusable { const isSelected = question.multi_select ? multiSet.has(i) : singleSelection === i; if (this.isEditingOther() && isCursor && isOther) { - optionLines.push( - this.renderEditingOtherLine(optionWidth, questionIdx, option, num, isSelected), - ); + lines.push(this.renderEditingOtherLine(renderWidth, questionIdx, option, num, isSelected)); continue; } @@ -763,71 +517,28 @@ export class QuestionDialogComponent extends Container implements Focusable { tone = dim; } const continuation = ' '.repeat(visibleWidth(prefix)); - appendWrapped(optionLines, prefix, continuation, label, optionWidth, tone); + appendWrapped(lines, prefix, continuation, label, renderWidth, tone); if ( option.description !== undefined && option.description.length > 0 && !(this.isEditingOther() && isCursor && isOther) ) { - appendWrapped(optionLines, ' ', ' ', option.description, optionWidth, dim); + appendWrapped(lines, ' ', ' ', option.description, renderWidth, dim); } } if (visibleEnd < options.length || visibleStart > 0) { - optionLines.push( + lines.push( dim( ` showing ${String(visibleStart + 1)}-${String(visibleEnd)} of ${String(options.length)}`, ), ); } - if (previewMode) { - const content = options[cursor]?.preview?.trim() || 'No preview for this option.'; - if (splitPreview) { - const previewWidth = renderWidth - optionWidth - 2; - lines.push( - ...joinColumns( - optionLines, - renderPreviewBox(content, previewWidth), - optionWidth, - previewWidth, - ), - ); - } else { - lines.push(...optionLines, '', ...renderPreviewBox(content, renderWidth)); - } - } else { - lines.push(...optionLines); - } - - if (previewMode) { - const notes = this.noteDrafts[questionIdx] ?? ''; - lines.push(''); - if (this.isEditingNotes()) { - const inputLine = this.otherInput.render(Math.max(4, renderWidth - 10))[0] ?? '> '; - lines.push(`${accent(' Notes: ')}${inputLine.startsWith('> ') ? inputLine.slice(2) : inputLine}`); - } else if (notes.trim().length > 0) { - lines.push(`${accent(' Notes: ')}${notes.trim()}`); - } else { - const nAction = this.bindings.findLast( - (binding) => - binding.context === 'Confirmation' && - binding.chord.length === 1 && - binding.chord[0] === 'n', - )?.action; - if ( - nAction === undefined || - nAction === null || - nAction === 'confirm:no' || - this.handlers()[nAction] === undefined - ) { - lines.push(`${accent(' Notes: ')}${dim('press n to add notes')}`); - } - } - } - - lines.push('', this.buildQuestionHint(dim, questionIdx), accent('─'.repeat(renderWidth))); + lines.push(''); + lines.push(this.buildQuestionHint(dim, questionIdx)); + lines.push(accent('─'.repeat(renderWidth))); return lines.map((line) => truncateToWidth(line, width)); } @@ -841,7 +552,8 @@ export class QuestionDialogComponent extends Container implements Focusable { const renderWidth = Math.max(1, width); const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; this.pushTabs(lines); - lines.push('', currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); + lines.push(''); + lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); const reviewWarning = this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); if (reviewWarning !== undefined) { @@ -873,7 +585,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } } - lines.push('', text(` ${SUBMIT_PROMPT}`), ''); + lines.push(''); + lines.push(text(` ${SUBMIT_PROMPT}`)); + lines.push(''); for (let i = 0; i < SUBMIT_ACTIONS.length; i++) { const label = SUBMIT_ACTIONS[i]; @@ -886,7 +600,9 @@ export class QuestionDialogComponent extends Container implements Focusable { } } - lines.push('', this.buildSubmitHint(dim), accent('─'.repeat(renderWidth))); + lines.push(''); + lines.push(this.buildSubmitHint(dim)); + lines.push(accent('─'.repeat(renderWidth))); return lines.map((line) => truncateToWidth(line, width)); } @@ -894,7 +610,7 @@ export class QuestionDialogComponent extends Container implements Focusable { private pushTabs(lines: string[]): void { const dim = (text: string) => currentTheme.fg('textDim', text); const active = (text: string) => - currentTheme.bg('selectionBg', currentTheme.boldFg('inverseText', text)); + currentTheme.bg('primary', currentTheme.boldFg('text', text)); const tabs: string[] = []; for (let i = 0; i < this.request.data.questions.length; i++) { @@ -917,35 +633,13 @@ export class QuestionDialogComponent extends Container implements Focusable { } private buildQuestionHint(dim: (s: string) => string, questionIdx: number): string { - if (this.isEditingNotes()) { - const field = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previousField'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:nextField'), - 'switch', - ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const parts = [ - 'type notes', - '↵ save', - this.totalTabs() > 1 ? field : undefined, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} return`, - ].filter((part): part is string => part !== undefined); - return dim(` ${parts.join(' ')}`); - } - if (this.isEditingOther()) { - const field = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previousField'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:nextField'), - 'switch', - ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const parts = [ + const parts: string[] = [ 'type answer', '↵ save', - this.totalTabs() > 1 ? field : undefined, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ].filter((part): part is string => part !== undefined); + ...(this.totalTabs() > 1 ? ['tab switch'] : []), + 'esc cancel', + ]; return dim(` ${parts.join(' ')}`); } @@ -954,55 +648,19 @@ export class QuestionDialogComponent extends Container implements Focusable { const question = this.request.data.questions[questionIdx]; if (question === undefined) return dim(' esc cancel'); - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previous'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:next'), - 'select', - ); - const confirm = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:yes'); - const field = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previousField'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:nextField'), - 'switch', - ); - const explain = keybindingDisplayText( - this.bindings, - 'Confirmation', - 'confirm:toggleExplanation', - ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const parts = [ - navigation, - `${numberHint}${confirm === undefined ? '' : ` / ${formatBindingKeys(confirm)}`} ${question.multi_select ? 'toggle' : 'choose'}`, - this.totalTabs() > 1 ? field : undefined, - this.hasPreview(questionIdx) && explain !== undefined - ? `${formatBindingKeys(explain)} notes` - : undefined, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ].filter((part): part is string => part !== undefined); + const parts: string[] = [ + '↑↓ select', + `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, + ]; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } private buildSubmitHint(dim: (s: string) => string): string { - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previous'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:next'), - 'select', - ); - const confirm = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:yes'); - const field = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previousField'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:nextField'), - 'switch', - ); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const parts = [ - navigation, - '1/2 choose', - confirm === undefined ? undefined : `${formatBindingKeys(confirm)} confirm`, - this.totalTabs() > 1 ? field : undefined, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ].filter((part): part is string => part !== undefined); + const parts: string[] = ['↑↓ select', '1/2 choose', '↵ confirm']; + if (this.totalTabs() > 1) parts.push('←/→/tab switch'); + parts.push('esc cancel'); return dim(` ${parts.join(' ')}`); } @@ -1031,10 +689,6 @@ export class QuestionDialogComponent extends Container implements Focusable { return this.editingOther && !this.isSubmitTab(); } - private isEditingNotes(): boolean { - return this.editingNotes && !this.isSubmitTab(); - } - private currentQuestionIndex(): number | undefined { return this.isSubmitTab() ? undefined : this.currentTab; } @@ -1053,20 +707,13 @@ export class QuestionDialogComponent extends Container implements Focusable { ...question.options.map((option) => ({ label: option.label, description: option.description, - preview: option.preview, kind: 'preset' as const, })), - ...(question.allow_other === false || this.hasPreview(questionIdx) - ? [] - : [ - { - label: question.other_label?.length ? question.other_label : DEFAULT_OTHER_LABEL, - description: question.other_description?.length - ? question.other_description - : undefined, - kind: 'other' as const, - }, - ]), + { + label: question.other_label?.length ? question.other_label : DEFAULT_OTHER_LABEL, + description: question.other_description?.length ? question.other_description : undefined, + kind: 'other' as const, + }, ]; } @@ -1128,16 +775,6 @@ export class QuestionDialogComponent extends Container implements Focusable { this.otherDrafts[questionIdx] = this.otherInput.getValue(); } - private syncNotesDraft(questionIdx: number): void { - this.noteDrafts[questionIdx] = this.otherInput.getValue(); - } - - private hasPreview(questionIdx: number): boolean { - return this.request.data.questions[questionIdx]?.options.some( - (option) => option.preview !== undefined && option.preview.trim().length > 0, - ) ?? false; - } - private isAnswered(questionIdx: number): boolean { const answer = this.answers[questionIdx]; return answer !== undefined && answer.length > 0; diff --git a/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts b/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts index 66128e38..dea37d54 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/session-picker.ts @@ -9,18 +9,9 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; +import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { - combinedBindingHint, - formatBindingKeys, -} from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -84,14 +75,7 @@ function singleLine(text: string): string { } function sessionSearchText(session: SessionRow): string { - return singleLine( - `${(session.title ?? session.id).trim() || session.id} ${sessionTag(session) ?? ''}`, - ); -} - -function sessionTag(session: SessionRow): string | undefined { - const value = session.metadata?.['tag']; - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; + return singleLine((session.title ?? session.id).trim() || session.id); } export class SessionPickerComponent extends Container implements Focusable { @@ -105,9 +89,9 @@ export class SessionPickerComponent extends Container implements Focusable { private visibleCount: number; private scope: 'cwd' | 'all'; private loading: boolean; + private hasMore: boolean; + private loadingMore: boolean; private list: SearchableList<SessionRow>; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); focused = false; @@ -124,6 +108,14 @@ export class SessionPickerComponent extends Container implements Focusable { onCtrlD?: () => void; onToggleScope?: (selectedSessionId: string) => void; maxVisibleSessions?: number; + /** More pages exist on the backend (keyset paging). */ + hasMore?: boolean; + /** A follow-up page fetch is in flight. */ + loadingMore?: boolean; + /** Fired when the cursor reaches the end of every row fetched so far. */ + onLoadMore?: () => void; + /** Fired when a search query becomes active while pages remain unfetched. */ + onSearchDrain?: () => void; }) { super(); this.sessions = opts.sessions; @@ -135,6 +127,10 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope = opts.onToggleScope; this.maxVisibleSessions = opts.maxVisibleSessions ?? 4; this.pageSize = Math.max(1, opts.pageSize ?? 50); + this.hasMore = opts.hasMore ?? false; + this.loadingMore = opts.loadingMore ?? false; + this.onLoadMore = opts.onLoadMore; + this.onSearchDrain = opts.onSearchDrain; const initialIndex = this.resolveInitialSelectedIndex(opts.initialSelectedSessionId); this.list = new SearchableList({ items: this.sessions, @@ -151,6 +147,26 @@ export class SessionPickerComponent extends Container implements Focusable { private readonly onCtrlC?: () => void; private readonly onCtrlD?: () => void; + private readonly onLoadMore?: () => void; + private readonly onSearchDrain?: () => void; + + /** Appends a freshly fetched page, keeping the cursor and active query. */ + appendSessions(rows: SessionRow[]): void { + this.sessions = [...this.sessions, ...rows]; + this.list.setItems(this.sessions); + // Rows arriving while a query is active must become visible without + // waiting for the next keypress; only grow, never shrink the window. + this.visibleCount = Math.max( + this.visibleCount, + Math.min(this.list.view().items.length, this.pageSize), + ); + } + + /** Updates the backend-paging facts after an in-flight fetch settles. */ + setPaging(hasMore: boolean, loadingMore: boolean): void { + this.hasMore = hasMore; + this.loadingMore = loadingMore; + } private resolveInitialSelectedIndex(initialSelectedSessionId: string | undefined): number { if (initialSelectedSessionId === undefined) return 0; @@ -158,11 +174,6 @@ export class SessionPickerComponent extends Container implements Focusable { return Math.max(index, 0); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - private filteredSessions(): readonly SessionRow[] { return this.list.view().items; } @@ -175,6 +186,11 @@ export class SessionPickerComponent extends Container implements Focusable { const view = this.list.view(); if (view.query !== previousQuery) { this.visibleCount = Math.min(view.items.length, this.pageSize); + // A fresh query only searches the pages fetched so far; ask the host to + // drain the rest in the background so search covers every session. + if (view.query.length > 0 && previousQuery.length === 0 && this.hasMore) { + this.onSearchDrain?.(); + } return; } @@ -182,32 +198,18 @@ export class SessionPickerComponent extends Container implements Focusable { if (view.selectedIndex >= loadedCount - 1 && loadedCount < view.items.length) { this.visibleCount = Math.min(view.items.length, this.visibleCount + this.pageSize); } + // The cursor reached the end of everything fetched: pull the next page. + if ( + this.hasMore && + !this.loadingMore && + view.items.length > 0 && + view.selectedIndex >= view.items.length - 1 + ) { + this.onLoadMore?.(); + } } handleInput(data: string): void { - const previousQuery = this.list.view().query; - const handlers = { - 'select:previous': () => { - this.list.moveUp(); - this.syncVisibleCount(previousQuery); - }, - 'select:next': () => { - this.list.moveDown(); - this.syncVisibleCount(previousQuery); - }, - 'select:accept': () => { - const session = this.list.selected(); - if (session) this.onSelect(session); - }, - 'select:cancel': () => { - if (this.list.clearQuery()) this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); - else this.onCancel(); - }, - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; if (matchesKey(data, Key.ctrl('c'))) { this.onCtrlC?.(); return; @@ -220,17 +222,22 @@ export class SessionPickerComponent extends Container implements Focusable { this.onToggleScope?.(this.list.selected()?.id ?? this.currentSessionId); return; } - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - this.syncVisibleCount(previousQuery); + if (matchesKey(data, Key.escape)) { + if (this.list.clearQuery()) { + this.visibleCount = Math.min(this.filteredSessions().length, this.pageSize); + return; + } + this.onCancel(); return; } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); - this.syncVisibleCount(previousQuery); + if (matchesKey(data, Key.enter)) { + const session = this.list.selected(); + if (session) this.onSelect(session); return; } - if (this.list.handleSearchKey(data)) { + + const previousQuery = this.list.view().query; + if (this.list.handleKey(data)) { this.syncVisibleCount(previousQuery); } } @@ -256,38 +263,44 @@ export class SessionPickerComponent extends Container implements Focusable { : 'Ctrl+A all'; if (this.loading) { - lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)), currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), currentTheme.fg('primary', '─'.repeat(width))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('Loading sessions...', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } if (this.sessions.length === 0) { - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - const hintParts = [scopeHint, cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`].filter( + const hintParts = [scopeHint, 'Esc cancel'].filter( (item): item is string => item !== undefined, ); - lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS)), currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), '', currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), currentTheme.fg('primary', '─'.repeat(width))); + lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); + lines.push( + currentTheme.fg('textMuted', truncateToWidth(hintParts.join(' · '), width, ELLIPSIS)), + ); + lines.push(''); + lines.push( + currentTheme.fg('textMuted', truncateToWidth('No sessions found.', width, ELLIPSIS)), + ); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } const view = this.list.view(); const titleSuffix = view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'navigate', - ); - const accept = keybindingDisplayText(this.bindings, 'Select', 'select:accept'); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); const hintParts = [ ...(view.query.length > 0 ? ['Backspace clear'] : []), - navigation, + '↑↓ navigate', scopeHint, - accept === undefined ? undefined : `${formatBindingKeys(accept)} select`, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, + 'Enter select', + 'Esc cancel', ].filter((item): item is string => item !== undefined); - lines.push(currentTheme.boldFg('primary', title) + titleSuffix, currentTheme.fg('textMuted', hintParts.join(' · ')), ''); + lines.push(currentTheme.boldFg('primary', title) + titleSuffix); + lines.push(currentTheme.fg('textMuted', hintParts.join(' · '))); + lines.push(''); if (view.query.length > 0) { lines.push(currentTheme.fg('primary', 'Search: ') + currentTheme.fg('text', view.query)); @@ -295,7 +308,8 @@ export class SessionPickerComponent extends Container implements Focusable { const loadedSessions = this.loadedSessions(view.items); if (loadedSessions.length === 0) { - lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS)), currentTheme.fg('primary', '─'.repeat(width))); + lines.push(currentTheme.fg('textMuted', truncateToWidth('No matches', width, ELLIPSIS))); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines; } const selectedIndex = view.selectedIndex; @@ -321,15 +335,29 @@ export class SessionPickerComponent extends Container implements Focusable { } const filteredCount = view.items.length; - if (loadedSessions.length > visibleSessions.length || view.query.length > 0) { + if ( + loadedSessions.length > visibleSessions.length || + view.query.length > 0 || + this.hasMore || + this.loadingMore + ) { lines.push(''); + const moreSuffix = this.loadingMore + ? ' · loading more…' + : this.hasMore + ? view.query.length > 0 + ? ' · searching all…' + : ' · scroll for more' + : ''; const totalSuffix = view.query.length > 0 ? `${String(loadedSessions.length)} loaded / ${String(filteredCount)} matches` - : loadedSessions.length === this.sessions.length - ? `${String(loadedSessions.length)} sessions` - : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; - const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}`; + : this.hasMore || this.loadingMore + ? `${String(loadedSessions.length)} loaded` + : loadedSessions.length === this.sessions.length + ? `${String(loadedSessions.length)} sessions` + : `${String(loadedSessions.length)} loaded / ${String(this.sessions.length)} sessions`; + const footer = `Showing ${String(visibleStart + 1)}-${String(visibleStart + visibleSessions.length)} of ${totalSuffix}${moreSuffix}`; lines.push(currentTheme.fg('textMuted', truncateToWidth(footer, width, ELLIPSIS))); } @@ -353,9 +381,7 @@ export class SessionPickerComponent extends Container implements Focusable { const time = formatRelativeTime(session.updated_at); const badge = isCurrent ? CURRENT_MARK : ''; const rawTitle = (session.title ?? session.id).trim() || session.id; - const title = rawTitle; - const tag = sessionTag(session); - const titleSource = tag === undefined ? title : `${title} #${tag}`; + const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata }); // Inline trailing parts after the title: "<title> <time> ← current". const trailingParts = [time, badge].filter((p) => p.length > 0); diff --git a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts index 00047a4c..81e4b8d1 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/settings-selector.ts @@ -2,12 +2,10 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; export type SettingsSelection = | 'model' - | 'output-style' | 'theme' | 'editor' | 'permission' | 'experiments' - | 'copy' | 'upgrade' | 'usage'; @@ -17,11 +15,6 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Model', description: 'Switch the active model and thinking mode.', }, - { - value: 'output-style', - label: 'Output style', - description: 'Choose how Pythinker formats responses.', - }, { value: 'permission', label: 'Permission', @@ -42,11 +35,6 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Experiments', description: 'Turn experimental features on or off.', }, - { - value: 'copy', - label: 'Copy responses', - description: 'Choose whether /copy always uses the full response.', - }, { value: 'upgrade', label: 'Automatic updates', @@ -62,12 +50,10 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ function isSettingsSelection(value: string): value is SettingsSelection { return ( value === 'model' || - value === 'output-style' || value === 'theme' || value === 'editor' || value === 'permission' || value === 'experiments' || - value === 'copy' || value === 'upgrade' || value === 'usage' ); diff --git a/apps/pythinker-code/src/tui/components/dialogs/start-permission-prompt.ts b/apps/pythinker-code/src/tui/components/dialogs/start-permission-prompt.ts index d5fa98f8..36a45dde 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/start-permission-prompt.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/start-permission-prompt.ts @@ -1,21 +1,13 @@ import { Key, - parseKey, + matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; export type StartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -41,89 +33,35 @@ export class StartPermissionPromptComponent<TChoice extends StartPermissionChoic { focused = false; private selectedIndex = 0; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver( - this.bindings.filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:toggle', - ), - ); constructor(private readonly opts: StartPermissionPromptOptions<TChoice>) {} invalidate(): void {} - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => - binding.action === 'confirm:yes' || - binding.action === 'confirm:no' || - binding.action === 'confirm:previous' || - binding.action === 'confirm:next' || - binding.action === 'confirm:toggle', - ), - ); - } - handleInput(data: string): void { - const keyId = parseKey(data); - const handlers: KeybindingHandlers = { - 'confirm:yes': () => this.opts.onSelect(this.opts.options[this.selectedIndex]!.value), - 'confirm:no': this.opts.onCancel, - 'confirm:previous': () => { - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - }, - 'confirm:next': () => { - this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); - }, - 'confirm:toggle': () => this.opts.onSelect(this.opts.options[this.selectedIndex]!.value), - }; - if ( - (keyId ?? data) === Key.escape && - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no') === undefined - ) { + if (matchesKey(data, Key.escape)) { this.opts.onCancel(); return; } - if ( - keyId === undefined - ? this.keybindings.dispatchKeyId(data, ['Confirmation'], handlers) - : this.keybindings.dispatch(data, ['Confirmation'], handlers) - ) { + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); return; } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(this.opts.options[this.selectedIndex]!.value); + } } render(width: number): string[] { const rule = currentTheme.fg('primary', '─'.repeat(width)); - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:previous'), - keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:next'), - 'navigate', - ); - const select = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:yes'); - const cancel = keybindingDisplayText(this.bindings, 'Confirmation', 'confirm:no'); - const hint = [ - navigation, - select === undefined ? undefined : `${formatBindingKeys(select)} select`, - cancel === undefined ? undefined : `${formatBindingKeys(cancel)} cancel`, - ] - .filter((part): part is string => part !== undefined) - .join(' · '); const lines = [ rule, currentTheme.boldFg('primary', ` ${this.opts.title}`), - currentTheme.fg('textMuted', ` ${hint}`), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'), '', ]; diff --git a/apps/pythinker-code/src/tui/components/dialogs/tabbed-model-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/tabbed-model-selector.ts index 73c77629..606a6435 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/tabbed-model-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/tabbed-model-selector.ts @@ -16,21 +16,17 @@ import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; import { Container, + Key, + matchesKey, truncateToWidth, - visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { - defaultKeybindings, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; import { ModelSelectorComponent, - normalizeModelChoices, providerDisplayName, type ModelSelection, type ModelSelectorOptions, @@ -43,11 +39,23 @@ export interface TabbedModelSelectorOptions { readonly models: Record<string, ModelAlias>; readonly currentValue: string; readonly selectedValue?: string; - readonly currentEffort: string; + readonly currentThinkingEffort: string; + /** Forwarded to each inner selector; overrides the default ' Select a model' + * title line. */ + readonly title?: string; /** When set, the tab for this provider id is initially active instead of the * tab derived from `currentValue`. */ readonly initialTabId?: string; + /** When set, warning-colored lines are rendered directly below the key-hint + * line, wrapping as needed (e.g. the mid-conversation switch cost notice). */ + readonly warning?: string; + /** Forwarded to each inner selector; set to false to hide the Thinking + * footer and disable ←/→ effort switching. */ + readonly thinkingControl?: boolean; readonly onSelect: (selection: ModelSelection) => void; + /** Forwarded to each inner selector; when set, Alt+S applies the choice to + * the current session only without persisting it as the default. */ + readonly onSessionOnlySelect?: (selection: ModelSelection) => void; readonly onCancel: () => void; } @@ -62,55 +70,36 @@ export class TabbedModelSelectorComponent extends Container implements Focusable private readonly opts: TabbedModelSelectorOptions; private readonly tabs: readonly ModelTab[]; private activeIndex: number; - private keybindings = new KeybindingResolver(defaultKeybindings()); constructor(opts: TabbedModelSelectorOptions) { super(); this.opts = opts; this.tabs = buildTabs(opts); - const selectedCandidate = opts.selectedValue ?? opts.currentValue; - const initialTabId = - opts.initialTabId ?? - opts.models[selectedCandidate]?.provider ?? - Object.values(opts.models).find((model) => - selectedCandidate.startsWith(`${model.provider}/`), - )?.provider; - const initialTabIdx = initialTabId - ? this.tabs.findIndex((tab) => tab.id === initialTabId) + // Default to the "All" tab. Only an explicit initialTabId (e.g. the + // provider just added via /provider) opens on a specific provider tab — + // the current model is still highlighted inside whichever tab is active. + const initialTabIdx = opts.initialTabId + ? this.tabs.findIndex((tab) => tab.id === opts.initialTabId) : -1; this.activeIndex = Math.max(initialTabIdx, 0); this.syncFocusToActive(); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.keybindings = new KeybindingResolver(bindings); - for (const tab of this.tabs) tab.selector.setKeybindings(bindings); - } - handleInput(data: string): void { - if (this.tabs[this.activeIndex]?.selector.handleInput(data) === true) return; - - const handlers = { - 'tabs:next': () => { - if (this.tabs.length > 1) { - this.activeIndex = (this.activeIndex + 1) % this.tabs.length; - this.syncFocusToActive(); - } - }, - 'tabs:previous': () => { - if (this.tabs.length > 1) { - this.activeIndex = (this.activeIndex - 1 + this.tabs.length) % this.tabs.length; - this.syncFocusToActive(); - } - }, - } as const; - if ( - this.keybindings.dispatch(data, ['Tabs'], handlers) || - this.keybindings.dispatchKeyId(data, ['Tabs'], handlers) - ) { - return; + if (this.tabs.length > 1) { + if (matchesKey(data, Key.tab)) { + this.activeIndex = (this.activeIndex + 1) % this.tabs.length; + this.syncFocusToActive(); + return; + } + if (matchesKey(data, Key.shift('tab'))) { + this.activeIndex = (this.activeIndex - 1 + this.tabs.length) % this.tabs.length; + this.syncFocusToActive(); + return; + } } + this.tabs[this.activeIndex]?.selector.handleInput(data); } override render(width: number): string[] { @@ -120,19 +109,20 @@ export class TabbedModelSelectorComponent extends Container implements Focusable if (this.tabs.length <= 1) { return inner.map((line) => truncateToWidth(line, width)); } - // Layout: divider, title, hint, blank, tab strip, blank, then the model - // list. The inner selector's blank line (inner[3]) separates the hint from - // the tab strip; an extra blank separates the tabs from their list. - const stripLine = this.renderTabStrip(width); - const out: string[] = [ - inner[0] ?? '', - inner[1] ?? '', - inner[2] ?? '', - inner[3] ?? '', - stripLine, - '', - ]; - for (let i = 4; i < inner.length; i++) out.push(inner[i]!); + // Layout: divider, title, hint, optional warning, blank, tab strip, blank, + // then the model list. The header ends at its first blank line — keep that + // blank above the strip, and separate the tabs from the list with another + // blank. + const stripLine = renderTabStrip({ + labels: this.tabs.map((tab) => tab.label), + activeIndex: this.activeIndex, + width, + colors: currentTheme.palette, + }); + const headerEnd = inner.findIndex((line) => line === ''); + const splitAt = headerEnd === -1 ? 3 : headerEnd; + const out: string[] = [...inner.slice(0, splitAt + 1), stripLine, '']; + for (let i = splitAt + 1; i < inner.length; i++) out.push(inner[i]!); return out.map((line) => truncateToWidth(line, width)); } @@ -143,100 +133,16 @@ export class TabbedModelSelectorComponent extends Container implements Focusable } } - selectedAlias(): string | undefined { - return this.tabs[this.activeIndex]?.selector.selectedAlias(); - } - - activeTabId(): string | undefined { - return this.tabs[this.activeIndex]?.id; - } - private syncFocusToActive(): void { for (let i = 0; i < this.tabs.length; i++) { const tab = this.tabs[i]!; tab.selector.focused = this.focused && i === this.activeIndex; } } - - /** Style a tab segment. The active tab is filled with the brand background - * (matching the AskUserQuestion dialog); inactive tabs are muted. Both have - * the same visible width so switching never shifts the layout. */ - private styleTab(label: string, isActive: boolean): string { - const cell = ` ${label} `; - return isActive - ? currentTheme.bg('selectionBg', currentTheme.boldFg('inverseText', cell)) - : currentTheme.fg('textMuted', cell); - } - - private renderTabStrip(width: number): string { - const segments: string[] = []; - for (let i = 0; i < this.tabs.length; i++) { - const tab = this.tabs[i]!; - segments.push(this.styleTab(tab.label, i === this.activeIndex)); - } - - // If everything fits with a leading space, show the whole strip. The - // provider-switch hint lives in the inner selector's hint line, not here. - const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); - if (1 + totalSegmentWidth <= width) { - return ' ' + segments.join(' '); - } - - // Scrolling needed. Find the widest window that contains activeIndex. - const segmentWidths = segments.map((s) => visibleWidth(s)); - let start = this.activeIndex; - let end = this.activeIndex + 1; - let contentWidth = segmentWidths[this.activeIndex]!; - - const fits = (s: number, e: number, cw: number): boolean => { - const needLeft = s > 0; - const needRight = e < segments.length; - const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); - return cw + frameWidth <= width; - }; - - while (true) { - const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; - const rightW = end < segments.length ? segmentWidths[end]! : Infinity; - if (leftW === Infinity && rightW === Infinity) break; - - if (leftW <= rightW) { - if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else { - break; - } - } else { - if (fits(start, end + 1, contentWidth + rightW)) { - contentWidth += rightW; - end++; - } else if (fits(start - 1, end, contentWidth + leftW)) { - contentWidth += leftW; - start--; - } else { - break; - } - } - } - - const hasLeft = start > 0; - const hasRight = end < segments.length; - let strip = hasLeft ? currentTheme.fg('textMuted', '< ') : ' '; - strip += segments.slice(start, end).join(' '); - if (hasRight) { - strip += currentTheme.fg('textMuted', ' >'); - } - return strip; - } } function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { - const normalizedModels = normalizeModelChoices(opts.models).models; - const entries = Object.entries(normalizedModels); + const entries = Object.entries(opts.models); const providerIds: string[] = []; const seen = new Set<string>(); for (const [, model] of entries) { @@ -251,7 +157,7 @@ function buildTabs(opts: TabbedModelSelectorOptions): readonly ModelTab[] { { id: ALL_TAB_ID, label: ALL_TAB_LABEL, - selector: makeSelector(opts, normalizedModels), + selector: makeSelector(opts, opts.models), }, ]; for (const providerId of providerIds) { @@ -277,11 +183,15 @@ function makeSelector( const inner: ModelSelectorOptions = { models: subset, currentValue: opts.currentValue, - selectedValue, - currentEffort: opts.currentEffort, + ...(selectedValue !== undefined ? { selectedValue } : {}), + currentThinkingEffort: opts.currentThinkingEffort, + title: opts.title, searchable: true, providerSwitchHint: true, + warning: opts.warning, + thinkingControl: opts.thinkingControl, onSelect: opts.onSelect, + onSessionOnlySelect: opts.onSessionOnlySelect, onCancel: opts.onCancel, }; return new ModelSelectorComponent(inner); diff --git a/apps/pythinker-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/pythinker-code/src/tui/components/dialogs/task-output-viewer.ts index 74ca5328..af5387f6 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/task-output-viewer.ts @@ -17,11 +17,12 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@pymodel/pythinker-code-sdk'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; const ELLIPSIS = '…'; @@ -32,7 +33,7 @@ export interface TaskOutputViewerProps { readonly onClose: () => void; } -const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { +export const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { running: 'running', completed: 'completed', failed: 'failed', @@ -41,7 +42,7 @@ const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { lost: 'lost', }; -function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { +export function statusColor(status: BackgroundTaskStatus): 'success' | 'textMuted' | 'error' { switch (status) { case 'running': return 'success'; @@ -104,7 +105,7 @@ export class TaskOutputViewer extends Container implements Focusable { } private splitOutput(output: string): string[] { - return (output.length > 0 ? output : '[no output captured]').split('\n'); + return (output.length > 0 ? sanitizeShellOutput(output) : '[no output captured]').split('\n'); } // ── input ────────────────────────────────────────────────────────── @@ -125,11 +126,20 @@ export class TaskOutputViewer extends Container implements Focusable { this.scrollBy(1); return; } - if (matchesKey(data, Key.pageUp) || k === ' ' || matchesKey(data, Key.ctrl('b'))) { + if ( + matchesKey(data, Key.pageUp) || + matchesKey(data, Key.ctrl('u')) || + k === ' ' || + data === '\u0002' /* C-b */ + ) { this.scrollBy(-Math.max(1, visible - 1)); return; } - if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl('f'))) { + if ( + matchesKey(data, Key.pageDown) || + matchesKey(data, Key.ctrl('d')) || + data === '\u0006' /* C-f */ + ) { this.scrollBy(Math.max(1, visible - 1)); return; } @@ -240,7 +250,7 @@ export class TaskOutputViewer extends Container implements Focusable { ); const keys = `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + + `${key('PgUp/PgDn/Ctrl+U/D')} ${dim('page')} ` + `${key('g/G')} ${dim('top/bot')} ` + `${key('Q/Esc')} ${dim('cancel')}`; const left = ` ${keys}`; diff --git a/apps/pythinker-code/src/tui/components/dialogs/tasks-browser.ts b/apps/pythinker-code/src/tui/components/dialogs/tasks-browser.ts index e1a9574b..1c70a5f6 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/tasks-browser.ts @@ -21,19 +21,13 @@ import { truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@pymodel/pythinker-code-sdk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; -import { combinedBindingHint, formatBindingKeys } from '#/tui/components/dialogs/choice-picker'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; +import { sanitizeShellOutput } from '#/tui/utils/shell-output'; const ELLIPSIS = '…'; @@ -46,7 +40,7 @@ export interface TasksBrowserProps { readonly tailOutput: string | undefined; readonly tailLoading: boolean; readonly flashMessage: string | undefined; - readonly onSelect: (taskId: string | undefined) => void; + readonly onSelect: (taskId: string) => void; readonly onToggleFilter: () => void; readonly onRefresh: () => void; readonly onCancel: () => void; @@ -137,8 +131,13 @@ function visibleTasks( tasks: readonly BackgroundTaskInfo[], filter: TasksFilter, ): BackgroundTaskInfo[] { - if (filter === 'all') return [...tasks]; - return tasks.filter((t) => !isTerminal(t.status)); + // The /tasks panel is for background task management. Foreground tasks + // (detached === false) are shown in the main transcript instead, and only + // appear here after being detached via Ctrl+B. `detached !== false` keeps + // reconcile ghosts whose `detached` field may be undefined. + const backgroundOnly = tasks.filter((t) => t.detached !== false); + if (filter === 'all') return [...backgroundOnly]; + return backgroundOnly.filter((t) => !isTerminal(t.status)); } function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { @@ -186,8 +185,6 @@ export class TasksBrowserApp extends Container implements Focusable { private listScroll = 0; private pendingStopTaskId: string | undefined = undefined; private pendingStopTimer: NodeJS.Timeout | undefined = undefined; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver(this.bindings); constructor(props: TasksBrowserProps, terminal: Terminal) { super(); @@ -200,40 +197,30 @@ export class TasksBrowserApp extends Container implements Focusable { setProps(next: TasksBrowserProps): void { this.props = next; this.sortedVisible = visibleTasks(next.tasks, next.filter).toSorted(compareTasks); - // Report the reconciled selection back so the controller can drop a - // filtered-out task id and stop loading its stale tail output. - const selectedTaskId = this.syncSelectionFromProps(); + this.syncSelectionFromProps(); if (this.pendingStopTaskId !== undefined) { const task = next.tasks.find((t) => t.taskId === this.pendingStopTaskId); if (task === undefined || isTerminal(task.status)) this.clearPendingStop(); } this.invalidate(); - if (selectedTaskId !== next.selectedTaskId) next.onSelect(selectedTaskId); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - this.keybindings = new KeybindingResolver(bindings); - } - - /** Returns the task id the list settles on (undefined when nothing is visible). */ - private syncSelectionFromProps(): string | undefined { + private syncSelectionFromProps(): void { if (this.sortedVisible.length === 0) { this.selectedIndex = 0; this.listScroll = 0; - return undefined; + return; } if (this.props.selectedTaskId !== undefined) { const idx = this.sortedVisible.findIndex((t) => t.taskId === this.props.selectedTaskId); if (idx !== -1) { this.selectedIndex = idx; - return this.props.selectedTaskId; + return; } } if (this.selectedIndex >= this.sortedVisible.length) { this.selectedIndex = this.sortedVisible.length - 1; } - return this.sortedVisible[this.selectedIndex]?.taskId; } private clearPendingStop(): void { @@ -265,24 +252,24 @@ export class TasksBrowserApp extends Container implements Focusable { return; } - const handlers = { - 'select:previous': () => this.moveSelection(-1), - 'select:next': () => this.moveSelection(1), - 'select:accept': () => { - const task = this.sortedVisible[this.selectedIndex]; - if (task) this.props.onOpenOutput(task.taskId); - }, - 'select:cancel': () => this.props.onCancel(), - } as const; - if ( - this.keybindings.dispatch(data, ['Select'], handlers) || - this.keybindings.dispatchKeyId(data, ['Select'], handlers) - ) return; - - if (k === 'q' || k === 'Q') { + if (matchesKey(data, Key.escape) || k === 'q' || k === 'Q') { this.props.onCancel(); return; } + if (matchesKey(data, Key.up) || k === 'k') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.emitSelect(); + this.invalidate(); + return; + } + if (matchesKey(data, Key.down) || k === 'j') { + if (this.sortedVisible.length === 0) return; + this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1); + this.emitSelect(); + this.invalidate(); + return; + } if (matchesKey(data, Key.tab) || k === '\t') { this.props.onToggleFilter(); return; @@ -306,20 +293,13 @@ export class TasksBrowserApp extends Container implements Focusable { this.invalidate(); return; } - if (k === 'o' || k === 'O') { + if (k === 'o' || k === 'O' || matchesKey(data, Key.enter)) { const task = this.sortedVisible[this.selectedIndex]; if (task) this.props.onOpenOutput(task.taskId); return; } } - private moveSelection(delta: -1 | 1): void { - if (this.sortedVisible.length === 0) return; - this.selectedIndex = Math.max(0, Math.min(this.sortedVisible.length - 1, this.selectedIndex + delta)); - this.emitSelect(); - this.invalidate(); - } - /** * Render the entire screen as `terminal.rows` lines of `width` cols. * Layout: header(1) + body(rows-2) + footer(1). @@ -359,7 +339,11 @@ export class TasksBrowserApp extends Container implements Focusable { 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); - const counts = countByStatus(this.props.tasks); + // Count only the tasks actually listed (background tasks after the + // foreground-task filter), so a foreground-only session doesn't read + // "1 running / 1 total" above an empty list. + const visible = visibleTasks(this.props.tasks, this.props.filter); + const counts = countByStatus(visible); const countSegments: string[] = []; if (counts.running > 0) countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); @@ -369,7 +353,7 @@ export class TasksBrowserApp extends Container implements Focusable { countSegments.push( currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = currentTheme.fg('textMuted', ` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); @@ -387,33 +371,14 @@ export class TasksBrowserApp extends Container implements Focusable { return fitExactly(line, width); } - const navigation = combinedBindingHint( - keybindingDisplayText(this.bindings, 'Select', 'select:previous'), - keybindingDisplayText(this.bindings, 'Select', 'select:next'), - 'select', - ); - const accept = keybindingDisplayText(this.bindings, 'Select', 'select:accept'); - const cancel = keybindingDisplayText(this.bindings, 'Select', 'select:cancel'); - const acceptKeys = [ - ...(accept === undefined ? [] : formatBindingKeys(accept).split(' / ')).filter( - (binding) => binding.toLowerCase() !== 'o', - ), - 'O', - ].join('/'); - const cancelKeys = [ - 'Q', - ...(cancel === undefined ? [] : formatBindingKeys(cancel).split(' / ')).filter( - (binding) => binding.toLowerCase() !== 'q', - ), - ].join('/'); const parts = [ - navigation === undefined ? undefined : ` ${key(formatBindingKeys(navigation.split(' ')[0] ?? ''))} ${dim(navigation.slice(navigation.indexOf(' ') + 1))}`, - `${key(acceptKeys)} ${dim('output')}`, + ` ${key('↑↓')} ${dim('select')}`, + `${key('Enter/O')} ${dim('output')}`, `${key('S')} ${dim('stop')}`, `${key('R')} ${dim('refresh')}`, `${key('Tab')} ${dim('filter')}`, - `${key(cancelKeys)} ${dim('cancel')} `, - ].filter((part): part is string => part !== undefined); + `${key('Q/Esc')} ${dim('cancel')} `, + ]; const left = parts.join(' '); const flash = this.props.flashMessage; if (flash !== undefined && flash.length > 0) { @@ -552,9 +517,14 @@ export class TasksBrowserApp extends Container implements Focusable { // ── right: detail + preview stack ──────────────────────────────────── private renderRightStack(width: number, height: number): string[] { - // Detail gets ~8 rows (or 40% of body, whichever is larger). Preview - // takes the rest. Both rendered as separate frames stacked vertically. - const detailHeight = Math.max(8, Math.min(Math.floor(height * 0.4), height - 5)); + // Detail wants ~10 rows (or 40% of body, whichever is larger) — agent tasks + // carry Task ID / Status / Description / Agent ID / Agent type / Model / + // Effort / Time. Clamp it so the preview frame keeps its borders plus one + // content row even near the minimum terminal height. + const detailHeight = Math.min( + Math.max(10, Math.min(Math.floor(height * 0.4), height - 5)), + Math.max(3, height - 3), + ); const previewHeight = height - detailHeight; return [ ...this.renderDetailFrame(width, detailHeight), @@ -589,6 +559,12 @@ export class TasksBrowserApp extends Container implements Focusable { if (task.kind === 'agent' && task.subagentType !== undefined) { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } + if (task.kind === 'agent' && task.model !== undefined) { + lines.push(`${label('Model:')}${value(task.model)}`); + } + if (task.kind === 'agent' && task.thinkingEffort !== undefined) { + lines.push(`${label('Effort:')}${value(task.thinkingEffort)}`); + } if (task.kind === 'question') { lines.push(`${label('Questions:')}${currentTheme.fg('textMuted', String(task.questionCount))}`); if (task.toolCallId !== undefined) { @@ -628,7 +604,7 @@ export class TasksBrowserApp extends Container implements Focusable { if (this.props.tailLoading) body = '[loading…]'; else if (this.props.tailOutput === undefined || this.props.tailOutput.length === 0) body = '[no output captured]'; - else body = this.props.tailOutput; + else body = sanitizeShellOutput(this.props.tailOutput); const rawLines = body.split('\n'); const tailLines = rawLines.slice(-innerHeight); diff --git a/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts b/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts new file mode 100644 index 00000000..20179074 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/dialogs/trust-prompt.ts @@ -0,0 +1,140 @@ +import { + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type Focusable, +} from '@pymodel/pi-tui'; + +import type { WorkspaceTrustMcpServerInfo } from '@pymodel/pythinker-code-sdk'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type TrustPromptChoice = 'trust' | 'distrust'; + +export interface TrustPromptOptions { + readonly workDir: string; + /** Project-level MCP servers that trusting would enable; may be empty. */ + readonly gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[]; + /** Esc resolves to 'distrust' as well. */ + readonly onSelect: (choice: TrustPromptChoice) => void; +} + +interface TrustPromptOption { + readonly value: TrustPromptChoice; + readonly label: string; + readonly description: string; +} + +const OPTIONS: readonly TrustPromptOption[] = [ + { + value: 'trust', + label: 'Trust this folder', + description: 'Enable project MCP servers. Remembered for this folder.', + }, + { + value: 'distrust', + label: "Don't trust", + description: 'Exit Pythinker Code. Asked again next launch.', + }, +]; + +export class TrustPromptComponent implements Component, Focusable { + focused = false; + private selectedIndex = 1; + + constructor(private readonly opts: TrustPromptOptions) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onSelect('distrust'); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(OPTIONS[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ' Trust this folder?'), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'), + '', + ...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map( + (line) => ` ${currentTheme.fg('textStrong', line)}`, + ), + '', + ]; + + const notice = + 'Project-level MCP servers are disabled until you explicitly choose Trust. Trust starts the listed project MCP targets and remembers this folder.'; + for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + if (this.opts.gatedMcpServers.length > 0) { + lines.push(` ${currentTheme.fg('warning', 'Project MCP targets:')}`); + for (const server of this.opts.gatedMcpServers) { + const details = formatMcpTarget(server); + for (const line of wrapTextWithAnsi(details, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('warning', line)}`); + } + } + } + lines.push(''); + + for (let i = 0; i < OPTIONS.length; i += 1) { + const option = OPTIONS[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const label = selected + ? currentTheme.boldFg('primary', option.label) + : currentTheme.fg('text', option.label); + lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label); + for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} + +function formatMcpTarget(server: WorkspaceTrustMcpServerInfo): string { + if (server.transport === 'stdio') { + const args = server.args === undefined ? '' : ` args=${JSON.stringify(server.args)}`; + const cwd = server.cwd === undefined ? '' : ` cwd=${server.cwd}`; + return sanitizeForDisplay(`${server.name} (stdio): command=${server.command ?? ''}${args}${cwd}`); + } + return sanitizeForDisplay(`${server.name} (${server.transport}): url=${server.url ?? ''}`); +} + +/** + * Drops C0/C1 control characters (including ESC) from workspace-supplied text: + * the trust prompt renders before the workspace is trusted, so a planted + * `.mcp.json` must not inject terminal control sequences into it. + */ +function sanitizeForDisplay(value: string): string { + let result = ''; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) continue; + result += char; + } + return result; +} diff --git a/apps/pythinker-code/src/tui/components/dialogs/undo-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/undo-selector.ts index a5cd80d7..09e1c2ae 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/undo-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/undo-selector.ts @@ -2,23 +2,13 @@ import { Container, Key, matchesKey, - parseKey, truncateToWidth, visibleWidth, type Focusable, -} from '@earendil-works/pi-tui'; -import type { PartialCompactionDirection } from '@pymodel/pythinker-code-sdk'; +} from '@pymodel/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; -import { - defaultKeybindings, - keybindingDisplayText, - KeybindingResolver, - type KeybindingHandlers, - type ParsedKeybinding, -} from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; -import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; const MAX_VISIBLE_CHOICES = 5; @@ -26,7 +16,7 @@ const PREFERRED_SELECTED_OFFSET = 2; export interface UndoChoice { readonly id: string; - readonly count?: number; + readonly count: number; readonly input: string; readonly label: string; } @@ -34,10 +24,6 @@ export interface UndoChoice { export interface UndoSelectorOptions { readonly choices: readonly UndoChoice[]; readonly onSelect: (choice: UndoChoice) => void; - readonly onSummarize: ( - choice: UndoChoice, - direction: PartialCompactionDirection, - ) => void; readonly onCancel: () => void; } @@ -46,8 +32,6 @@ export class UndoSelectorComponent extends Container implements Focusable { private readonly opts: UndoSelectorOptions; private readonly list: SearchableList<UndoChoice>; private submitted = false; - private bindings = defaultKeybindings(); - private keybindings = new KeybindingResolver([]); constructor(opts: UndoSelectorOptions) { super(); @@ -57,86 +41,36 @@ export class UndoSelectorComponent extends Container implements Focusable { toSearchText: (choice) => choice.label, initialIndex: Math.max(0, opts.choices.length - 1), }); - this.setKeybindings(this.bindings); - } - - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.bindings = bindings; - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - const actions = new Set([ - 'messageSelector:up', - 'messageSelector:down', - 'messageSelector:top', - 'messageSelector:bottom', - 'messageSelector:select', - 'confirm:no', - ]); - this.keybindings = new KeybindingResolver( - [...winners.values()].filter( - (binding) => binding.action !== null && actions.has(binding.action), - ), - ); } handleInput(data: string): void { if (this.submitted) return; - const handlers: KeybindingHandlers = { - 'messageSelector:up': () => this.list.moveUp(), - 'messageSelector:down': () => this.list.moveDown(), - 'messageSelector:top': () => this.list.moveToStart(), - 'messageSelector:bottom': () => this.list.moveToEnd(), - 'messageSelector:select': () => this.select(), - 'confirm:no': () => this.opts.onCancel(), - }; - const keyId = parseKey(data); - if ( - keyId?.includes('+') === true - ? this.keybindings.dispatch(data, ['MessageSelector', 'Confirmation'], handlers) - : this.keybindings.dispatchKeyId(keyId ?? data, ['MessageSelector', 'Confirmation'], handlers) - ) { + if (matchesKey(data, Key.escape)) { + this.opts.onCancel(); return; } - if (matchesKey(data, Key.pageUp)) { - this.list.pageUp(); - return; - } - if (matchesKey(data, Key.pageDown)) { - this.list.pageDown(); + if (this.list.handleKey(data)) { return; } - const action = printableChar(data)?.toLowerCase(); - if (action === 's' || action === 'u') { + if (matchesKey(data, Key.enter)) { const selected = this.list.selected(); - if (selected?.count !== undefined) { + if (selected !== undefined) { this.submitted = true; - this.opts.onSummarize(selected, action === 's' ? 'from' : 'up_to'); + this.opts.onSelect(selected); } - return; } - } override render(width: number): string[] { const view = this.list.view(); - const canSummarize = this.list.selected()?.count !== undefined; - const hintParts = [ - messageSelectorNavigationHint(this.bindings), - ...(canSummarize - ? ['S summarize from', 'U summarize up to'] - : ['S/U unavailable for code-only point']), - bindingHint(this.bindings, 'MessageSelector', 'messageSelector:select', 'undo'), - bindingHint(this.bindings, 'Confirmation', 'confirm:no', 'cancel'), - ].filter((part): part is string => part !== undefined); + const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), - currentTheme.boldFg('primary', ' Select a conversation point'), + currentTheme.boldFg('primary', ' Select messages to undo'), currentTheme.fg('textMuted', ' ' + hintParts.join(' · ')), '', ]; @@ -161,7 +95,8 @@ export class UndoSelectorComponent extends Container implements Focusable { } } - lines.push('', currentTheme.fg('primary', '─'.repeat(width))); + lines.push(''); + lines.push(currentTheme.fg('primary', '─'.repeat(width))); return lines.map((line) => truncateToWidth(line, width)); } @@ -182,29 +117,4 @@ export class UndoSelectorComponent extends Container implements Focusable { : currentTheme.fg(token, label); return line; } - - private select(): void { - const selected = this.list.selected(); - if (selected !== undefined) { - this.submitted = true; - this.opts.onSelect(selected); - } - } -} - -function bindingHint( - bindings: readonly ParsedKeybinding[], - context: 'MessageSelector' | 'Confirmation', - action: 'messageSelector:select' | 'confirm:no', - label: string, -): string | undefined { - const keys = keybindingDisplayText(bindings, context, action); - return keys === undefined ? undefined : `${keys} ${label}`; -} - -function messageSelectorNavigationHint(bindings: readonly ParsedKeybinding[]): string | undefined { - const up = keybindingDisplayText(bindings, 'MessageSelector', 'messageSelector:up'); - const down = keybindingDisplayText(bindings, 'MessageSelector', 'messageSelector:down'); - if (up === undefined && down === undefined) return undefined; - return `${[up, down].filter((key): key is string => key !== undefined).join(' / ')} navigate`; } diff --git a/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts b/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts index 40b823e6..35055e08 100644 --- a/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts +++ b/apps/pythinker-code/src/tui/components/dialogs/update-preference-selector.ts @@ -4,7 +4,7 @@ const UPDATE_PREFERENCE_OPTIONS: readonly ChoiceOption[] = [ { value: 'on', label: 'On', - description: 'Update automatically in the background.', + description: 'Install new versions in the background.', }, { value: 'off', diff --git a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts index 55faca1f..ea3a0fbe 100644 --- a/apps/pythinker-code/src/tui/components/editor/custom-editor.ts +++ b/apps/pythinker-code/src/tui/components/editor/custom-editor.ts @@ -8,32 +8,17 @@ import { matchesKey, Key, SelectList, - truncateToWidth, visibleWidth, type SelectItem, type TUI, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { createPythinkerEditorTheme, currentTheme } from '#/tui/theme'; -import { applyBuffer, readBuffer } from '#/tui/editor/vim/editor-bridge'; -import { - createRainbowPainter, - isRainbowColorActive, -} from '#/tui/easter-eggs/rainbow-colors'; -import { - applyKey, - createInitialPersistent, - createInitialState, -} from '#/tui/editor/vim/state-machine'; -import type { PersistentState, VimMode, VimState } from '#/tui/editor/vim/types'; -import { - defaultKeybindings, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; -import { isPrintableChar, printableChar } from '#/tui/utils/printable-key'; +import { currentTheme } from '#/tui/theme'; +import { createEditorTheme } from '#/tui/theme/pi-tui-theme'; +import { printableChar } from '#/tui/utils/printable-key'; -import { findSlashAutocompleteContext, getSlashHighlightRanges } from './slash-autocomplete-context'; +import { extractAtPrefix } from './file-mention-provider'; +import { findInlineSkillTokens } from '../../utils/inline-skill-tokens'; import { WrappingSelectList } from './wrapping-select-list'; // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences @@ -42,40 +27,6 @@ const ANSI_SGR = /\u001B\[[0-9;]*m/g; const PASTE_MARKER_RE = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g; const BRACKET_PASTE_START = '\u001B[200~'; const BRACKET_PASTE_END = '\u001B[201~'; -const CSI_PREFIX = '\u001B['; -const SS3_PREFIX = '\u001BO'; - -function isLegacyModifiedInput(data: string): boolean { - // Legacy terminals send Ctrl/Alt combos as C0 control bytes or ESC+char - // sequences, which never reach the Kitty CSI-u decoder. - if (data.length === 1) { - const code = data.codePointAt(0) ?? 0; - return code < 0x20 - && data !== '\t' - && data !== '\n' - && data !== '\r' - && data !== '\u001B'; - } - return data.startsWith('\u001B') - && !data.startsWith(CSI_PREFIX) - && !data.startsWith(SS3_PREFIX) - && isPrintableChar(data.slice(1)); -} - -function shouldBypassVim(data: string, key: string): boolean { - // Decoded CSI-u printables and bare Escape are vim keys, not terminal controls. - if (isPrintableChar(key) || data === '\u001B') return false; - // Legacy control and Alt sequences belong to app keybindings, as their - // Kitty CSI-u equivalents already do. - if (isLegacyModifiedInput(data)) return true; - // pi-tui exclusively owns bracketed-paste markers and their payload registry. - if (data.startsWith(BRACKET_PASTE_START)) return true; - // Keep paste disjoint so removing its branch cannot fall through to this CSI branch. - return ( - !data.startsWith(BRACKET_PASTE_START) && - (data.startsWith(CSI_PREFIX) || data.startsWith(SS3_PREFIX)) - ); -} // Kitty keyboard protocol CSI-u sequence: ESC [ keycode ; modifier[:eventType] u. // We intentionally match only the simple two-field form — enough to rewrite @@ -90,19 +41,17 @@ const SHIFT_BIT = 1; interface AutocompleteInternals { cancelAutocomplete(): void; - requestAutocomplete?(options: { force: boolean; explicitTab: boolean }): void; readonly autocompleteAbort?: AbortController; readonly autocompleteDebounceTimer?: ReturnType<typeof setTimeout>; - readonly autocompletePrefix?: string; - readonly autocompleteList?: { getSelectedItem(): SelectItem | undefined }; } interface AutocompleteListFactoryInternals { createAutocompleteList?: (prefix: string, items: SelectItem[]) => SelectList; } -export interface CustomEditorOptions { - readonly vimMode?: boolean; +interface AutocompleteTriggerInternals { + tryTriggerAutocomplete: (explicitTab?: boolean) => void; + requestAutocomplete: (options: { force: boolean; explicitTab: boolean }) => void; } // Mirror pi-tui's private SLASH_COMMAND_SELECT_LIST_LAYOUT @@ -165,85 +114,28 @@ function stripSgr(s: string): string { return s.replace(ANSI_SGR, ''); } -function findCursorMarkerRange( - line: string, -): { rawStart: number; rawEnd: number; visibleStart: number; currentChar: string | undefined } | null { - const rawStart = line.indexOf('\u001B[7m'); - if (rawStart < 0) return null; - const rawEnd = line.indexOf('\u001B[0m', rawStart); - if (rawEnd < 0) return null; - const visibleStart = stripSgr(line.slice(0, rawStart)).length; - const visible = stripSgr(line); - return { - rawStart, - rawEnd: rawEnd + '\u001B[0m'.length, - visibleStart, - currentChar: visible[visibleStart], - }; -} - -export function buildAutocompleteGhostSuffix(prefix: string, item: SelectItem): string | null { - if (prefix.startsWith('/')) { - const typed = prefix.slice(1); - if (!item.value.startsWith(typed)) return null; - return `${item.value.slice(typed.length)} `; - } - if (!item.value.startsWith(prefix)) return null; - return item.value.slice(prefix.length); -} - -export function insertAutocompleteGhost(line: string, ghostText: string): string | undefined { - if (ghostText.length === 0) return undefined; - const cursor = findCursorMarkerRange(line); - if (cursor === null) return undefined; - if (cursor.currentChar !== undefined && cursor.currentChar !== ' ' && cursor.currentChar !== '\t') { - return undefined; - } - - const visible = stripSgr(line); - const insertStartVisible = cursor.visibleStart + 1; - let insertEndVisible = insertStartVisible; - while (insertEndVisible < visible.length) { - const ch = visible[insertEndVisible]; - if (ch !== ' ' && ch !== '\t') break; - insertEndVisible += 1; - } - - const availableWidth = insertEndVisible - insertStartVisible; - const ghostPlain = stripSgr(truncateToWidth(ghostText, availableWidth + 1, '')); - const ghostWidth = visibleWidth(ghostPlain); - if (ghostWidth <= 0) return undefined; - - const rawEnd = Math.max( - mapVisibleIdxToRaw(line, cursor.visibleStart + ghostWidth), - cursor.rawEnd, - ); - const firstGhostChar = ghostPlain[0] ?? ''; - const rest = ghostPlain.slice(firstGhostChar.length); - const ghost = rest.length === 0 ? '' : currentTheme.fg('textMuted', rest); - return ( - line.slice(0, cursor.rawStart) + - `\u001B[7m${firstGhostChar}\u001B[0m` + - ghost + - line.slice(rawEnd) - ); +interface CustomEditorOptions { + disablePasteBurst?: boolean; } export class CustomEditor extends Editor { public onEscape?: () => void; + /** + * Fired for every input that is not a lone Escape. Used to disarm a pending + * double-Esc so only two consecutive Escape presses trigger the shortcut. + */ + public onNonEscapeInput?: () => void; public onCtrlD?: () => void; public onCtrlC?: () => void; - public onRedraw?: () => void; public onToggleToolExpand?: () => void; public onOpenExternalEditor?: () => void; - public onSearchHistory?: () => void; - public onMessageActions?: () => void; public onCtrlS?: () => void; - public onCycleEffort?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; + /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ + public onToggleTodoExpand?: () => boolean; public onUndo?: () => void; - public onInsertNewline?: () => void; public onTextPaste?: () => void; - public onCommand?: (command: string) => void; /** * Called when ↑ is pressed in an empty editor. Return `true` to consume * the key (e.g. recalled a queued message); return `false` to fall @@ -251,6 +143,11 @@ export class CustomEditor extends Editor { */ public onUpArrowEmpty?: () => boolean; public onDownArrowEmpty?: () => boolean; + public onShiftTab?: () => void; + /** 'bash' when entering a `!` shell command. The `!` is never part of the + * text buffer — it is a separate mode + prompt symbol (see handleInput). */ + public inputMode: 'prompt' | 'bash' = 'prompt'; + public onInputModeChange?: (mode: 'prompt' | 'bash') => void; public connectedAbove = false; public borderHighlighted = false; /** @@ -258,31 +155,39 @@ export class CustomEditor extends Editor { * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return * `true` to consume the key (image was read and handled); return * `false` to let the key fall through to the normal paste path. - * The callback may be async; pi-tui awaits it before dispatching - * the next keystroke. + * The callback may be async; CustomEditor queues subsequent keystrokes until + * it settles before dispatching them. */ public onPasteImage?: () => Promise<boolean>; private consumingPaste = false; private consumeBuffer = ''; - private vimState?: VimState; - private vimPersistent?: PersistentState; - private keybindings = new KeybindingResolver(defaultKeybindings()); + /** Serialize paste callbacks so Enter/typing cannot overtake an image paste. */ + private pasteInFlight = false; + private readonly pasteInputQueue: string[] = []; + private argumentHints: ReadonlyMap<string, string> = new Map(); + private skillCommandNames: ReadonlySet<string> = new Set(); + + setArgumentHints(hints: ReadonlyMap<string, string>): void { + this.argumentHints = hints; + } + + setSkillCommandNames(names: ReadonlySet<string>): void { + this.skillCommandNames = names; + } - constructor(tui: TUI, options?: CustomEditorOptions) { + constructor(tui: TUI, options: CustomEditorOptions = {}) { // paddingX: 4 reserves column 0 for the left vertical border (│), // column 1 as a single space between border and prompt, column 2 for - // the `›` prompt token, and column 3 as the space between prompt and + // the `>` prompt token, and column 3 as the space between prompt and // content. The right side mirrors with 3 padding columns and the right // border at the last column. - const theme = createPythinkerEditorTheme(); - const slashSelectListTheme = { - ...theme.selectList, - selectedText: (text: string) => currentTheme.boldFg('primary', text), - }; - super(tui, theme, { paddingX: 4 }); - - this.setVimMode(options?.vimMode === true); + const theme = createEditorTheme(); + super(tui, theme, { + paddingX: 4, + disablePasteBurst: options.disablePasteBurst, + inlineSlashTrigger: true, + }); // pi-tui keeps `createAutocompleteList` private; shadow it with an // instance property so slash command menus render descriptions wrapped @@ -296,12 +201,33 @@ export class CustomEditor extends Editor { return new WrappingSelectList( items, this.getAutocompleteMaxVisible(), - slashSelectListTheme, + theme.selectList, SLASH_COMMAND_SELECT_LIST_LAYOUT, ); } return new SelectList(items, this.getAutocompleteMaxVisible(), theme.selectList); }; + + // pi-tui auto-triggers autocomplete for `/` (and letters in a slash + // context) with force:false, which routes through the slash-command + // branch. In bash mode `/` is a path separator, not a command prefix, so + // shadow the trigger to request file path completion (force:true) instead. + // Prompt mode keeps the original force:false behaviour. `tryTriggerAutocomplete` + // is private in pi-tui's typings but a plain prototype method at runtime. + const triggerInternals = this as unknown as AutocompleteTriggerInternals; + triggerInternals.tryTriggerAutocomplete = (explicitTab = false) => { + triggerInternals.requestAutocomplete({ force: this.inputMode === 'bash', explicitTab }); + }; + } + + override setDisablePasteBurst(disabled: boolean): void { + super.setDisablePasteBurst(disabled); + } + + public setInputMode(mode: 'prompt' | 'bash'): void { + if (this.inputMode === mode) return; + this.inputMode = mode; + this.onInputModeChange?.(mode); } private expandPasteMarkerAtCursor(): boolean { @@ -322,15 +248,9 @@ export class CustomEditor extends Editor { const text = this.getText(); const offset = lines.slice(0, line).reduce((sum, l) => sum + l.length + 1, 0) + start; const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); - // pi-tui >=0.80 clears the paste registry in setText(); preserve the - // other markers' contents so they stay expandable afterwards. - const internals = this as unknown as { pastes: Map<number, string>; pasteCounter: number }; - const savedPastes = new Map(internals.pastes); - const savedCounter = internals.pasteCounter; - savedPastes.delete(pasteId); - this.setText(newText); - for (const [id, paste] of savedPastes) internals.pastes.set(id, paste); - internals.pasteCounter = savedCounter; + // Keep the paste registry intact: the text still holds other live markers + // whose entries a plain setText would drop (upstream resets the registry). + this.setText(newText, { preservePasteRegistry: true }); return true; } return false; @@ -351,191 +271,117 @@ export class CustomEditor extends Editor { (this as unknown as AutocompleteInternals).cancelAutocomplete(); } - private hasMidPromptSlashContext(): boolean { - const { line, col } = this.getCursor(); - const currentLine = this.getLines()[line] ?? ''; - const context = findSlashAutocompleteContext(currentLine, col); - return context !== null && currentLine.slice(0, context.commandStart).trim().length > 0; - } - - private requestMidPromptSlashAutocomplete(explicitTab: boolean): boolean { - if (!this.hasMidPromptSlashContext()) return false; - const autocomplete = this as unknown as AutocompleteInternals; - autocomplete.requestAutocomplete?.({ force: false, explicitTab }); - return autocomplete.requestAutocomplete !== undefined; - } - override render(width: number): string[] { const lines = super.render(width); if (lines.length < 3) return lines; - const topBorderIdx = lines.findIndex(isHorizontalBorder); - const bottomBorderIdx = lines.findIndex( - (line, index) => index > topBorderIdx && isHorizontalBorder(line), - ); - if (topBorderIdx < 0 || bottomBorderIdx < 0) return lines; - - const autocompleteLines = lines.slice(bottomBorderIdx + 1); - const autocomplete = this as unknown as AutocompleteInternals; - const slashMenuOpen = - autocompleteLines.length > 0 && - autocomplete.autocompletePrefix?.startsWith('/') === true; - const firstContentIdx = topBorderIdx + 1; + const firstContentIdx = 1; + const isBash = this.inputMode === 'bash'; + const text = this.getText().trimStart(); + if (!isBash) { + // Paint the leading slash command on the first content line only, then + // inline skill tokens on every content line (multi-line prompts can + // reference skills anywhere). + const original = lines[firstContentIdx]; + if (original !== undefined) { + let highlighted = original; + let leadingRange: { start: number; end: number } | null = null; + if (text.startsWith('/')) { + leadingRange = leadingSlashTokenRange(stripSgr(original)); + const leading = highlightFirstSlashToken(original, 'primary'); + if (leading !== undefined) { + highlighted = leading; + } + } + const inline = highlightInlineSkillTokens( + highlighted, + this.skillCommandNames, + leadingRange, + 'primary', + ); + if (inline !== undefined) { + highlighted = inline; + } + if (highlighted !== original) { + lines[firstContentIdx] = highlighted; + } + } + for (let i = firstContentIdx + 1; i < lines.length - 1; i++) { + const original = lines[i]; + if (original === undefined) continue; + const inline = highlightInlineSkillTokens(original, this.skillCommandNames, null, 'primary'); + if (inline !== undefined) { + lines[i] = inline; + } + } + } + const hint = this.computeArgumentHint(); + if (hint !== undefined) { + const line = lines[firstContentIdx]; + if (line !== undefined) { + lines[firstContentIdx] = injectArgumentHint(line, hint, this.getText().length, width); + } + } const firstContent = lines[firstContentIdx]; if (firstContent !== undefined) { - const withPrompt = injectPromptSymbol(firstContent); + const withPrompt = injectPromptSymbol( + firstContent, + isBash ? '!' : '>', + isBash ? (s) => this.borderColor(s) : undefined, + ); if (withPrompt !== undefined) { lines[firstContentIdx] = withPrompt; } } - - let slashTokenColumn: number | undefined; - const cursorLineIdx = lines.findIndex((line) => line.includes('\u001B[7m')); - if (cursorLineIdx >= 0) { - const cursorLine = lines[cursorLineIdx]; - if (cursorLine !== undefined) { - slashTokenColumn = activeSlashTokenColumn(cursorLine); - const highlighted = highlightFirstSlashToken(cursorLine, 'textStrong'); - const decoratedLine = highlighted ?? cursorLine; - const autocomplete = this as unknown as AutocompleteInternals; - const selectedItem = autocomplete.autocompleteList?.getSelectedItem(); - const prefix = autocomplete.autocompletePrefix ?? ''; - const ghostSuffix = - selectedItem === undefined ? null : buildAutocompleteGhostSuffix(prefix, selectedItem); - const withGhost = - ghostSuffix === null ? undefined : insertAutocompleteGhost(decoratedLine, ghostSuffix); - lines[cursorLineIdx] = withGhost ?? decoratedLine; - } - } - // `this.borderColor` is pi-tui's per-render paint function. The host may // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and - // side bars through the same hook to stay in sync. Rainbow mode instead - // uses one stateful painter for the complete frame. - const paintBorder = isRainbowColorActive() - ? createRainbowPainter() - : (text: string) => this.borderColor(text); - const contentRows = lines.slice(firstContentIdx, bottomBorderIdx); - if (contentRows.length === 1 && !this.getText().includes('\n')) { - const compact = lines[firstContentIdx]; - const top = lines[topBorderIdx]; - const bottom = lines[bottomBorderIdx]; - if (compact === undefined || top === undefined || bottom === undefined) return lines; - // Compact mode keeps the top and bottom rules but drops the corners and - // side bars, so a single-line prompt reads as one open lane. - const composer = [ - horizontalRule(top, paintBorder), - compactPromptRow( - compact, - slashMenuOpen - ? (text) => currentTheme.fg('textStrong', text) - : paintBorder, - ), - decorateVimModeBorder( - horizontalRule(bottom, paintBorder), - this.vimState?.mode, - paintBorder, - ), - ]; - return slashMenuOpen - ? [ - ...composer, - ...renderSlashCommandMenu( - autocompleteLines, - width, - this.getPaddingX(), - // Compact mode removes two leading composer cells. Menu labels - // already start two cells after their selection marker. - Math.max(0, (slashTokenColumn ?? 4) - 4), - ), - ] - : [...composer, ...autocompleteLines]; - } - - const editorLines = slashMenuOpen - ? lines.slice(0, bottomBorderIdx + 1) - : lines; - const editor = wrapWithSideBorders(editorLines, paintBorder, { + // side bars through the same hook to stay in sync. + return wrapWithSideBorders(lines, (s) => this.borderColor(s), { connectedAbove: this.connectedAbove && !this.borderHighlighted, + label: isBash ? ` ${currentTheme.boldFg('shellMode', '! shell mode')} ` : undefined, }); - const bottomBorder = editor[bottomBorderIdx]; - if (bottomBorder !== undefined) { - editor[bottomBorderIdx] = decorateVimModeBorder( - bottomBorder, - this.vimState?.mode, - paintBorder, - ); - } - return slashMenuOpen - ? [ - ...editor, - ...renderSlashCommandMenu( - autocompleteLines, - width, - this.getPaddingX(), - // Boxed composers retain their rendered slash column. Menu labels - // already start two cells after their selection marker. - Math.max(0, (slashTokenColumn ?? 2) - 2), - ), - ] - : editor; - } - - isVimModeEnabled(): boolean { - return this.vimState !== undefined; - } - - /** Toggles vim ownership of editor input; state survives until toggled off. */ - setVimMode(enabled: boolean): void { - if (enabled) { - if (this.vimState !== undefined && this.vimPersistent !== undefined) return; - this.vimState = createInitialState(); - this.vimPersistent = createInitialPersistent(); - return; - } - if (this.vimState === undefined && this.vimPersistent === undefined) return; - this.vimState = undefined; - this.vimPersistent = undefined; } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.keybindings = new KeybindingResolver(bindings); + private computeArgumentHint(): string | undefined { + // Argument hints describe slash commands, which do not exist in bash mode. + if (this.inputMode === 'bash') return undefined; + const text = this.getText(); + const match = /^\/(\S+)( ?)$/.exec(text); + if (match === null) return undefined; + const cmd = match[1]; + const trailingSpace = match[2] ?? ''; + if (cmd === undefined) return undefined; + const hint = this.argumentHints.get(cmd); + if (hint === undefined) return undefined; + const { line, col } = this.getCursor(); + if (line !== 0) return undefined; + const currentLine = this.getLines()[0] ?? ''; + if (col !== currentLine.length) return undefined; + return trailingSpace.length > 0 ? hint : ` ${hint}`; } override handleInput(data: string): void { - // Normalize and drop key-release events before vim ownership is decided, - // so a release cannot fall through to vim or app keybinding handling. const normalized = normalizeCapsLockedCtrl(data); if (isKeyRelease(normalized)) { return; } - const key = printableChar(normalized); - if ( - this.vimState !== undefined && - this.vimPersistent !== undefined && - !shouldBypassVim(normalized, key) - ) { - const result = applyKey( - this.vimState, - this.vimPersistent, - readBuffer(this), - key, - ); - this.vimState = result.state; - this.vimPersistent = result.persistent; - if (result.handled) { - if (matchesKey(normalized, Key.escape) && this.hasAutocompleteActivity()) { - this.cancelAutocompleteActivity(); - } - applyBuffer(this, result.buffer); - return; - } - super.handleInput(normalized); - if (!this.hasAutocompleteActivity()) this.requestMidPromptSlashAutocomplete(false); + // Clipboard reads are asynchronous. Queue every key received while a + // paste callback is in flight and replay it once the callback settles + // (clipboard read + placeholder insert — compression and the daemon + // upload continue in the background off this path), so Enter cannot + // submit a draft that is still missing the pasted image. + if (this.pasteInFlight) { + this.pasteInputQueue.push(normalized); return; } + // Any input other than a lone Escape breaks a pending double-Esc sequence, + // so the shortcut only fires for two consecutive Escape presses. + if (!matchesKey(normalized, Key.escape)) { + this.onNonEscapeInput?.(); + } + // When a paste marker was just expanded, discard the trailing bracketed // paste data that the terminal sends alongside the Ctrl-V keystroke. if (this.consumingPaste) { @@ -556,43 +402,101 @@ export class CustomEditor extends Editor { return; } - const contexts = this.hasAutocompleteActivity() - ? (['Autocomplete', 'Chat'] as const) - : (['Chat'] as const); - if (this.keybindings.dispatch(normalized, contexts, { - 'autocomplete:accept': () => super.handleInput('\t'), - 'autocomplete:dismiss': () => this.cancelAutocompleteActivity(), - 'autocomplete:previous': () => super.handleInput('\u001B[A'), - 'autocomplete:next': () => super.handleInput('\u001B[B'), - 'app:interrupt': () => this.onCtrlC?.(), - 'app:exit': () => this.getText().length === 0 - ? this.onCtrlD?.() - : super.handleInput(normalized), - 'app:redraw': () => this.onRedraw?.(), - 'app:toggleTranscript': () => this.onToggleToolExpand?.(), - 'history:search': () => this.onSearchHistory?.(), - 'chat:historySearch': () => this.onSearchHistory?.(), - 'history:previous': () => super.handleInput('\u001B[A'), - 'history:next': () => super.handleInput('\u001B[B'), - 'chat:cancel': () => this.onEscape?.(), - // Deprecated action kept for schema back-compat; treat as thinkingToggle. - 'chat:cycleMode': () => this.onCycleEffort?.(), - 'chat:externalEditor': () => this.onOpenExternalEditor?.(), - 'chat:messageActions': () => this.onMessageActions?.(), - 'chat:modelPicker': () => this.onCommand?.('model'), - 'chat:submit': () => super.handleInput('\r'), - 'chat:stash': () => this.onCtrlS?.(), - 'chat:thinkingToggle': () => this.onCycleEffort?.(), - 'chat:undo': () => { - this.onUndo?.(); - super.handleInput('\u001F'); - }, - 'chat:newline': () => { - this.onInsertNewline?.(); - super.handleInput('\n'); - }, - 'chat:imagePaste': () => this.handlePasteKeybinding(normalized), - }, { onCommand: (command) => this.onCommand?.(command) })) { + // Paste image binding — platform-aware: + // Windows terminals reserve Ctrl-V for their own paste handling + // (e.g. Windows Terminal's Ctrl+V shortcut), so we listen for + // Alt-V there. Everywhere else Ctrl-V pastes. When the host + // reports no image available, we fall through to pi-tui's + // normal paste path so text from the clipboard still works. + const pasteKey = process.platform === 'win32' ? 'alt+v' : Key.ctrl('v'); + if (matchesKey(normalized, pasteKey)) { + if (this.expandPasteMarkerAtCursor()) { + return; + } + if (this.onPasteImage !== undefined) { + const handler = this.onPasteImage; + const pasteAsText = (): void => { + this.onTextPaste?.(); + super.handleInput.call(this, normalized); + }; + this.pasteInFlight = true; + void handler() + .then((handled) => { + if (!handled) pasteAsText(); + }) + .catch(() => { + // A rejecting image-paste handler must not leak an unhandled + // rejection (the CLI turns those into a silent exit) — treat it + // the same as "no image available" and fall back to text paste. + pasteAsText(); + }) + .finally(() => { + this.pasteInFlight = false; + this.flushPasteInputQueue(); + }); + return; + } + } + + if (matchesKey(normalized, Key.ctrl('d'))) { + if (this.getText().length === 0) { + this.onCtrlD?.(); + return; + } + } + + if (matchesKey(normalized, Key.ctrl('c'))) { + this.onCtrlC?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('g'))) { + this.onOpenExternalEditor?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('o'))) { + this.onToggleToolExpand?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('s'))) { + this.onCtrlS?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + + if (matchesKey(normalized, Key.ctrl('t'))) { + // Only consume the key when the todo list actually has overflow to + // expand/collapse; otherwise fall through to the editor default. + if (this.onToggleTodoExpand?.() === true) return; + } + + if (matchesKey(normalized, 'shift+tab')) { + this.onShiftTab?.(); + return; + } + + if (matchesKey(normalized, Key.ctrl('-'))) { + this.onUndo?.(); + } + + // Exit bash mode: Backspace/Escape on an empty `!` prompt returns to prompt + // mode. Because the `!` is not in the buffer, "deleting" it is really + // "delete on empty bash input". + if ( + this.inputMode === 'bash' && + this.getText().length === 0 && + (matchesKey(normalized, Key.escape) || matchesKey(normalized, Key.backspace)) + ) { + this.inputMode = 'prompt'; + this.onInputModeChange?.('prompt'); return; } @@ -618,64 +522,203 @@ export class CustomEditor extends Editor { return; } - if (matchesKey(normalized, Key.tab) && this.requestMidPromptSlashAutocomplete(true)) { + // Swallow Tab while the autocomplete dropdown is closed so it does not + // trigger pi-tui's built-in file completion. When the dropdown is open, + // fall through so pi-tui can still accept the selected item with Tab. + if (matchesKey(normalized, Key.tab) && !this.isShowingAutocomplete()) { + return; + } + + // Enter bash mode: typing `!` at the start of an empty prompt. The `!` is + // not inserted into the buffer — it becomes the mode + prompt symbol, so the + // cursor never has to skip over it and submit never has to strip it. + if ( + this.inputMode === 'prompt' && + printableChar(normalized) === '!' && + this.getText().length === 0 + ) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); return; } + const emptyPromptBeforeInput = this.inputMode === 'prompt' && this.getText().length === 0; super.handleInput(normalized); - if (!this.hasAutocompleteActivity()) this.requestMidPromptSlashAutocomplete(false); + + // Enter bash mode when `!...` is pasted into an empty prompt. The typed path + // above handles the single `!` keystroke; this catches bracketed / Ctrl-V + // pastes whose content starts with `!`. Strip the leading `!` so the buffer + // holds only the command, exactly like the typed path. + if (emptyPromptBeforeInput && this.inputMode === 'prompt' && this.getText().startsWith('!')) { + this.inputMode = 'bash'; + this.onInputModeChange?.('bash'); + this.setText(this.getText().slice(1)); + } + + this.reopenAutocompleteAfterInput(); + } + + private flushPasteInputQueue(): void { + if (this.pasteInFlight) return; + const next = this.pasteInputQueue.shift(); + if (next === undefined) return; + this.handleInput(next); + if (!this.pasteInFlight) this.flushPasteInputQueue(); } - private handlePasteKeybinding(data: string): void { - if (this.expandPasteMarkerAtCursor()) return; - if (this.onPasteImage === undefined) { - super.handleInput(data); + private reopenAutocompleteAfterInput(): void { + if (this.isShowingAutocomplete()) return; + const { line, col } = this.getCursor(); + const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? ''; + const editor = this as unknown as { + requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void; + }; + if (editor.requestAutocomplete === undefined) return; + const trigger = (): void => { + // Use force:false so slash-aware logic runs: commands with argument + // completions return their subcommands, commands without them return + // null. force:true would bypass the slash branch and fall through to + // path completion, wrongly popping up the file list. + editor.requestAutocomplete?.({ force: false, explicitTab: false }); + }; + + // Reopen path / argument completion right after a `/` is typed + // (e.g. `/add-dir /` or an `@dir/` mention). + if (textBeforeCursor.endsWith('/')) { + const isAtMention = extractAtPrefix(textBeforeCursor) !== null; + if (isAtMention) { + trigger(); + } else if (this.inputMode === 'bash') { + // In bash mode `/` is a path separator, not a slash command. A bare + // leading `/` is already handled by the tryTriggerAutocomplete shadow + // in the constructor; this branch covers the inline case (e.g. `ls /`, + // `cat /etc/`, `/add-dir/`) that pi-tui never auto-triggers. force:true + // is required so pi-tui's own slash-command handling is bypassed — + // force:false would let it pop up subcommand completions. + if (textBeforeCursor.trimStart() !== '/') { + editor.requestAutocomplete?.({ force: true, explicitTab: false }); + } + } else { + const isSlashArgument = textBeforeCursor.startsWith('/') && textBeforeCursor.includes(' '); + if (isSlashArgument) { + trigger(); + } + } return; } - const pasteAsText = (): void => { - this.onTextPaste?.(); - super.handleInput(data); - }; - void this.onPasteImage().then( - (handled) => { - if (!handled) pasteAsText(); - }, - () => { - pasteAsText(); - }, - ); + + // After accepting a slash command name via Tab, pi-tui inserts a trailing + // space and closes the menu without triggering argument completion. Reopen + // it so subcommands (e.g. `/goal ` → status/pause/…) show immediately. + // Skipped in bash mode: `/` is a path there, and force:false would let + // pi-tui's own slash-command handling pop up subcommand completions. + if ( + this.inputMode !== 'bash' && + textBeforeCursor.endsWith(' ') && + textBeforeCursor.startsWith('/') && + textBeforeCursor.includes(' ') + ) { + trigger(); + } + } +} + +/** + * Return a copy of `line` with the first `/token` coloured using `hex`. + * For `/goal next manage`, also colour the command-path tokens. + * `line` may already contain SGR escapes (cursor inverse, etc.); we + * locate `/` via visible-index math so ANSI pass-through survives. + * Returns `undefined` if no token is found. + */ +export function highlightFirstSlashToken(line: string, token: 'primary'): string | undefined { + const visible = stripSgr(line); + const range = leadingSlashTokenRange(visible); + if (range === null) return undefined; + const ranges = [range]; + if (visible.slice(range.start, range.end) === '/goal') { + ranges.push(...goalCommandPathRanges(visible, range.end)); + } + return highlightVisibleRanges(line, ranges, token); +} + +function leadingSlashTokenRange(visible: string): { start: number; end: number } | null { + const slashIdx = visible.indexOf('/'); + if (slashIdx < 0) return null; + // Guard: only paint when `/` is the first non-whitespace character + // on the line (avoids colouring a mid-sentence slash). + for (let i = 0; i < slashIdx; i++) { + if (visible[i] !== ' ' && visible[i] !== '\t') return null; + } + // Token ends at the next whitespace (or the visible end). + let endVisible = slashIdx + 1; + while (endVisible < visible.length) { + const ch = visible[endVisible]; + if (ch === ' ' || ch === '\t') break; + endVisible++; } + const visibleToken = visible.slice(slashIdx, endVisible); + if (visibleToken.slice(1).includes('/')) return null; + return { start: slashIdx, end: endVisible }; } /** - * Return a copy of `line` with the active slash-command token coloured using - * the current theme, even when the command lives mid-prompt. + * Highlight inline skill tokens in `line`. A token is painted only when it + * names a known skill; `exclude` (the already-painted leading slash command + * range) is skipped so the leading command is not painted twice. */ -export function highlightFirstSlashToken( +export function highlightInlineSkillTokens( line: string, - token: 'primary' | 'textStrong', + skillCommandNames: ReadonlySet<string>, + exclude: { start: number; end: number } | null, + token: 'primary', ): string | undefined { - const cursor = findCursorMarkerRange(line); - if (cursor === null) return undefined; - const ranges = getSlashHighlightRanges(stripSgr(line), cursor.visibleStart); + if (skillCommandNames.size === 0) return undefined; + const visible = stripSgr(line); + const ranges = findInlineSkillTokens(visible, { + isKnownSkill: (commandName) => + skillCommandNames.has(commandName) || skillCommandNames.has(`skill:${commandName}`), + includeLeading: true, + }).filter( + (inlineToken) => + exclude === null || inlineToken.start >= exclude.end || inlineToken.end <= exclude.start, + ); if (ranges.length === 0) return undefined; return highlightVisibleRanges(line, ranges, token); } -function activeSlashTokenColumn(line: string): number | undefined { - // The slash menu is indented so its labels line up under the active `/cmd` - // token; this computes that token's visible (terminal-cell) column. - const cursor = findCursorMarkerRange(line); - if (cursor === null) return undefined; - const visible = stripSgr(line); - const start = getSlashHighlightRanges(visible, cursor.visibleStart)[0]?.start; - return start === undefined ? undefined : visibleWidth(visible.slice(0, start)); +function goalCommandPathRanges( + visible: string, + commandEnd: number, +): Array<{ start: number; end: number }> { + const nextRange = readTokenRange(visible, commandEnd); + if (nextRange === null || visible.slice(nextRange.start, nextRange.end) !== 'next') { + return []; + } + const ranges = [nextRange]; + const manageRange = readTokenRange(visible, nextRange.end); + if (manageRange !== null && visible.slice(manageRange.start, manageRange.end) === 'manage') { + ranges.push(manageRange); + } + return ranges; +} + +function readTokenRange(visible: string, start: number): { start: number; end: number } | null { + let tokenStart = start; + while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; + if (tokenStart >= visible.length) return null; + let tokenEnd = tokenStart; + while (tokenEnd < visible.length && !isTokenSpace(visible[tokenEnd])) tokenEnd++; + return { start: tokenStart, end: tokenEnd }; +} + +function isTokenSpace(ch: string | undefined): boolean { + return ch === ' ' || ch === '\t'; } function highlightVisibleRanges( line: string, ranges: Array<{ start: number; end: number }>, - token: 'primary' | 'textStrong', + token: 'primary', ): string { let out = ''; let rawCursor = 0; @@ -683,127 +726,80 @@ function highlightVisibleRanges( const rawStart = mapVisibleIdxToRaw(line, range.start); const rawEnd = mapVisibleIdxToRaw(line, range.end); out += line.slice(rawCursor, rawStart); - out += paintHighlightRange(line.slice(rawStart, rawEnd), token); + out += currentTheme.boldFg(token, line.slice(rawStart, rawEnd)); rawCursor = rawEnd; } return out + line.slice(rawCursor); } -function paintHighlightRange( - text: string, - token: 'primary' | 'textStrong', +// Mirrors the editor's paddingX (see constructor). The hint is spliced into +// the first content line, which starts with this many spaces of left padding. +const EDITOR_LEFT_PADDING = 4; +// pi-tui renders the end-of-input cursor as an inverse-video space. +const CURSOR_BLOCK = '\u001B[7m \u001B[0m'; + +/** + * Splice a dimmed argument-hint ghost string into the first content line. + * + * The hint is purely visual: it is appended after the typed command (and + * after the cursor block when one is rendered) so the cursor stays at the + * end of the real input. It consumes trailing padding space, so the line + * width is preserved; if it would overflow the box it is truncated with an + * ellipsis. Returns the line unchanged when there is no room for a hint. + */ +function injectArgumentHint( + line: string, + hint: string, + realTextLength: number, + width: number, ): string { - // The cursor marker injects an SGR reset inside the token; re-apply the - // highlight to every non-reset segment so the token stays colored. - return text - .split(/(\u001B\[0m)/u) - .map((part) => - part.length === 0 || part === '\u001B[0m' - ? part - : currentTheme.boldFg(token, part), - ) - .join(''); + const cursorIdx = line.indexOf(CURSOR_BLOCK); + const cursorPresent = cursorIdx !== -1; + const contentWidth = Math.max(1, width - EDITOR_LEFT_PADDING * 2); + // Room left in the content area after the typed text (and cursor). The hint + // must fit within this so the rendered line keeps its width. + const available = contentWidth - realTextLength - (cursorPresent ? 1 : 0); + const trimmed = truncateHint(hint, available); + if (trimmed.length === 0) return line; + const colored = currentTheme.fg('textDim', trimmed); + const insertAt = cursorPresent + ? cursorIdx + CURSOR_BLOCK.length + : mapVisibleIdxToRaw(line, EDITOR_LEFT_PADDING + realTextLength); + // Everything after the insertion point is trailing padding + right padding + // (plain spaces). Replace it with the hint followed by the remaining spaces + // so the visible line width is preserved. + const trailing = line.length - insertAt; + return line.slice(0, insertAt) + colored + ' '.repeat(Math.max(0, trailing - trimmed.length)); +} + +function truncateHint(hint: string, maxLen: number): string { + if (maxLen <= 0) return ''; + if (hint.length <= maxLen) return hint; + if (maxLen === 1) return '…'; + return `${hint.slice(0, maxLen - 1)}…`; } /** - * Overlay a terminal-style `❯ ` prompt symbol on the first content line. + * Overlay a terminal-style `> ` prompt symbol on the first content line. * Column 0 is reserved for the left vertical border (overlaid later by - * wrapWithSideBorders); column 1 is a single-space gap, so the `❯` token + * wrapWithSideBorders); column 1 is a single-space gap, so the `>` token * lives at column 2 with column 3 separating it from content. * Relies on the editor being configured with `paddingX >= 4` so the line * starts with at least four literal spaces. Emits no SGR so the terminal's * default foreground colour renders the symbol. Returns `undefined` if the * line is too short or doesn't begin with the expected padding. */ -export function injectPromptSymbol(line: string): string | undefined { +export function injectPromptSymbol( + line: string, + symbol = '>', + paint?: (s: string) => string, +): string | undefined { if (line.length < 4) return undefined; for (let i = 0; i < 4; i++) { if (line[i] !== ' ') return undefined; } - return ' ❯ ' + line.slice(4); -} - -function isHorizontalBorder(line: string): boolean { - const plain = stripSgr(line); - return plain.length > 0 && plain[0] === '─'; -} - -/** - * Repaint a border row as a plain full-width rule. Rows carrying a scroll - * indicator (`── ↑ N more ──`) keep their own text. - */ -/** - * Embed ` NORMAL `/` INSERT `/` VISUAL ` in the composer's bottom border so - * the active vim mode stays visible while typing. Plain rules keep their - * scroll-indicator text; boxed and flat bottom rows are rewritten. - */ -function decorateVimModeBorder( - line: string, - mode: VimMode | undefined, - paint: (text: string) => string, -): string { - if (mode === undefined) return line; - const plain = stripSgr(line); - const boxed = /^╰─+╯$/u.test(plain); - if (!boxed && !/^─+$/u.test(plain)) return line; - - const label = ` ${mode} `; - const left = boxed ? '╰─' : '─'; - const right = boxed ? '╯' : ''; - const fillWidth = - visibleWidth(plain) - visibleWidth(left) - visibleWidth(label) - visibleWidth(right); - if (fillWidth < 1) return line; - - const token = mode === 'NORMAL' ? 'primary' : mode === 'INSERT' ? 'success' : 'warning'; - return paint(left) + - currentTheme.boldFg(token, label) + - paint(`${'─'.repeat(fillWidth)}${right}`); -} - -function horizontalRule(line: string, paint: (text: string) => string): string { - const plain = stripSgr(line); - return /^─+$/u.test(plain) ? paint(plain) : line; -} - -function renderSlashCommandMenu( - lines: readonly string[], - width: number, - editorPadding: number, - leftIndent: number, -): string[] { - const safeWidth = Math.max(0, Math.trunc(Number.isFinite(width) ? width : 0)); - const indent = ' '.repeat(Math.max(0, leftIndent)); - return lines.map((line) => - fitMenuLine(`${indent}${stripEditorPadding(line, editorPadding)}`, safeWidth), - ); -} - -function stripEditorPadding(line: string, padding: number): string { - let start = 0; - while (start < padding && line[start] === ' ') start += 1; - - let end = line.length; - let removed = 0; - while (removed < padding && end > start && line[end - 1] === ' ') { - end -= 1; - removed += 1; - } - return line.slice(start, end); -} - -function fitMenuLine(line: string, width: number): string { - const clipped = truncateToWidth(line, width, ''); - return clipped + ' '.repeat(Math.max(0, width - visibleWidth(clipped))); -} - -function compactPromptRow(line: string, paint: (text: string) => string): string { - if (line.startsWith(' ❯ ')) return paint('❯') + line.slice(3); - - const withPrompt = injectPromptSymbol(line); - if (withPrompt === undefined) return line; - // Compact mode removes the two cells reserved for the legacy left border - // and paints only the prompt glyph when rainbow mode is active. - return paint('❯') + ' ' + line.slice(4); + const rendered = paint ? paint(symbol) : symbol; + return ' ' + rendered + ' ' + line.slice(4); } /** @@ -817,31 +813,44 @@ function compactPromptRow(line: string, paint: (text: string) => string): string * inner SGR intact; only column 0 and the last column are overlaid, and * only if they're literal spaces — that protects the cursor-overflow * case where the rightmost column is an SGR-tagged inverse cursor. + * + * When `options.label` is set, it is overlaid on the left of the top border + * (e.g. the `! shell mode` badge), replacing the leading dashes. It is only + * applied to a plain dash run, never to a `↑/↓ N more` scroll indicator. */ export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, - options: { - readonly connectedAbove?: boolean; - } = {}, + options: { readonly connectedAbove?: boolean; readonly label?: string } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { + const isTop = !seenTop; const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); + if (isTop && options.label !== undefined && /^─+$/.test(middle)) { + const labelWidth = visibleWidth(options.label); + if (labelWidth <= middle.length) { + return ( + paint(leftCorner) + + options.label + + paint('─'.repeat(middle.length - labelWidth)) + + paint(rightCorner) + ); + } + } return paint(leftCorner + middle + rightCorner); } if (line.length === 0) return line; const firstCh = line[0]; const lastCh = line.at(-1); const head = firstCh === ' ' ? paint('│') : (firstCh ?? ''); - const tail = - line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); + const tail = line.length > 1 && lastCh === ' ' ? paint('│') : (lastCh ?? ''); if (line.length === 1) return head; return head + line.slice(1, -1) + tail; }); diff --git a/apps/pythinker-code/src/tui/components/editor/file-mention-provider.ts b/apps/pythinker-code/src/tui/components/editor/file-mention-provider.ts index c9bbede1..7c0c3525 100644 --- a/apps/pythinker-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/pythinker-code/src/tui/components/editor/file-mention-provider.ts @@ -1,5 +1,5 @@ -import { readdirSync, statSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { accessSync, constants as fsConstants, readdirSync, statSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; import { CombinedAutocompleteProvider, @@ -8,26 +8,11 @@ import { type AutocompleteProvider, type AutocompleteSuggestions, type SlashCommand, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; -import { findSlashAutocompleteContext } from './slash-autocomplete-context'; +import { findInlineSkillTokens } from '../../utils/inline-skill-tokens'; const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '=']); -const TRAILING_PROSE_PUNCTUATION = new Set([ - ',', - '.', - ';', - ':', - '!', - '?', - ')', - ']', - '}', - '>', - '"', - "'", - '`', -]); const MAX_FALLBACK_SCAN = 2000; const MAX_FALLBACK_SUGGESTIONS = 50; @@ -37,26 +22,34 @@ export interface SlashAutocompleteCommand extends SlashCommand { interface FsMentionCandidate { readonly path: string; + readonly absolutePath: string; readonly isDirectory: boolean; } /** * Pythinker wrapper around pi-tui's combined autocomplete provider. * - * File / folder mention behavior uses pi-tui's fd-backed provider when fd is - * available. While managed fd is downloading (or when it is unavailable), a - * small filesystem fallback keeps basic `@` file and folder completion usable. - * Ordinary path completion is still handled by pi-tui's readdir-backed path - * completer. This wrapper also keeps Pythinker-specific slash-command guards. + * File / folder mention behavior uses pi-tui's fd-backed provider whenever fd + * is available, fanning out across the working directory and any additional + * roots so `@` completion pushes the query down to fd instead of enumerating + * every file. A small filesystem fallback is used only while managed fd is + * downloading, when it is unavailable, or if fd fails to spawn. Ordinary path + * completion is still handled by pi-tui's readdir-backed path completer. This + * wrapper also keeps Pythinker-specific slash-command guards. */ export class FileMentionProvider implements AutocompleteProvider { private readonly inner: CombinedAutocompleteProvider; + private readonly additionalDirs: readonly string[]; constructor( private readonly slashCommands: SlashAutocompleteCommand[], private readonly workDir: string, private readonly fdPath: string | null, + additionalDirs: readonly string[] = [], + private readonly getInputMode: () => 'prompt' | 'bash' = () => 'prompt', + private readonly skillCommandNames?: ReadonlySet<string>, ) { + this.additionalDirs = additionalDirs.map((dir) => normalizePath(resolve(workDir, dir))); // Build an expanded list that includes alias entries so that // inner's argument completion can find commands by alias too. const expanded: SlashAutocompleteCommand[] = []; @@ -66,7 +59,7 @@ export class FileMentionProvider implements AutocompleteProvider { expanded.push({ ...cmd, name: alias }); } } - this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath); + this.inner = new CombinedAutocompleteProvider(expanded, workDir, fdPath, this.additionalDirs); } async getSuggestions( @@ -78,61 +71,206 @@ export class FileMentionProvider implements AutocompleteProvider { const currentLine = lines[cursorLine] ?? ''; const textBeforeCursor = currentLine.slice(0, cursorCol); + // `@` file / folder mentions take priority over the slash-command guards + // below. Without this, typing `@` inside a slash command's argument text + // (e.g. `/goal Fix the @|checkout docs`) would be swallowed by + // `shouldSuppressSlashArgumentCompletion` before the mention branch ever + // runs, so the file list never opens. const atPrefix = extractAtPrefix(textBeforeCursor); if (atPrefix !== null) { - if (this.fdPath === null) { - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + // fd backs `@` completion across every root (cwd + additional dirs). Fall + // back to the filesystem scanner when fd is unavailable, not executable + // (e.g. the managed binary was removed or lost execute permission), or if + // spawning it fails below. A genuine fd no-match still returns null. + if (this.fdPath === null || !isExecutableFd(this.fdPath)) { + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } try { return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } catch { // If fd fails to spawn unexpectedly, keep @ completion usable. - return getFsMentionSuggestions(this.workDir, atPrefix, options.signal); + return getFsMentionSuggestions( + this.workDir, + this.additionalDirs, + atPrefix, + options.signal, + ); } } - const slashContext = findSlashAutocompleteContext(currentLine, cursorCol); - if (slashContext !== null && !shouldPreferForcedRootPath(textBeforeCursor, options.force)) { - if (slashContext.kind === 'name') { - const suggestions = getSlashCommandNameSuggestions(this.slashCommands, slashContext.prefix); - if (suggestions !== null) { - return suggestions; - } - if (options.force !== true) { - return null; - } - } + // An inline skill token the cursor is still on stays eligible for skill + // selection even when the input begins with a slash command and has text + // after the cursor — the argument suppression below guards the command's + // own arguments, not an inline skill the user inserts mid-text. Computed + // before the leading-whitespace suppression: an indented inline token + // (` /skill:rev`) is a skill reference, not a path to suppress. + const inlineSkillPrefix = extractInlineSkillPrefix(textBeforeCursor, cursorLine); + + if ( + inlineSkillPrefix === null && + shouldSuppressLeadingWhitespaceSlashPath(textBeforeCursor, options.force) + ) { + return null; + } - if ( - options.force !== true && - slashContext.prefix.trim().length === 0 && - currentLine.slice(cursorCol).trimStart().length > 0 - ) { - return null; - } + // A `/` at the start of a later line is an inline skill reference, not a + // start-of-message slash command: offer the skill-only picker there. + if ( + cursorLine > 0 && + textBeforeCursor.trim() === '/' && + this.getInputMode() !== 'bash' && + options.force !== true + ) { + return this.getInlineSkillSuggestions('/'); + } + + if ( + inlineSkillPrefix === null && + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; + } - const command = findSlashAutocompleteCommand(this.slashCommands, slashContext.commandName); - if (command?.getArgumentCompletions !== undefined) { - const items = await command.getArgumentCompletions(slashContext.prefix); - if (Array.isArray(items) && items.length > 0) { - return { - items, - prefix: slashContext.prefix, - }; + // Handle slash-command name completion ourselves so that aliases are + // searchable and visible in the label. Only the first line can host a + // start-of-message slash command; later lines are inline skill territory. + if (!options.force && cursorLine === 0 && textBeforeCursor.startsWith('/')) { + const spaceIndex = textBeforeCursor.indexOf(' '); + if (spaceIndex === -1) { + const tokens = textBeforeCursor + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + type SlashMatch = { + cmd: SlashAutocompleteCommand; + score: number; + viaAlias: boolean; + label: string; + }; + const matches: SlashMatch[] = []; + + for (const cmd of this.slashCommands) { + const nameScore = scoreTokens(tokens, cmd.name); + if (nameScore !== null) { + matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); + continue; + } + // Aliases only count when the primary name missed; the label then + // lists them so the user can see why the command matched. + const aliases = cmd.aliases ?? []; + let bestAliasScore: number | null = null; + for (const alias of aliases) { + const aliasScore = scoreTokens(tokens, alias); + if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { + bestAliasScore = aliasScore; + } + } + if (bestAliasScore !== null) { + matches.push({ + cmd, + score: bestAliasScore, + viaAlias: true, + label: `${cmd.name} (${aliases.join(', ')})`, + }); + } } + + // Primary-name matches outrank alias matches on score ties. + matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.label, + description: formatSlashCommandDescription(m.cmd), + })), + prefix: textBeforeCursor, + }; } - if (options.force !== true) { - return null; + } + + // In bash mode `/` is a path separator, not a slash command. Skip slash + // command argument handling so an absolute path that happens to start with + // a command name (e.g. `/add-dir/...`) completes inside the path instead of + // returning the command's argument completions. + if (this.getInputMode() !== 'bash') { + const slashArgumentSuggestions = await getSlashArgumentSuggestions(this.slashCommands, textBeforeCursor); + if (slashArgumentSuggestions !== null) { + return slashArgumentSuggestions; } } + // Inline skill selection: `/` after whitespace mid-input in prompt mode. + // Runs after slash-command argument handling so known commands such as + // `/add-dir /` keep their own argument completions. + if ( + inlineSkillPrefix !== null && + this.getInputMode() !== 'bash' && + options.force !== true + ) { + // A mid-input `/` in prompt mode is only meaningful as skill selection; + // when no skills are registered, suppress path completion instead of + // offering root directories. + return this.getInlineSkillSuggestions(inlineSkillPrefix); + } + try { - return await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + const inner = await this.inner.getSuggestions(lines, cursorLine, cursorCol, options); + if (inner === null || this.getInputMode() !== 'bash') { + return inner; + } + // In bash mode `/` is a path separator; hide dot-prefixed entries to + // match the `/add-dir` directory completer (registry.ts skips any name + // starting with `.`). Ordinary prompt-mode path completion is left as-is. + return { ...inner, items: inner.items.filter((item) => !isDotPrefixedEntry(item)) }; } catch { return null; } } + private getInlineSkillSuggestions(prefix: string): AutocompleteSuggestions | null { + if (this.skillCommandNames === undefined || this.skillCommandNames.size === 0) return null; + const names = this.skillCommandNames; + const tokens = prefix + .slice(1) + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + + const matches: Array<{ cmd: SlashAutocompleteCommand; score: number }> = []; + for (const cmd of this.slashCommands) { + if (!names.has(cmd.name)) continue; + const score = scoreTokens(tokens, cmd.name); + if (score !== null) { + matches.push({ cmd, score }); + } + } + matches.sort((a, b) => a.score - b.score); + + if (matches.length === 0) return null; + return { + items: matches.map((m) => ({ + value: m.cmd.name, + label: m.cmd.name, + description: formatSlashCommandDescription(m.cmd), + data: { inlineSkill: true }, + })), + prefix, + }; + } + applyCompletion( lines: string[], cursorLine: number, @@ -140,38 +278,70 @@ export class FileMentionProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string, ): { lines: string[]; cursorLine: number; cursorCol: number } { - const currentLine = lines[cursorLine] ?? ''; - const slashContext = findSlashAutocompleteContext(currentLine, cursorCol); - if (slashContext !== null && slashContext.prefix === prefix) { - const nextLines = [...lines]; - if (slashContext.kind === 'name') { - const before = currentLine.slice(0, slashContext.commandStart); - const completionEnd = trimTrailingProsePunctuation(currentLine, cursorCol, slashContext.commandEnd); - const after = currentLine.slice(completionEnd); - const separator = shouldInsertSlashCommandSeparator(after) ? ' ' : ''; - nextLines[cursorLine] = `${before}/${item.value}${separator}${after}`; + // Inline skill selection mid-input: pi-tui's default applyCompletion + // treats mid-line slash prefixes as file paths and drops the `/`. Preserve + // the slash and add a trailing space so the completed token stays a valid + // skill reference (e.g. `hello /rev` -> `hello /skill:review `). + if ( + item.data?.['inlineSkill'] === true && + this.getInputMode() !== 'bash' && + prefix.startsWith('/') + ) { + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); + if (extractInlineSkillPrefix(textBeforeCursor, cursorLine) === prefix) { + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLines = [...lines]; + newLines[cursorLine] = `${beforePrefix}/${item.value} ${afterCursor}`; return { - lines: nextLines, + lines: newLines, cursorLine, - cursorCol: before.length + item.value.length + separator.length + 1, + // +2 for the preserved "/" and the appended " ". + cursorCol: beforePrefix.length + item.value.length + 2, }; } - - const before = currentLine.slice(0, slashContext.replaceStart); - const completionEnd = trimTrailingProsePunctuation(currentLine, cursorCol, slashContext.replaceEnd); - const after = currentLine.slice(completionEnd); - nextLines[cursorLine] = `${before}${item.value}${after}`; - return { - lines: nextLines, - cursorLine, - cursorCol: before.length + item.value.length, - }; + } + // In bash mode a leading `/` is a path, but pi-tui's applyCompletion + // mistakes it for a slash command (prefix starts with `/`, nothing before + // it, no second `/`) and prepends another `/`, producing e.g. + // `//Applications/ ` with a trailing space that also blocks further + // completion. Handle path completion ourselves so the value replaces the + // prefix verbatim. `@` mentions keep pi-tui's behaviour. + if (this.getInputMode() === 'bash' && prefix.startsWith('/')) { + return applyPathCompletion(lines, cursorLine, cursorCol, item, prefix); } return this.inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } } -function extractAtPrefix(text: string): string | null { +/** + * Extract the inline skill prefix (e.g. `/rev`) from `text` when the cursor is + * positioned after a `/` that is preceded by whitespace and not part of the + * leading slash-command area. Returns `null` when the context is not an inline + * skill trigger. + * + * On lines after the first, a `/` at the start of the line always begins an + * inline skill prefix — including the partially typed `/rev` — so the picker + * stays in skill-only mode while the token is completed. + */ +export function extractInlineSkillPrefix(text: string, cursorLine: number = 0): string | null { + if (cursorLine > 0) { + const trimmedStart = text.trimStart(); + const match = /^\/[^\s/]*$/.exec(trimmedStart); + if (match !== null) return match[0]; + } + // findInlineSkillTokens skips the leading slash-command area, so a line such + // as `/skill:review args /` still yields the trailing `/` token. + const tokens = findInlineSkillTokens(text, { + isKnownSkill: () => true, + allowEmpty: true, + }); + const token = tokens.findLast((t) => t.end === text.length); + return token === undefined ? null : text.slice(token.start); +} + +export function extractAtPrefix(text: string): string | null { let tokenStart = 0; for (let i = text.length - 1; i >= 0; i -= 1) { if (PATH_DELIMITERS.has(text[i] ?? '')) { @@ -183,15 +353,73 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +function isExecutableFd(fdPath: string): boolean { + // Bare command names (for example "fd" discovered on the system PATH) are + // trusted: spawn resolves them through PATH. Only absolute/relative paths are + // probed, which is how the managed fd is referenced and which can go stale. + if (!fdPath.includes('/') && !fdPath.includes('\\')) { + return true; + } + try { + accessSync(fdPath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Match the `/add-dir` directory completer, which skips every entry whose name + * starts with `.` (see registry.ts). pi-tui's path completer sets `label` to + * the entry basename, with a trailing `/` for directories. + */ +function isDotPrefixedEntry(item: AutocompleteItem): boolean { + const name = item.label.endsWith('/') ? item.label.slice(0, -1) : item.label; + return name.startsWith('.'); +} + +/** + * Replace `prefix` with `item.value` verbatim, mirroring pi-tui's file-path + * branch (no trailing space, so a completed directory can be extended with the + * next `/`). Used in bash mode to avoid pi-tui's slash-command branch, which + * would prepend an extra `/` to a bare leading `/` path. For a quoted + * directory value (path contains spaces), the cursor stays inside the closing + * quote so follow-up `/` completion keeps working. + */ +function applyPathCompletion( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, +): { lines: string[]; cursorLine: number; cursorCol: number } { + const currentLine = lines[cursorLine] ?? ''; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + const isDirectory = item.label.endsWith('/'); + const hasTrailingQuote = item.value.endsWith('"'); + const cursorOffset = + isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + cursorOffset, + }; +} + function getFsMentionSuggestions( workDir: string, + additionalDirs: readonly string[], atPrefix: string, signal: AbortSignal, ): AutocompleteSuggestions | null { if (signal.aborted) return null; const query = atPrefix.slice(1); - const candidates = collectFsMentionCandidates(workDir, signal); + const candidates = collectFsMentionCandidates(workDir, additionalDirs, signal); if (candidates.length === 0 || signal.aborted) return null; const ranked = rankFsMentionCandidates(candidates, query).slice(0, MAX_FALLBACK_SUGGESTIONS); @@ -203,44 +431,69 @@ function getFsMentionSuggestions( }; } -function collectFsMentionCandidates(workDir: string, signal: AbortSignal): FsMentionCandidate[] { - const result: FsMentionCandidate[] = []; - const stack = ['']; +function collectFsMentionCandidates( + workDir: string, + additionalDirs: readonly string[], + signal: AbortSignal, +): FsMentionCandidate[] { + const candidatesByAbsolutePath = new Map<string, FsMentionCandidate>(); + const roots = [ + { root: normalizePath(resolve(workDir)), isAdditionalDir: false }, + ...additionalDirs.map((dir) => ({ + root: normalizePath(resolve(workDir, dir)), + isAdditionalDir: true, + })), + ]; + let scanned = 0; - while (stack.length > 0 && result.length < MAX_FALLBACK_SCAN) { - if (signal.aborted) break; - const relativeDir = stack.pop() ?? ''; - const absoluteDir = relativeDir.length === 0 ? workDir : join(workDir, relativeDir); - let entries; - try { - entries = readdirSync(absoluteDir, { withFileTypes: true }); - } catch { - continue; - } + for (const { root, isAdditionalDir } of roots) { + const stack = ['']; - for (const entry of entries) { - if (signal.aborted || result.length >= MAX_FALLBACK_SCAN) break; - if (entry.name === '.git') continue; - - const relativePath = normalizePath(relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name)); - const isSymlink = entry.isSymbolicLink(); - let isDirectory = entry.isDirectory(); - if (!isDirectory && isSymlink) { - try { - isDirectory = statSync(join(workDir, relativePath)).isDirectory(); - } catch { - // Broken symlink or permission error — keep it as a file candidate. - } + while (stack.length > 0 && scanned < MAX_FALLBACK_SCAN) { + if (signal.aborted) break; + const relativeDir = stack.pop() ?? ''; + const absoluteDir = relativeDir.length === 0 ? root : join(root, relativeDir); + let entries; + try { + entries = readdirSync(absoluteDir, { withFileTypes: true }); + } catch { + continue; } - result.push({ path: relativePath, isDirectory }); - if (isDirectory && !isSymlink) { - stack.push(relativePath); + for (const entry of entries) { + if (signal.aborted || scanned >= MAX_FALLBACK_SCAN) break; + if (entry.name === '.git') continue; + + const relativePath = normalizePath( + relativeDir.length === 0 ? entry.name : join(relativeDir, entry.name), + ); + const absolutePath = normalizePath(join(absoluteDir, entry.name)); + const isSymlink = entry.isSymbolicLink(); + let isDirectory = entry.isDirectory(); + if (!isDirectory && isSymlink) { + try { + isDirectory = statSync(absolutePath).isDirectory(); + } catch { + // Broken symlink or permission error — keep it as a file candidate. + } + } + + scanned += 1; + if (!candidatesByAbsolutePath.has(absolutePath)) { + candidatesByAbsolutePath.set(absolutePath, { + path: isAdditionalDir ? absolutePath : relativePath, + absolutePath, + isDirectory, + }); + } + if (isDirectory && !isSymlink) { + stack.push(relativePath); + } } } } - return result; + return [...candidatesByAbsolutePath.values()]; } function rankFsMentionCandidates( @@ -290,7 +543,7 @@ function toMentionItem(candidate: FsMentionCandidate): AutocompleteItem { return { value, label, - description: valuePath, + description: candidate.absolutePath, }; } @@ -298,88 +551,70 @@ function normalizePath(path: string): string { return path.replaceAll('\\', '/'); } -function shouldPreferForcedRootPath( +async function getSlashArgumentSuggestions( + slashCommands: readonly SlashAutocompleteCommand[], textBeforeCursor: string, - force: boolean | undefined, -): boolean { - return force === true && textBeforeCursor.trim() === '/'; -} +): Promise<AutocompleteSuggestions | null> { + const parsed = parseSlashArgumentContext(textBeforeCursor, slashCommands); + if (parsed === null) return null; -function trimTrailingProsePunctuation(line: string, cursorCol: number, tokenEnd: number): number { - let nextEnd = tokenEnd; - while (nextEnd > cursorCol && TRAILING_PROSE_PUNCTUATION.has(line[nextEnd - 1] ?? '')) { - nextEnd -= 1; - } - return nextEnd; -} + const items = await parsed.command.getArgumentCompletions?.(parsed.argumentPrefix); + if (items === undefined || items === null || items.length === 0) return null; -function shouldInsertSlashCommandSeparator(after: string): boolean { - const nextChar = after[0]; - if (nextChar === undefined) return false; - if (nextChar === ' ' || nextChar === '\t') return false; - return !TRAILING_PROSE_PUNCTUATION.has(nextChar); + return { + prefix: parsed.argumentPrefix, + items, + }; } -function getSlashCommandNameSuggestions( +function parseSlashArgumentContext( + textBeforeCursor: string, slashCommands: readonly SlashAutocompleteCommand[], - prefix: string, -): AutocompleteSuggestions | null { - const tokens = prefix - .slice(1) - .trim() - .split(/\s+/) - .filter((token) => token.length > 0); - - type SlashMatch = { - readonly cmd: SlashAutocompleteCommand; - readonly score: number; - readonly viaAlias: boolean; - readonly label: string; - }; - - const matches: SlashMatch[] = []; - for (const cmd of slashCommands) { - const nameScore = scoreTokens(tokens, cmd.name); - if (nameScore !== null) { - matches.push({ cmd, score: nameScore, viaAlias: false, label: cmd.name }); - continue; - } - - const aliases = cmd.aliases ?? []; - let bestAliasScore: number | null = null; - for (const alias of aliases) { - const aliasScore = scoreTokens(tokens, alias); - if (aliasScore !== null && (bestAliasScore === null || aliasScore < bestAliasScore)) { - bestAliasScore = aliasScore; - } - } - if (bestAliasScore !== null) { - matches.push({ - cmd, - score: bestAliasScore, - viaAlias: true, - label: `${cmd.name} (${aliases.join(', ')})`, - }); - } +): { command: SlashAutocompleteCommand; argumentPrefix: string } | null { + const whitespaceMatch = textBeforeCursor.match(/^\/(\S+)\s+(\S*)$/); + if (whitespaceMatch !== null) { + const [, commandName = '', argumentPrefix = ''] = whitespaceMatch; + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + if (!textBeforeCursor.endsWith(' ') && argumentPrefix.length === 0) return null; + return { command, argumentPrefix }; } - matches.sort((a, b) => a.score - b.score || Number(a.viaAlias) - Number(b.viaAlias)); - if (matches.length === 0) return null; - return { - items: matches.map((match) => ({ - value: match.cmd.name, - label: match.label, - description: formatSlashCommandDescription(match.cmd), - })), - prefix, - }; + const pathLikeMatch = textBeforeCursor.match(/^\/([^/\s]+)(\/.*)$/); + const commandName = pathLikeMatch?.[1]; + const argumentPrefix = pathLikeMatch?.[2]; + if (commandName === undefined || argumentPrefix === undefined) return null; + + const command = findSlashCommand(slashCommands, commandName); + if (command === undefined) return null; + return { command, argumentPrefix }; } -function findSlashAutocompleteCommand( +function findSlashCommand( slashCommands: readonly SlashAutocompleteCommand[], - name: string, + commandName: string, ): SlashAutocompleteCommand | undefined { - return slashCommands.find((command) => command.name === name || command.aliases?.includes(name)); + return slashCommands.find((cmd) => cmd.name === commandName || (cmd.aliases ?? []).includes(commandName)); +} + +function shouldSuppressLeadingWhitespaceSlashPath( + textBeforeCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (textBeforeCursor.startsWith('/')) return false; + return textBeforeCursor.trimStart().startsWith('/'); +} + +function shouldSuppressSlashArgumentCompletion( + textBeforeCursor: string, + textAfterCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (!textBeforeCursor.startsWith('/')) return false; + if (!textBeforeCursor.includes(' ')) return false; + return textAfterCursor.trimStart().length > 0; } /** diff --git a/apps/pythinker-code/src/tui/components/editor/slash-autocomplete-context.ts b/apps/pythinker-code/src/tui/components/editor/slash-autocomplete-context.ts deleted file mode 100644 index f3998f04..00000000 --- a/apps/pythinker-code/src/tui/components/editor/slash-autocomplete-context.ts +++ /dev/null @@ -1,108 +0,0 @@ -interface VisibleRange { - readonly start: number; - readonly end: number; -} - -export interface SlashAutocompleteContext { - readonly kind: 'name' | 'args'; - readonly commandStart: number; - readonly commandEnd: number; - readonly commandName: string; - readonly prefix: string; - readonly replaceStart: number; - readonly replaceEnd: number; - readonly argumentStart?: number; -} - -const SLASH_BOUNDARY_CHARS = new Set([' ', '\t', '"', "'", '`', '(', '[', '{', '<']); - -export function findSlashAutocompleteContext( - line: string, - cursorCol: number, -): SlashAutocompleteContext | null { - const clampedCursor = Math.max(0, Math.min(cursorCol, line.length)); - - for (let i = clampedCursor - 1; i >= 0; i -= 1) { - if (line[i] !== '/') continue; - if (!isSlashBoundary(line[i - 1])) continue; - - const commandStart = i; - const commandEnd = findTokenEnd(line, commandStart); - const commandName = line.slice(commandStart + 1, commandEnd); - if (commandName.includes('/')) continue; - - if (clampedCursor <= commandEnd) { - return { - kind: 'name', - commandStart, - commandEnd, - commandName, - prefix: line.slice(commandStart, clampedCursor), - replaceStart: commandStart, - replaceEnd: commandEnd, - }; - } - - const argumentStart = commandEnd + 1; - return { - kind: 'args', - commandStart, - commandEnd, - commandName, - prefix: line.slice(argumentStart, clampedCursor), - replaceStart: argumentStart, - replaceEnd: findTokenEnd(line, clampedCursor), - argumentStart, - }; - } - - return null; -} - -export function getSlashHighlightRanges(line: string, cursorCol: number): VisibleRange[] { - const context = findSlashAutocompleteContext(line, cursorCol); - if (context === null) return []; - - const ranges: VisibleRange[] = [{ start: context.commandStart, end: context.commandEnd }]; - if (context.commandName === 'goal') { - ranges.push(...goalCommandPathRanges(line, context.commandEnd)); - } - return ranges; -} - -function goalCommandPathRanges(line: string, commandEnd: number): VisibleRange[] { - const nextRange = readTokenRange(line, commandEnd); - if (nextRange === null || line.slice(nextRange.start, nextRange.end) !== 'next') { - return []; - } - const ranges = [nextRange]; - const manageRange = readTokenRange(line, nextRange.end); - if (manageRange !== null && line.slice(manageRange.start, manageRange.end) === 'manage') { - ranges.push(manageRange); - } - return ranges; -} - -function readTokenRange(line: string, start: number): VisibleRange | null { - let tokenStart = start; - while (tokenStart < line.length && isTokenSpace(line[tokenStart])) tokenStart += 1; - if (tokenStart >= line.length) return null; - return { - start: tokenStart, - end: findTokenEnd(line, tokenStart), - }; -} - -function findTokenEnd(line: string, start: number): number { - let end = start; - while (end < line.length && !isTokenSpace(line[end])) end += 1; - return end; -} - -function isSlashBoundary(ch: string | undefined): boolean { - return ch === undefined || SLASH_BOUNDARY_CHARS.has(ch); -} - -function isTokenSpace(ch: string | undefined): boolean { - return ch === ' ' || ch === '\t'; -} diff --git a/apps/pythinker-code/src/tui/components/editor/wrapping-select-list.ts b/apps/pythinker-code/src/tui/components/editor/wrapping-select-list.ts index 40723022..795649b9 100644 --- a/apps/pythinker-code/src/tui/components/editor/wrapping-select-list.ts +++ b/apps/pythinker-code/src/tui/components/editor/wrapping-select-list.ts @@ -6,9 +6,7 @@ import { type SelectItem, type SelectListLayoutOptions, type SelectListTheme, -} from '@earendil-works/pi-tui'; - -import { SELECT_POINTER } from '#/tui/constant/symbols'; +} from '@pymodel/pi-tui'; // Mirror pi-tui's private select-list layout constants // (dist/components/select-list.js); keep in sync when bumping pi-tui. @@ -85,7 +83,7 @@ export class WrappingSelectList extends SelectList { primaryColumnWidth: number, ): string[] { const { theme } = this.internals(); - const prefix = isSelected ? `${SELECT_POINTER} ` : ' '; + const prefix = isSelected ? '→ ' : ' '; const prefixWidth = visibleWidth(prefix); const description = item.description ? item.description.replaceAll(/[\r\n]+/g, ' ').trim() @@ -112,11 +110,7 @@ export class WrappingSelectList extends SelectList { const indent = ' '.repeat(descriptionStart); if (isSelected) { return descriptionLines.map((line, index) => - index === 0 - ? theme.selectedPrefix(prefix) + - theme.selectedText(truncatedValue) + - theme.description(spacing + line) - : theme.description(indent + line), + theme.selectedText(index === 0 ? `${prefix}${truncatedValue}${spacing}${line}` : indent + line), ); } return descriptionLines.map((line, index) => @@ -129,11 +123,7 @@ export class WrappingSelectList extends SelectList { const maxWidth = width - prefixWidth - 2; const truncatedValue = this.truncatePrimaryValue(item, isSelected, maxWidth, maxWidth); - return [ - isSelected - ? theme.selectedPrefix(prefix) + theme.selectedText(truncatedValue) - : prefix + truncatedValue, - ]; + return [isSelected ? theme.selectedText(`${prefix}${truncatedValue}`) : prefix + truncatedValue]; } private truncatePrimaryValue( @@ -159,8 +149,7 @@ export class WrappingSelectList extends SelectList { const min = Math.max(1, Math.min(rawMin, rawMax)); const max = Math.max(1, Math.max(rawMin, rawMax)); const widest = filteredItems.reduce( - (acc, item) => - Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), + (acc, item) => Math.max(acc, visibleWidth(item.label || item.value) + PRIMARY_COLUMN_GAP), 0, ); return Math.max(min, Math.min(widest, max)); diff --git a/apps/pythinker-code/src/tui/components/index.ts b/apps/pythinker-code/src/tui/components/index.ts index e6de902c..43d4e590 100644 --- a/apps/pythinker-code/src/tui/components/index.ts +++ b/apps/pythinker-code/src/tui/components/index.ts @@ -1,6 +1,6 @@ export * from './chrome/device-code-box'; export * from './chrome/footer'; -export * from './chrome/activity-loader'; +export * from './chrome/moon-loader'; export * from './chrome/todo-panel'; export * from './chrome/welcome'; export * from './dialogs/approval-panel'; @@ -29,7 +29,6 @@ export * from './messages/shell-execution'; export * from './messages/skill-activation'; export * from './messages/status-message'; export * from './messages/dynamic-workflow-markers'; -export * from './messages/dynamic-workflow-mission-control'; export * from './messages/thinking'; export * from './messages/tool-call'; export * from './messages/usage-panel'; diff --git a/apps/pythinker-code/src/tui/components/media/code-highlight.ts b/apps/pythinker-code/src/tui/components/media/code-highlight.ts index 870e2fab..deec7a25 100644 --- a/apps/pythinker-code/src/tui/components/media/code-highlight.ts +++ b/apps/pythinker-code/src/tui/components/media/code-highlight.ts @@ -47,11 +47,7 @@ export function highlightLines(code: string, lang: string | undefined): string[] const normalizedLang = lang?.trim().toLowerCase(); if (!normalizedLang || !supportsLanguage(normalizedLang)) return code.split('\n'); try { - return highlight(code, { - language: normalizedLang, - ignoreIllegals: true, - theme: codeHighlightTheme, - }).split('\n'); + return highlight(code, { language: normalizedLang, ignoreIllegals: true, theme: codeHighlightTheme }).split('\n'); } catch { return code.split('\n'); } diff --git a/apps/pythinker-code/src/tui/components/media/diff-preview.ts b/apps/pythinker-code/src/tui/components/media/diff-preview.ts index 00ede4a2..1fec48b2 100644 --- a/apps/pythinker-code/src/tui/components/media/diff-preview.ts +++ b/apps/pythinker-code/src/tui/components/media/diff-preview.ts @@ -156,6 +156,8 @@ export interface ClusteredDiffOptions { readonly maxLines?: number; readonly isIncomplete?: boolean; readonly expandKeyHint?: string; + readonly oldStart?: number; + readonly newStart?: number; } interface Cluster { @@ -239,7 +241,13 @@ export function renderDiffLinesClustered( const s = makeDiffStyles(); const contextLines = opts.contextLines ?? 3; const maxLines = opts.maxLines; - const diffLines = computeDiffLines(oldText, newText, 1, 1, opts.isIncomplete ?? false); + const diffLines = computeDiffLines( + oldText, + newText, + opts.oldStart ?? 1, + opts.newStart ?? 1, + opts.isIncomplete ?? false, + ); const { clusters, changedCount, addedCount, removedCount } = buildClusters( diffLines, contextLines, diff --git a/apps/pythinker-code/src/tui/components/media/image-thumbnail.ts b/apps/pythinker-code/src/tui/components/media/image-thumbnail.ts index cc8ef4d5..0e5f751b 100644 --- a/apps/pythinker-code/src/tui/components/media/image-thumbnail.ts +++ b/apps/pythinker-code/src/tui/components/media/image-thumbnail.ts @@ -12,7 +12,7 @@ * the viewport; pi-tui handles proportional scaling internally. */ -import { Container, Image, Text, type ImageTheme, getCapabilities } from '@earendil-works/pi-tui'; +import { Container, Image, Text, type ImageTheme, getCapabilities } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; diff --git a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress-estimator.ts b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress-estimator.ts new file mode 100644 index 00000000..90e0ffad --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress-estimator.ts @@ -0,0 +1,436 @@ +const DEFAULT_RATE_WINDOW_MS = 45_000; +const DEFAULT_CATCHUP_TIME_MS = 1_500; +const DEFAULT_WORKLOAD_SPREAD_FACTOR = 1.5; +const DEFAULT_UNFINISHED_PROGRESS_CAP = 0.85; +const DEFAULT_MAX_BOOST_GAIN = 0.75; +const RATE_TOOL_CONFIDENCE_SCALE = 4; +const BOOST_TOOL_CONFIDENCE_SCALE = 3; +const MIN_RATE_FACTOR = 0.25; +const HALF_TICK = 0.5; + +export type AgentDynamicWorkflowProgressEstimatorPhase = + | 'pending' + | 'queued' + | 'suspended' + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface AgentDynamicWorkflowProgressEstimatorOptions { + readonly rateWindowMs?: number; + readonly catchupTimeMs?: number; + readonly maxCatchupTicksPerSecond?: number; + readonly workloadSpreadFactor?: number; + readonly unfinishedProgressCap?: number; + readonly maxBoostGain?: number; +} + +export interface AgentDynamicWorkflowProgressEstimateInput { + readonly memberKey: string; + readonly phase: AgentDynamicWorkflowProgressEstimatorPhase; + readonly capacityTicks: number; + readonly nowMs: number; +} + +export interface AgentDynamicWorkflowProgressEstimate { + readonly rawTicks: number; + readonly displayTicks: number; + readonly estimatedTotalToolCalls?: number; + readonly estimatedProgress?: number; + readonly targetProgress?: number; + readonly targetTicks?: number; + readonly boosted: boolean; + readonly confidence?: number; +} + +interface MemberProgressState { + startedAtMs?: number; + pausedAtMs?: number; + pausedDurationMs: number; + terminalAtMs?: number; + terminalKind?: 'completed' | 'failed' | 'cancelled'; + rawTicks: number; + readonly seenToolCallIds: Set<string>; + toolCallActiveTimesMs: number[]; + displayTicks: number; + lastEstimateAtMs?: number; + lastTargetTicks?: number; +} + +interface CompletedSample { + readonly totalMs: number; + readonly rawTicks: number; +} + +interface EstimatePrior { + readonly completedCount: number; + readonly typicalTotalMs: number; + readonly typicalToolCalls: number; + readonly typicalRatePerMs: number; +} + +export class AgentDynamicWorkflowProgressEstimator { + private readonly members = new Map<string, MemberProgressState>(); + private readonly rateWindowMs: number; + private readonly catchupTimeMs: number; + private readonly maxCatchupTicksPerSecond: number | undefined; + private readonly workloadSpreadFactor: number; + private readonly unfinishedProgressCap: number; + private readonly maxBoostGain: number; + + constructor(options: AgentDynamicWorkflowProgressEstimatorOptions = {}) { + this.rateWindowMs = positiveOrDefault(options.rateWindowMs, DEFAULT_RATE_WINDOW_MS); + this.catchupTimeMs = positiveOrDefault(options.catchupTimeMs, DEFAULT_CATCHUP_TIME_MS); + this.maxCatchupTicksPerSecond = positiveOrUndefined(options.maxCatchupTicksPerSecond); + this.workloadSpreadFactor = spreadFactorOrDefault( + options.workloadSpreadFactor, + DEFAULT_WORKLOAD_SPREAD_FACTOR, + ); + this.unfinishedProgressCap = clampPositiveRatio( + options.unfinishedProgressCap, + DEFAULT_UNFINISHED_PROGRESS_CAP, + ); + this.maxBoostGain = clampPositiveRatio(options.maxBoostGain, DEFAULT_MAX_BOOST_GAIN); + } + + ensureMember(memberKey: string, nowMs: number): void { + void nowMs; + this.getOrCreateMember(memberKey); + } + + removeMissingMembers(memberKeys: readonly string[]): void { + const live = new Set(memberKeys); + for (const memberKey of this.members.keys()) { + if (!live.has(memberKey)) this.members.delete(memberKey); + } + } + + markStarted(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + this.startWork(state, nowMs); + if (state.rawTicks === 0) { + state.rawTicks = 1; + state.displayTicks = Math.max(state.displayTicks, 1); + } + delete state.terminalAtMs; + delete state.terminalKind; + } + + markQueued(memberKey: string, nowMs: number): void { + const state = this.getOrCreateMember(memberKey); + if (state.startedAtMs === undefined || state.terminalKind !== undefined) return; + state.pausedAtMs ??= nowMs; + state.lastEstimateAtMs = nowMs; + delete state.lastTargetTicks; + } + + recordToolCall(input: { + readonly memberKey: string; + readonly toolCallId: string; + readonly nowMs: number; + }): { readonly accepted: boolean; readonly rawTicks: number } { + const state = this.getOrCreateMember(input.memberKey); + this.startWork(state, input.nowMs); + if (state.seenToolCallIds.has(input.toolCallId)) { + return { accepted: false, rawTicks: state.rawTicks }; + } + state.seenToolCallIds.add(input.toolCallId); + state.toolCallActiveTimesMs.push(this.activeElapsedMs(state, input.nowMs)); + state.rawTicks += 1; + state.displayTicks = Math.max(state.displayTicks + 1, state.rawTicks); + delete state.terminalAtMs; + delete state.terminalKind; + return { accepted: true, rawTicks: state.rawTicks }; + } + + markCompleted(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'completed'); + } + + markFailed(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'failed'); + } + + markCancelled(memberKey: string, nowMs: number): void { + this.markTerminal(memberKey, nowMs, 'cancelled'); + } + + estimate(input: AgentDynamicWorkflowProgressEstimateInput): AgentDynamicWorkflowProgressEstimate { + const state = this.getOrCreateMember(input.memberKey); + const capacityTicks = Math.max(1, input.capacityTicks); + const rawTicks = state.rawTicks; + const previousDisplayTicks = Math.max(state.displayTicks, rawTicks); + const prior = this.buildPrior(); + const baseEstimate = { + rawTicks, + displayTicks: previousDisplayTicks, + boosted: false, + }; + + if (input.phase !== 'running' || rawTicks <= 0 || prior === undefined) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return baseEstimate; + } + + const completedConfidence = this.completedSampleConfidence(prior.completedCount); + const estimatedTotalToolCalls = this.estimateTotalToolCalls( + state, + prior, + input.nowMs, + completedConfidence, + ); + const estimatedProgress = Math.min( + this.unfinishedProgressCap, + rawTicks / estimatedTotalToolCalls, + ); + const rawProgress = Math.min(1, rawTicks / capacityTicks); + if (estimatedProgress <= rawProgress) { + state.displayTicks = previousDisplayTicks; + state.lastEstimateAtMs = input.nowMs; + delete state.lastTargetTicks; + return { + ...baseEstimate, + estimatedTotalToolCalls, + estimatedProgress, + boosted: false, + }; + } + + const toolConfidence = confidence(rawTicks, BOOST_TOOL_CONFIDENCE_SCALE); + const boostConfidence = completedConfidence * toolConfidence; + const boostGain = this.maxBoostGain * boostConfidence; + const targetProgress = rawProgress + boostGain * (estimatedProgress - rawProgress); + const targetTicks = Math.max(rawTicks, targetProgress * capacityTicks); + const displayTicks = this.catchUpDisplayTicks( + state, + previousDisplayTicks, + targetTicks, + capacityTicks, + input.nowMs, + ); + + state.displayTicks = displayTicks; + state.lastEstimateAtMs = input.nowMs; + state.lastTargetTicks = targetTicks; + return { + rawTicks, + displayTicks, + estimatedTotalToolCalls, + estimatedProgress, + targetProgress, + targetTicks, + boosted: displayTicks > rawTicks, + confidence: boostConfidence, + }; + } + + estimateAll( + inputs: readonly AgentDynamicWorkflowProgressEstimateInput[], + ): Map<string, AgentDynamicWorkflowProgressEstimate> { + const estimates = new Map<string, AgentDynamicWorkflowProgressEstimate>(); + for (const input of inputs) { + estimates.set(input.memberKey, this.estimate(input)); + } + return estimates; + } + + hasPendingCatchup(): boolean { + return Array.from(this.members.values()).some( + (state) => state.lastTargetTicks !== undefined && state.lastTargetTicks > state.displayTicks + 0.1, + ); + } + + private markTerminal( + memberKey: string, + nowMs: number, + terminalKind: 'completed' | 'failed' | 'cancelled', + ): void { + const state = this.getOrCreateMember(memberKey); + this.finishPausedInterval(state, nowMs); + state.terminalAtMs = nowMs; + state.terminalKind = terminalKind; + state.displayTicks = Math.max(state.displayTicks, state.rawTicks); + delete state.lastTargetTicks; + } + + private startWork(state: MemberProgressState, nowMs: number): void { + const wasQueued = state.startedAtMs === undefined || state.pausedAtMs !== undefined; + state.startedAtMs ??= nowMs; + this.finishPausedInterval(state, nowMs); + if (!wasQueued) return; + delete state.lastEstimateAtMs; + delete state.lastTargetTicks; + } + + private finishPausedInterval(state: MemberProgressState, nowMs: number): void { + if (state.pausedAtMs === undefined) return; + state.pausedDurationMs += Math.max(0, nowMs - state.pausedAtMs); + delete state.pausedAtMs; + } + + private activeElapsedMs(state: MemberProgressState, nowMs: number): number { + if (state.startedAtMs === undefined) return 0; + const currentPausedMs = + state.pausedAtMs === undefined ? 0 : Math.max(0, nowMs - state.pausedAtMs); + return Math.max(0, nowMs - state.startedAtMs - state.pausedDurationMs - currentPausedMs); + } + + private getOrCreateMember(memberKey: string): MemberProgressState { + const state = this.members.get(memberKey) ?? { + pausedDurationMs: 0, + rawTicks: 0, + seenToolCallIds: new Set(), + toolCallActiveTimesMs: [], + displayTicks: 0, + }; + this.members.set(memberKey, state); + return state; + } + + private buildPrior(): EstimatePrior | undefined { + const samples = this.completedSamples(); + if (samples.length === 0) return undefined; + return { + completedCount: samples.length, + typicalTotalMs: logMedian(samples.map((sample) => sample.totalMs)), + typicalToolCalls: logMedian(samples.map((sample) => sample.rawTicks)), + typicalRatePerMs: logMedian( + samples.map((sample) => (sample.rawTicks + HALF_TICK) / sample.totalMs), + ), + }; + } + + private completedSamples(): CompletedSample[] { + const samples: CompletedSample[] = []; + for (const state of this.members.values()) { + if (state.terminalKind !== 'completed') continue; + if (state.startedAtMs === undefined || state.terminalAtMs === undefined) continue; + if (state.rawTicks <= 0) continue; + const totalMs = this.activeElapsedMs(state, state.terminalAtMs); + if (totalMs <= 0) continue; + samples.push({ totalMs, rawTicks: state.rawTicks }); + } + return samples; + } + + private estimateTotalToolCalls( + state: MemberProgressState, + prior: EstimatePrior, + nowMs: number, + completedConfidence: number, + ): number { + const elapsedMs = this.activeElapsedMs(state, nowMs); + const localRatePerMs = this.estimateLocalRatePerMs(state, elapsedMs); + const rateWeight = confidence(state.rawTicks, RATE_TOOL_CONFIDENCE_SCALE); + const clampedLocalRatePerMs = Math.max( + localRatePerMs, + prior.typicalRatePerMs * MIN_RATE_FACTOR, + ); + const ratePerMs = geometricInterpolate( + prior.typicalRatePerMs, + clampedLocalRatePerMs, + rateWeight, + ); + const totalMs = Math.max(prior.typicalTotalMs, elapsedMs / this.unfinishedProgressCap); + const estimatedTotalToolCalls = ratePerMs * totalMs; + const boundedTotalToolCalls = this.softBoundTotalToolCalls( + estimatedTotalToolCalls, + prior, + completedConfidence, + ); + return Math.max( + boundedTotalToolCalls, + state.rawTicks / this.unfinishedProgressCap, + 1, + ); + } + + private softBoundTotalToolCalls( + totalToolCalls: number, + prior: EstimatePrior, + completedConfidence: number, + ): number { + const lowerBound = prior.typicalToolCalls / this.workloadSpreadFactor; + const upperBound = prior.typicalToolCalls * this.workloadSpreadFactor; + const bounded = Math.max(lowerBound, Math.min(upperBound, totalToolCalls)); + if (bounded === totalToolCalls) return totalToolCalls; + return geometricInterpolate(totalToolCalls, bounded, completedConfidence); + } + + private estimateLocalRatePerMs( + state: MemberProgressState, + elapsedMs: number, + ): number { + if (elapsedMs <= 0 || state.toolCallActiveTimesMs.length === 0) return 0; + let decayedToolCalls = 0; + for (const timeMs of state.toolCallActiveTimesMs) { + decayedToolCalls += Math.exp(-Math.max(0, elapsedMs - timeMs) / this.rateWindowMs); + } + const decayedElapsedMs = this.rateWindowMs * (1 - Math.exp(-elapsedMs / this.rateWindowMs)); + if (decayedElapsedMs <= 0) return 0; + return decayedToolCalls / decayedElapsedMs; + } + + private catchUpDisplayTicks( + state: MemberProgressState, + previousDisplayTicks: number, + targetTicks: number, + capacityTicks: number, + nowMs: number, + ): number { + if (targetTicks <= previousDisplayTicks) return previousDisplayTicks; + const lastEstimateAtMs = state.lastEstimateAtMs ?? nowMs; + const elapsedMs = Math.max(0, nowMs - lastEstimateAtMs); + if (elapsedMs <= 0) return previousDisplayTicks; + const alpha = 1 - Math.exp(-elapsedMs / this.catchupTimeMs); + const desiredDelta = (targetTicks - previousDisplayTicks) * alpha; + const maxCatchupTicksPerSecond = this.maxCatchupTicksPerSecond ?? capacityTicks / 2; + const maxDelta = Math.max(0, maxCatchupTicksPerSecond * (elapsedMs / 1_000)); + return previousDisplayTicks + Math.min(desiredDelta, maxDelta); + } + + private completedSampleConfidence(completedCount: number): number { + return confidence(completedCount, 1 + this.workloadSpreadFactor); + } +} + +function positiveOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function positiveOrUndefined(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined; +} + +function spreadFactorOrDefault(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 1 ? value : fallback; +} + +function clampPositiveRatio(value: number | undefined, fallback: number): number { + const ratio = positiveOrDefault(value, fallback); + return Math.max(0.01, Math.min(0.99, ratio)); +} + +function confidence(count: number, scale: number): number { + return 1 - Math.exp(-Math.max(0, count) / scale); +} + +function geometricInterpolate(low: number, high: number, weight: number): number { + const safeLow = Math.max(Number.EPSILON, low); + const safeHigh = Math.max(Number.EPSILON, high); + return Math.exp((1 - weight) * Math.log(safeLow) + weight * Math.log(safeHigh)); +} + +function logMedian(values: readonly number[]): number { + const logs = values + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.log(value)) + .toSorted((left, right) => left - right); + if (logs.length === 0) return 1; + const middle = Math.floor(logs.length / 2); + if (logs.length % 2 === 1) return Math.exp(logs[middle]!); + return Math.exp((logs[middle - 1]! + logs[middle]!) / 2); +} diff --git a/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts new file mode 100644 index 00000000..98fedfdc --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/agent-dynamic-workflow-progress.ts @@ -0,0 +1,1757 @@ +import { truncateToWidth, visibleWidth, type Component } from '@pymodel/pi-tui'; +import chalk from 'chalk'; + +import { + AgentDynamicWorkflowProgressEstimator, + type AgentDynamicWorkflowProgressEstimatorPhase, +} from '#/tui/components/messages/agent-dynamic-workflow-progress-estimator'; +import { FAILURE_MARK, SUCCESS_MARK } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; +import { gradientText } from '#/tui/theme/gradient-text'; + +const TEXT_CELL_PREFERRED_WIDTH = 30; +const CELL_GAP = ' '; +const FRAME_INTERVAL_MS = 80; +const TEXT_BRAILLE_BAR_MIN_WIDTH = 6; +const BRAILLE_BAR_MAX_WIDTH = 8; +const BRAILLE_EMPTY = '⣀'; +const BRAILLE_RIGHT_COLUMN_FULL = '⢸'; +const BRAILLE_LEVELS = ['⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'] as const; +const PHASE_LABEL_WIDTH = 'Completed'.length; +const MIN_LABEL_WIDTH = PHASE_LABEL_WIDTH; +const MAX_LATEST_MODEL_CHARS = 2_000; +const COMPLETE_FILL_MS = 360; +const FAILED_PLACEHOLDER_RED_FACTOR = 0.75; +const FAILED_PLACEHOLDER_NON_RED_FACTOR = 0.25; +const STATUS_BAR_CHAR = '━'; +const CANCELLED_MARK = '⊘ '; +const TOTAL_STATUS_BAR_GAP = 2; +const PROMPTING_TEXT_TRAILING_GAP = 1; +const ACTIVITY_SPINNER_PLACEHOLDER = ' '; +const AGENT_DYNAMIC_WORKFLOW_LEFT_INDENT = ' '; +const AGENT_DYNAMIC_WORKFLOW_RIGHT_GAP = 1; +const AGENT_DYNAMIC_WORKFLOW_NON_GRID_LINES = 6; +const COMPACT_TERMINAL_MARK_WIDTH = 1; +const ORCHESTRATING_LABEL = 'Orchestrating...'; +const PROMPTING_LABEL = 'Prompting...'; +const WORKING_LABEL = 'Working...'; +const COMPLETED_LABEL = 'Completed.'; +const FAILED_LABEL = 'Failed.'; +const ABORTED_LABEL = 'Aborted.'; +const CANCELLED_LABEL = 'Cancelled.'; +const QUEUED_LABEL = 'Queued...'; +const SUSPENDED_LABEL = 'Rate limited...'; +const RESUMED_ITEM_LABEL = '(resumed)'; +const CANCELLED_LABEL_DARKEN_FACTOR = 0.72; +const AGENT_DYNAMIC_WORKFLOW_TITLE_ACCENT_BIAS = 1.3; + +const STATUS_BAR_ORDER = [ + 'completed', + 'working', + 'suspended', + 'queued', + 'cancelled', + 'failed', +] as const; + +type AgentDynamicWorkflowPhase = AgentDynamicWorkflowProgressEstimatorPhase; +type StatusBarPhase = typeof STATUS_BAR_ORDER[number]; +type TotalStatus = 'working' | 'completed' | 'suspended' | 'failed' | 'aborted'; +type ClearableMemberKey = + | 'completedAtMs' + | 'completedText' + | 'failedAtMs' + | 'failureText' + | 'cancelledLabelText' + | 'cancelledLabelColor' + | 'cancelledMarkColor' + | 'cancelledBarColor' + | 'suspendedReason'; + +const COMPLETED_CLEAR_KEYS = [ + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const FAILED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const TERMINAL_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'cancelledLabelText', + 'cancelledLabelColor', + 'cancelledMarkColor', + 'cancelledBarColor', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; +const CANCELLED_CLEAR_KEYS = [ + 'completedAtMs', + 'completedText', + 'failedAtMs', + 'failureText', + 'suspendedReason', +] as const satisfies readonly ClearableMemberKey[]; + +interface AgentDynamicWorkflowMember { + readonly id: string; + agentId?: string; + phase: AgentDynamicWorkflowPhase; + ticks: number; + itemText: string; + latestModelText: string; + completedText?: string; + failureText?: string; + cancelledLabelText?: string; + cancelledLabelColor?: string; + cancelledMarkColor?: string; + cancelledBarColor?: string; + suspendedReason?: string; + completedAtMs?: number; + failedAtMs?: number; +} + +interface AgentDynamicWorkflowSnapshot { + readonly phase: AgentDynamicWorkflowPhase; + readonly ticks: number; + readonly latestModelText: string; + readonly phaseElapsedMs: number; +} + +interface AgentDynamicWorkflowResultStatus { + readonly index: number; + readonly status: 'completed' | 'failed' | 'cancelled'; + readonly completedText?: string; + readonly failureText?: string; +} + +export interface AgentDynamicWorkflowResultSummary { + readonly completed: number; + readonly failed: number; + readonly aborted: number; + readonly parsed: boolean; +} + +interface AgentDynamicWorkflowSummary { + readonly active: number; + readonly completed: number; + readonly failed: number; + readonly cancelled: number; +} + +export interface AgentDynamicWorkflowGridLayoutInput { + readonly width: number; + readonly height: number; + readonly count: number; +} + +export interface AgentDynamicWorkflowGridLayout { + readonly renderText: boolean; + readonly barCells: number; + readonly columns: number; + readonly rows: number; + readonly cellWidth: number; + readonly columnGap: number; + readonly leftPadding: number; +} + +export interface AgentDynamicWorkflowProgressOptions { + readonly description: string; + readonly requestRender?: () => void; + readonly availableGridHeight?: () => number | undefined; +} + +const PHASE_LABELS: Record<AgentDynamicWorkflowPhase, string> = { + pending: QUEUED_LABEL, + queued: QUEUED_LABEL, + suspended: SUSPENDED_LABEL, + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: ABORTED_LABEL, +}; + +export class AgentDynamicWorkflowProgressComponent implements Component { + private members: AgentDynamicWorkflowMember[]; + private readonly progressEstimator = new AgentDynamicWorkflowProgressEstimator(); + private description: string; + private readonly requestRender: (() => void) | undefined; + private readonly availableGridHeight: (() => number | undefined) | undefined; + private modelDisplay = ''; + private effortDisplay = ''; + private inputComplete = false; + private failed = false; + private aborted = false; + private itemsStarted = false; + private toolCallActive = true; + private promptTemplateText = ''; + private activitySpinnerText: (() => string) | undefined; + private timer: ReturnType<typeof setInterval> | undefined; + + constructor(options: AgentDynamicWorkflowProgressOptions) { + this.description = options.description; + this.requestRender = options.requestRender; + this.availableGridHeight = options.availableGridHeight; + this.members = []; + } + + /** Live palette, read on each render so a theme switch recolors the panel. */ + private get colors(): ColorPalette { + return currentTheme.palette; + } + + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } + + invalidate(): void {} + + setActivitySpinnerText(provider: (() => string) | undefined): void { + if (!this.toolCallActive) return; + this.activitySpinnerText = provider; + } + + /** + * Show the bound model once in the header. Every dynamic_workflow member binds to the + * same model, so the first child status update wins and later ones (e.g. + * from resumed agents that kept a different binding) do not churn it. + */ + setModelDisplay(modelDisplay: string): void { + if (this.modelDisplay.length > 0 || modelDisplay.length === 0) return; + this.modelDisplay = modelDisplay; + } + + /** + * Show the thinking effort next to the model, same first-wins rule. Only + * ever called with a concrete level (the handler filters the boolean + * states), so its presence already implies a real effort tier. + */ + setEffortDisplay(effortDisplay: string): void { + if (this.effortDisplay.length > 0 || effortDisplay.length === 0) return; + this.effortDisplay = effortDisplay; + } + + markToolCallEnded(): void { + this.toolCallActive = false; + this.activitySpinnerText = undefined; + } + + isToolCallActive(): boolean { + return this.toolCallActive; + } + + isRequestStreaming(): boolean { + return !this.inputComplete; + } + + updateArgs( + args: Record<string, unknown>, + options: { readonly streamingArguments?: string | undefined } = {}, + ): void { + const streamingArguments = options.streamingArguments; + const description = agentDynamicWorkflowDescriptionFromArgs(args); + if (description.length > 0 || this.description.length === 0) { + this.description = description; + } + const fullRows = [...agentDynamicWorkflowResumeItemsFromArgs(args), ...agentDynamicWorkflowItemsFromArgs(args)]; + const partialRows = streamingArguments === undefined + ? [] + : [ + ...agentDynamicWorkflowPartialResumeItemsFromArguments(streamingArguments), + ...agentDynamicWorkflowPartialItemsFromArguments(streamingArguments), + ]; + if ( + fullRows.length > 0 || + partialRows.length > 0 || + (streamingArguments !== undefined && agentDynamicWorkflowWorkItemsStartedFromArguments(streamingArguments)) + ) { + this.itemsStarted = true; + } + const fullPromptTemplate = agentDynamicWorkflowPromptTemplateFromArgs(args); + const partialPromptTemplate = + streamingArguments === undefined + ? '' + : agentDynamicWorkflowPartialPromptTemplateFromArguments(streamingArguments); + const promptTemplate = + fullPromptTemplate.length > 0 ? fullPromptTemplate : partialPromptTemplate; + if (promptTemplate.length > 0 || this.promptTemplateText.length === 0) { + this.promptTemplateText = promptTemplate; + } + + const itemCount = Math.max(fullRows.length, partialRows.length); + if (itemCount > 0) this.ensureMemberCount(itemCount); + this.updateItemTexts(fullRows, partialRows); + } + + markInputComplete(): void { + if (!this.inputComplete) { + this.inputComplete = true; + for (const member of this.members) { + if (member.phase === 'pending') member.phase = 'queued'; + } + } + this.startAnimationIfNeeded(); + } + + registerSubagent(input: { + readonly agentId: string; + readonly dynamicWorkflowIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberForSubagent(input.agentId, input.dynamicWorkflowIndex); + if (member === undefined) return; + member.agentId = input.agentId; + if (member.phase === 'pending') member.phase = 'queued'; + this.startAnimationIfNeeded(); + } + + markStarted(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.progressEstimator.markStarted(member.id, nowMs); + member.ticks = Math.max(member.ticks, 1); + this.promoteToRunning(member, nowMs); + this.startAnimationIfNeeded(); + } + + recordToolCall(input: { + readonly agentId: string; + readonly toolCallId: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined) return; + const result = this.progressEstimator.recordToolCall({ + memberKey: member.id, + toolCallId: input.toolCallId, + nowMs: Date.now(), + }); + if (!result.accepted) return; + member.ticks = result.rawTicks; + this.promoteToRunning(member); + this.startAnimationIfNeeded(); + } + + appendModelDelta(input: { + readonly agentId: string; + readonly delta: string; + }): void { + const member = this.findMemberByAgentId(input.agentId); + if (member === undefined || input.delta.length === 0) return; + member.latestModelText = `${member.latestModelText}${input.delta}`.slice( + -MAX_LATEST_MODEL_CHARS, + ); + this.promoteToRunning(member, Date.now(), true); + } + + markCompleted(agentId: string, completedText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined || member.phase === 'failed' || member.phase === 'cancelled') return; + const nowMs = Date.now(); + this.completeMember(member, nowMs, completedText); + this.startAnimationIfNeeded(); + } + + markSuspended(input: { + readonly agentId: string; + readonly reason: string; + readonly dynamicWorkflowIndex?: number; + readonly description?: string | undefined; + }): void { + const member = this.findMemberByAgentId(input.agentId) ?? + this.findMemberForSubagent(input.agentId, input.dynamicWorkflowIndex); + if (member === undefined || member.phase === 'completed' || member.phase === 'cancelled') return; + member.agentId = input.agentId; + this.progressEstimator.markQueued(member.id, Date.now()); + member.phase = 'suspended'; + clearMemberState(member, ...TERMINAL_CLEAR_KEYS); + this.startAnimationIfNeeded(); + } + + markFailed(agentId: string, failureText?: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + const nowMs = Date.now(); + this.failMember(member, nowMs, failureText); + this.startAnimationIfNeeded(); + } + + markDynamicWorkflowFailed(failureText?: string): void { + this.failed = true; + this.aborted = false; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.failMember(member, nowMs, failureText); + } + this.startAnimationIfNeeded(); + } + + markCancelled(agentId: string): void { + const member = this.findMemberByAgentId(agentId); + if (member === undefined) return; + this.cancelMember(member, Date.now()); + } + + markActiveCancelled(): void { + this.aborted = true; + const nowMs = Date.now(); + for (const member of this.members) { + if (isTerminalPhase(member.phase)) continue; + this.cancelMember(member, nowMs); + } + this.startAnimationIfNeeded(); + } + + applyResult(output: string): boolean { + const statuses = parseAgentDynamicWorkflowResultStatuses(output); + if (statuses.length === 0) return false; + this.aborted = false; + const nowMs = Date.now(); + for (const entry of statuses) { + this.ensureMemberCount(entry.index); + const member = this.members[entry.index - 1]; + if (member === undefined) continue; + if (entry.status === 'completed') { + this.completeMember(member, nowMs, entry.completedText); + } else if (entry.status === 'failed') { + this.failMember(member, nowMs, entry.failureText); + } else { + this.cancelMember(member, nowMs); + } + } + this.startAnimationIfNeeded(); + return true; + } + + render(width: number): string[] { + const outerWidth = Math.max(1, width); + const innerWidth = Math.max( + 1, + outerWidth - visibleWidth(AGENT_DYNAMIC_WORKFLOW_LEFT_INDENT) - AGENT_DYNAMIC_WORKFLOW_RIGHT_GAP, + ); + if (this.members.length === 0) { + const lines = [ + '', + this.renderHeader(innerWidth, undefined), + '', + this.renderStatusLine(innerWidth), + '', + ]; + return this.indentLines(lines, outerWidth); + } + + const nowMs = Date.now(); + const snapshots = this.members.map((member): AgentDynamicWorkflowSnapshot => ({ + phase: member.phase, + ticks: member.ticks, + latestModelText: member.latestModelText, + phaseElapsedMs: terminalPhaseElapsedMs(member, nowMs), + })); + const summary = summarizeSnapshots(snapshots); + const lines = [ + '', + this.renderHeader(innerWidth, summary), + '', + ...this.renderGrid( + innerWidth, + this.availableGridHeight?.(), + snapshots, + nowMs, + ), + '', + this.renderStatusLine(innerWidth), + '', + ]; + this.startAnimationIfNeeded(); + return this.indentLines(lines, outerWidth); + } + + private indentLines(lines: readonly string[], width: number): string[] { + const contentWidth = Math.max( + 0, + width - visibleWidth(AGENT_DYNAMIC_WORKFLOW_LEFT_INDENT) - AGENT_DYNAMIC_WORKFLOW_RIGHT_GAP, + ); + return lines.map((line) => + truncateToWidth( + AGENT_DYNAMIC_WORKFLOW_LEFT_INDENT + truncateToWidth(line, contentWidth), + width, + ) + ); + } + + private renderHeader(width: number, _summary: AgentDynamicWorkflowSummary | undefined): string { + if (width <= 3) return chalk.hex(this.colors.primary)('─'.repeat(width)); + + const title = gradientText('Agent DynamicWorkflow', this.colors.primary, this.colors.accent, AGENT_DYNAMIC_WORKFLOW_TITLE_ACCENT_BIAS); + const description = + this.description.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.text)(this.description) + : ''; + const modelText = + this.effortDisplay.length > 0 + ? `${this.modelDisplay} · ${this.effortDisplay}` + : this.modelDisplay; + const model = + modelText.length > 0 + ? chalk.hex(this.colors.primary)(' ─ ') + chalk.hex(this.colors.textDim)(modelText) + : ''; + const prefixText = '─ '; + const labelWidth = Math.max(1, width - visibleWidth(prefixText) - 1); + const label = truncateToWidth(title + description + model, labelWidth); + const suffixWidth = Math.max(0, width - visibleWidth(prefixText) - visibleWidth(label)); + const suffix = suffixWidth === 0 ? '' : ` ${'─'.repeat(Math.max(0, suffixWidth - 1))}`; + return chalk.hex(this.colors.primary)(prefixText) + label + chalk.hex(this.colors.primary)(suffix); + } + + private renderStatusLine(width: number): string { + const status = totalStatus(this.members, { + failed: this.failed, + aborted: this.aborted, + }); + const prefix = this.renderActivityPrefix(status); + if (prefix.length > 0) { + const contentWidth = Math.max(0, width - visibleWidth(prefix)); + if (contentWidth <= 0) return truncateToWidth(prefix, width); + return truncateToWidth(`${prefix}${this.renderStatusLineContent(contentWidth, status)}`, width); + } + return this.renderStatusLineContent(width, status); + } + + private renderActivityPrefix(status: TotalStatus): string { + if (this.toolCallActive) return this.activitySpinnerText?.() ?? ''; + return activityPrefixForTotalStatus(status, this.colors); + } + + private renderStatusLineContent(width: number, status: TotalStatus): string { + if (status !== 'working') return this.renderProgressStatusLine(width, status); + + if (!this.inputComplete) { + return this.renderOrchestratingStatusLine(width); + } + + return this.renderProgressStatusLine(width, status); + } + + private renderProgressStatusLine(width: number, status: TotalStatus): string { + const label = renderStatusLabel( + totalStatusLabel(status), + totalStatusLabelColor(status, this.members, this.colors), + ); + if (this.members.length === 0) return truncateToWidth(label, width); + const barWidth = Math.max(0, width - visibleWidth(label) - TOTAL_STATUS_BAR_GAP); + if (barWidth <= 0) return truncateToWidth(label, width); + return truncateToWidth( + `${label}${' '.repeat(TOTAL_STATUS_BAR_GAP)}${renderStatusPipBar(this.members, barWidth, this.colors)}`, + width, + ); + } + + private renderOrchestratingStatusLine(width: number): string { + if (this.itemsStarted) { + return truncateToWidth( + renderStatusLabel(ORCHESTRATING_LABEL, this.colors.primary), + width, + ); + } + + const promptTemplate = collapseWhitespace(this.promptTemplateText); + const label = renderStatusLabel( + promptTemplate.length > 0 ? PROMPTING_LABEL : ORCHESTRATING_LABEL, + this.colors.primary, + ); + if (promptTemplate.length === 0) return truncateToWidth(label, width); + + const availablePromptWidth = Math.max( + 0, + width - visibleWidth(label) - PROMPTING_TEXT_TRAILING_GAP, + ); + const separator = visibleWidth(promptTemplate) <= availablePromptWidth - 1 ? ' ' : ' '; + const promptWidth = Math.max(0, availablePromptWidth - visibleWidth(separator)); + if (promptWidth <= 0) return truncateToWidth(label, width); + const prompt = chalk.hex(this.colors.textDim)(truncateStartToWidth(promptTemplate, promptWidth)); + return truncateToWidth(`${label}${separator}${prompt}`, width); + } + + private renderGrid( + width: number, + height: number | undefined, + snapshots: readonly AgentDynamicWorkflowSnapshot[], + nowMs: number, + ): string[] { + const layout = calculateAgentDynamicWorkflowGridLayout({ + width, + height: height ?? Number.POSITIVE_INFINITY, + count: this.members.length, + }); + const columns = Math.max(1, layout.columns); + const rows = layout.rows; + const cellGap = ' '.repeat(layout.columnGap); + const leftPadding = ' '.repeat(layout.leftPadding); + const lines: string[] = []; + + for (let row = 0; row < rows; row += 1) { + const cells: string[] = []; + for (let col = 0; col < columns; col += 1) { + const index = row * columns + col; + const member = this.members[index]; + const snapshot = snapshots[index]; + if (member === undefined || snapshot === undefined) continue; + cells.push(padAnsi(this.renderCell(member, snapshot, layout, nowMs), layout.cellWidth)); + } + lines.push(leftPadding + cells.join(cellGap)); + } + return lines; + } + + private renderCell( + member: AgentDynamicWorkflowMember, + snapshot: AgentDynamicWorkflowSnapshot, + layout: AgentDynamicWorkflowGridLayout, + nowMs: number, + ): string { + const width = layout.cellWidth; + if (snapshot.phase === 'pending') { + return renderPendingCell(member, width, this.colors); + } + if (snapshot.phase === 'cancelled' && snapshot.ticks <= 0) { + return renderCancelledUnstartedCell(member, width, this.colors); + } + if (!layout.renderText) { + return this.renderCompactCell(member, snapshot, layout.barCells, nowMs); + } + if (snapshot.phase === 'queued' && snapshot.ticks <= 0) { + return renderQueuedCell(member, width, this.colors); + } + + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: snapshot.phase, + capacityTicks: layout.barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + snapshot.phase, + layout.barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + const prefix = `${id} ${bar} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + const label = renderCellLabel(member, snapshot, labelWidth, this.colors); + return prefix + label; + } + + private renderCompactCell( + member: AgentDynamicWorkflowMember, + snapshot: AgentDynamicWorkflowSnapshot, + barCells: number, + nowMs: number, + ): string { + const estimatePhase = snapshot.phase === 'pending' ? 'queued' : snapshot.phase; + const estimate = this.progressEstimator.estimate({ + memberKey: member.id, + phase: estimatePhase, + capacityTicks: barCells * BRAILLE_LEVELS.length, + nowMs, + }); + const id = chalk.hex(this.colors.primary)(member.id); + const bar = brailleBar( + estimate.displayTicks, + estimatePhase, + barCells, + this.colors, + snapshot.phaseElapsedMs, + cancelledProgressColor(member, snapshot.phase, this.colors), + ); + return `${id} ${bar}${compactTerminalMark(member, snapshot.phase, this.colors)}`; + } + + private findMemberForSubagent( + agentId: string, + dynamicWorkflowIndex: number | undefined, + ): AgentDynamicWorkflowMember | undefined { + const existing = this.findMemberByAgentId(agentId); + if (existing !== undefined) return existing; + + if (dynamicWorkflowIndex !== undefined && Number.isInteger(dynamicWorkflowIndex) && dynamicWorkflowIndex > 0) { + this.ensureMemberCount(dynamicWorkflowIndex); + const byIndex = this.members[dynamicWorkflowIndex - 1]; + if (byIndex !== undefined) return byIndex; + } + + const unassigned = this.members.find((member) => member.agentId === undefined); + if (unassigned !== undefined) return unassigned; + + this.ensureMemberCount(this.members.length + 1); + return this.members.at(-1); + } + + private findMemberByAgentId(agentId: string): AgentDynamicWorkflowMember | undefined { + return this.members.find((member) => member.agentId === agentId); + } + + private ensureMemberCount(count: number): void { + if (count <= this.members.length) return; + const previousLength = this.members.length; + this.members = [ + ...this.members, + ...createMembers(count, this.inputComplete ? 'queued' : 'pending').slice(this.members.length), + ]; + const nowMs = Date.now(); + for (let index = previousLength; index < this.members.length; index += 1) { + const member = this.members[index]; + if (member !== undefined) this.progressEstimator.ensureMember(member.id, nowMs); + } + } + + private updateItemTexts(fullItems: readonly string[], partialItems: readonly string[]): void { + const count = Math.max(fullItems.length, partialItems.length, this.members.length); + for (let index = 0; index < count; index += 1) { + const member = this.members[index]; + if (member === undefined) continue; + const itemText = fullItems[index] ?? partialItems[index]; + if (itemText !== undefined) member.itemText = itemText; + } + } + + private startAnimationIfNeeded(): void { + if (this.requestRender === undefined || this.timer !== undefined) return; + if (!this.hasAnimatedMembers()) return; + const requestRender = this.requestRender; + this.timer = setInterval(() => { + requestRender(); + if (!this.hasAnimatedMembers()) this.dispose(); + }, FRAME_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } + } + + private hasAnimatedMembers(): boolean { + const now = Date.now(); + return ( + this.progressEstimator.hasPendingCatchup() || + this.members.some((member) => + ( + member.phase === 'completed' && + member.completedAtMs !== undefined && + now - member.completedAtMs < COMPLETE_FILL_MS + ) || + ( + member.phase === 'failed' && + member.failedAtMs !== undefined && + now - member.failedAtMs < COMPLETE_FILL_MS + ), + ) + ); + } + + private promoteToRunning(member: AgentDynamicWorkflowMember, nowMs?: number, setTicks = false): void { + if (member.phase === 'pending' || member.phase === 'queued' || member.phase === 'suspended') { + member.phase = 'running'; + if (nowMs !== undefined) this.progressEstimator.markStarted(member.id, nowMs); + if (setTicks) member.ticks = Math.max(member.ticks, 1); + } + delete member.suspendedReason; + } + + private completeMember(member: AgentDynamicWorkflowMember, nowMs: number, completedText?: string): void { + if (member.phase !== 'completed') { + this.progressEstimator.markCompleted(member.id, nowMs); + member.completedAtMs = nowMs; + } + const normalizedCompletedText = normalizeFinalOutputText(completedText); + if (normalizedCompletedText !== undefined) member.completedText = normalizedCompletedText; + member.phase = 'completed'; + clearMemberState(member, ...COMPLETED_CLEAR_KEYS); + } + + private failMember(member: AgentDynamicWorkflowMember, nowMs: number, failureText?: string): void { + if (member.phase !== 'failed') { + this.progressEstimator.markFailed(member.id, nowMs); + member.failedAtMs = nowMs; + } + const normalizedFailureText = normalizeFailureText(failureText); + if (normalizedFailureText !== undefined) member.failureText = normalizedFailureText; + member.phase = 'failed'; + clearMemberState(member, ...FAILED_CLEAR_KEYS); + } + + private cancelMember(member: AgentDynamicWorkflowMember, nowMs: number): void { + const previousPhase = member.phase; + this.progressEstimator.markCancelled(member.id, nowMs); + member.phase = 'cancelled'; + clearMemberState(member, ...CANCELLED_CLEAR_KEYS); + if (previousPhase === 'pending' || previousPhase === 'queued' || previousPhase === 'suspended') { + member.cancelledLabelText = CANCELLED_LABEL; + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else if (previousPhase === 'running') { + member.cancelledLabelText = runningCellLabelText(member); + member.cancelledLabelColor = cancelledLabelColor(this.colors); + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } else { + member.cancelledLabelText = ABORTED_LABEL; + member.cancelledLabelColor = this.colors.warning; + member.cancelledMarkColor = this.colors.warning; + member.cancelledBarColor = this.colors.warning; + } + } +} + +function createMembers(count: number, phase: AgentDynamicWorkflowPhase): AgentDynamicWorkflowMember[] { + return Array.from({ length: count }, (_item, index) => ({ + id: String(index + 1).padStart(3, '0'), + phase, + ticks: 0, + itemText: '', + latestModelText: '', + })); +} + +function clearMemberState(member: AgentDynamicWorkflowMember, ...keys: ClearableMemberKey[]): void { + for (const key of keys) delete member[key]; +} + +function isTerminalPhase(phase: AgentDynamicWorkflowPhase): boolean { + return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; +} + +function terminalPhaseElapsedMs(member: AgentDynamicWorkflowMember, nowMs: number): number { + const startedAtMs = member.phase === 'completed' + ? member.completedAtMs + : member.phase === 'failed' + ? member.failedAtMs + : undefined; + return startedAtMs === undefined ? 0 : Math.max(0, nowMs - startedAtMs); +} + +export function agentDynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] { + const items = args['items']; + if (!Array.isArray(items)) return []; + return items.map(String); +} + +function agentDynamicWorkflowResumeItemsFromArgs(args: Record<string, unknown>): string[] { + const resumeAgentIds = args['resume_agent_ids']; + if ( + typeof resumeAgentIds !== 'object' || + resumeAgentIds === null || + Array.isArray(resumeAgentIds) + ) { + return []; + } + return Object.keys(resumeAgentIds).map(() => RESUMED_ITEM_LABEL); +} + +export function agentDynamicWorkflowPartialItemsCountFromArguments(argumentsText: string): number { + return agentDynamicWorkflowPartialItemsFromArguments(argumentsText).length; +} + +function agentDynamicWorkflowWorkItemsStartedFromArguments(argumentsText: string): boolean { + return /"items"\s*:/.test(argumentsText) || /"resume_agent_ids"\s*:/.test(argumentsText); +} + +export function agentDynamicWorkflowPartialItemsFromArguments(argumentsText: string): string[] { + const match = /"items"\s*:\s*\[/.exec(argumentsText); + if (match === null) return []; + const items: string[] = []; + for (let i = match.index + match[0].length; i < argumentsText.length; i += 1) { + const ch = argumentsText[i]; + if (ch === ']') return items; + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(argumentsText, i + 1); + items.push(parsed.value); + if (parsed.closed) { + i = parsed.nextIndex; + continue; + } + return items; + } + return items; +} + +function agentDynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] { + const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText); + if (match === null) return []; + return Array.from( + { length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) }, + () => RESUMED_ITEM_LABEL, + ); +} + +export function agentDynamicWorkflowDescriptionFromArgs(args: Record<string, unknown>): string { + const description = args['description']; + return typeof description === 'string' ? description : ''; +} + +function agentDynamicWorkflowPromptTemplateFromArgs(args: Record<string, unknown>): string { + const promptTemplate = args['prompt_template']; + return typeof promptTemplate === 'string' ? promptTemplate : ''; +} + +function agentDynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string { + const match = /"prompt_template"\s*:\s*"/.exec(argumentsText); + if (match === null) return ''; + return parsePartialJsonString(argumentsText, match.index + match[0].length).value; +} + +export function agentDynamicWorkflowResultSummaryFromOutput(output: string): AgentDynamicWorkflowResultSummary { + const statuses = parseAgentDynamicWorkflowResultStatuses(output); + let completed = 0; + let failed = 0; + let aborted = 0; + for (const status of statuses) { + if (status.status === 'completed') completed += 1; + if (status.status === 'failed') failed += 1; + if (status.status === 'cancelled') aborted += 1; + } + return { + completed, + failed, + aborted, + parsed: statuses.length > 0, + }; +} + +function parseAgentDynamicWorkflowResultStatuses(output: string): AgentDynamicWorkflowResultStatus[] { + const xmlStatuses = parseAgentDynamicWorkflowXmlResultStatuses(output); + if (xmlStatuses.length > 0) return xmlStatuses; + return parseAgentDynamicWorkflowLegacyResultStatuses(output); +} + +function forEachSubagentTag<T>( + output: string, + callback: (attrs: string, body: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + const tagPattern = /<subagent\b([^>]*)>/g; + let match: RegExpExecArray | null; + let index = 0; + while ((match = tagPattern.exec(output)) !== null) { + const attrs = match[1] ?? ''; + const closeIndex = output.indexOf('</subagent>', tagPattern.lastIndex); + if (closeIndex < 0) break; + const body = output.slice(tagPattern.lastIndex, closeIndex); + index += 1; + const value = callback(attrs, body, index); + if (value !== undefined) result.push(value); + tagPattern.lastIndex = closeIndex + '</subagent>'.length; + } + return result; +} + +function parseAgentDynamicWorkflowXmlResultStatuses(output: string): AgentDynamicWorkflowResultStatus[] { + return forEachSubagentTag(output, (attrs, body, tagIndex) => { + const explicitIndex = Number(xmlAttribute(attrs, 'index')); + const index = + Number.isInteger(explicitIndex) && explicitIndex > 0 ? explicitIndex : tagIndex; + const outcome = xmlAttribute(attrs, 'outcome'); + if ( + outcome !== 'completed' && + outcome !== 'failed' && + outcome !== 'aborted' && + outcome !== 'cancelled' + ) { + return undefined; + } + return { + index, + status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome, + completedText: outcome === 'completed' ? body : undefined, + failureText: outcome === 'failed' ? body : undefined, + }; + }); +} + +function xmlAttribute(attrs: string, name: string): string | undefined { + const match = new RegExp(`\\b${name}="([^"]*)"`).exec(attrs); + return match?.[1]; +} + +function forEachAgentBlock<T>( + output: string, + callback: (block: string, index: number) => T | undefined, +): T[] { + const result: T[] = []; + for (const block of output.split(/\n(?=\[agent \d+\]\n)/)) { + const indexMatch = /^\[agent (\d+)\]$/m.exec(block); + if (indexMatch === null) continue; + const value = callback(block, Number(indexMatch[1])); + if (value !== undefined) result.push(value); + } + return result; +} + +function parseAgentDynamicWorkflowLegacyResultStatuses(output: string): AgentDynamicWorkflowResultStatus[] { + return forEachAgentBlock(output, (block, index) => { + const statusMatch = /^status: (completed|failed|aborted|cancelled)$/m.exec(block); + if (statusMatch === null) return undefined; + const status = statusMatch[1] as 'completed' | 'failed' | 'aborted' | 'cancelled'; + return { + index, + status: status === 'aborted' || status === 'cancelled' ? 'cancelled' : status, + completedText: status === 'completed' ? parseAgentDynamicWorkflowCompletedText(block) : undefined, + failureText: status === 'failed' ? parseAgentDynamicWorkflowFailureText(block) : undefined, + }; + }); +} + +function parseAgentDynamicWorkflowCompletedText(block: string): string | undefined { + const marker = '\n[summary]\n'; + const markerIndex = block.indexOf(marker); + if (markerIndex < 0) return undefined; + return normalizeFinalOutputText(block.slice(markerIndex + marker.length)); +} + +function parseAgentDynamicWorkflowFailureText(block: string): string | undefined { + const match = /^subagent error:\s*([\s\S]*)$/m.exec(block); + if (match === null) return undefined; + return normalizeFailureText(match[1]); +} + +function textGridLayout( + columns: number, + rows: number, + cellWidth: number, + gapWidth: number, + idWidth: number, +): AgentDynamicWorkflowGridLayout { + return { + renderText: true, + barCells: barCellsForTextCellWidth(cellWidth, idWidth), + columns, + rows, + cellWidth, + columnGap: gapWidth, + leftPadding: 0, + }; +} + +export function calculateAgentDynamicWorkflowGridLayout( + input: AgentDynamicWorkflowGridLayoutInput, +): AgentDynamicWorkflowGridLayout { + const count = Math.max(0, Math.floor(input.count)); + const width = Math.max(0, Math.floor(input.width)); + const height = Math.max(0, Math.floor(input.height)); + const idWidth = agentDynamicWorkflowGridIdWidth(count); + + if (count === 0) { + return { + renderText: true, + barCells: 1, + columns: 0, + rows: 0, + cellWidth: 0, + columnGap: 0, + leftPadding: 0, + }; + } + + const textGapWidth = visibleWidth(CELL_GAP); + const compactGapWidth = textGapWidth; + const textColumns = columnsForCellWidth(width, count, TEXT_CELL_PREFERRED_WIDTH, textGapWidth); + const textRows = rowsForColumns(count, textColumns); + const textCellWidth = gridCellWidth(width, textColumns, textGapWidth); + if (textRows <= height && textCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(textColumns, textRows, textCellWidth, textGapWidth, idWidth); + } + const targetTextColumns = height <= 0 ? count : Math.min(count, Math.ceil(count / height)); + const targetTextCellWidth = gridCellWidth(width, targetTextColumns, textGapWidth); + const targetTextRows = rowsForColumns(count, targetTextColumns); + if (height > 0 && targetTextRows <= height && targetTextCellWidth >= minTextCellWidth(idWidth)) { + return textGridLayout(targetTextColumns, targetTextRows, targetTextCellWidth, textGapWidth, idWidth); + } + + const compactColumns = compactColumnsForLayout(width, count, height, idWidth, compactGapWidth); + const compactCellWidthBudget = gridCellWidth(width, compactColumns, compactGapWidth); + const compactBarCells = compactBarCellsForCellWidth(compactCellWidthBudget, idWidth); + const compactActualCellWidth = compactCellWidth(idWidth, compactBarCells); + return { + renderText: false, + barCells: compactBarCells, + columns: compactColumns, + rows: rowsForColumns(count, compactColumns), + cellWidth: compactActualCellWidth, + columnGap: compactGapWidth, + leftPadding: 0, + }; +} + +export function agentDynamicWorkflowGridHeightForTerminalRows( + rows: number | undefined, + followingRows = 0, +): number | undefined { + if (rows === undefined || !Number.isFinite(rows)) return undefined; + const rowsAfterDynamicWorkflow = Number.isFinite(followingRows) + ? Math.max(0, Math.floor(followingRows)) + : 0; + return Math.max(0, Math.floor(rows) - rowsAfterDynamicWorkflow - AGENT_DYNAMIC_WORKFLOW_NON_GRID_LINES); +} + +function agentDynamicWorkflowGridIdWidth(count: number): number { + return Math.max(3, String(Math.max(1, count)).length); +} + +function columnsForCellWidth( + width: number, + count: number, + cellWidth: number, + gapWidth: number, +): number { + if (count <= 1) return count <= 0 ? 0 : 1; + const columns = Math.floor((width + gapWidth) / (Math.max(1, cellWidth) + gapWidth)); + return Math.max(1, Math.min(count, columns)); +} + +function rowsForColumns(count: number, columns: number): number { + if (count <= 0) return 0; + return Math.ceil(count / Math.max(1, columns)); +} + +function gridCellWidth(width: number, columns: number, gapWidth: number): number { + if (columns <= 0) return 0; + return Math.max( + 1, + Math.floor((width - gapWidth * Math.max(0, columns - 1)) / columns), + ); +} + +function minTextCellWidth(idWidth: number): number { + return idWidth + TEXT_BRAILLE_BAR_MIN_WIDTH + 4 + MIN_LABEL_WIDTH; +} + +function barCellsForTextCellWidth(cellWidth: number, idWidth: number): number { + const fixedWidth = idWidth + 1 + 2 + 1 + MIN_LABEL_WIDTH; + const availableForBar = cellWidth - fixedWidth; + return availableForBar >= TEXT_BRAILLE_BAR_MIN_WIDTH + ? Math.min(BRAILLE_BAR_MAX_WIDTH, availableForBar) + : TEXT_BRAILLE_BAR_MIN_WIDTH; +} + +function compactColumnsForLayout( + width: number, + count: number, + height: number, + idWidth: number, + gapWidth: number, +): number { + const maxColumns = columnsForCellWidth(width, count, compactCellWidth(idWidth, 1), gapWidth); + if (height <= 0) return maxColumns; + const targetColumns = Math.min(count, Math.ceil(count / height)); + return Math.max(1, Math.min(targetColumns, maxColumns)); +} + +function compactBarCellsForCellWidth(cellWidth: number, idWidth: number): number { + return Math.max( + 1, + cellWidth - compactFixedWidth(idWidth) - COMPACT_TERMINAL_MARK_WIDTH, + ); +} + +function compactCellWidth(idWidth: number, barCells: number): number { + return compactFixedWidth(idWidth) + Math.max(1, barCells) + COMPACT_TERMINAL_MARK_WIDTH; +} + +function compactFixedWidth(idWidth: number): number { + return idWidth + 1 + 2; +} + +function summarizeSnapshots(snapshots: readonly AgentDynamicWorkflowSnapshot[]): AgentDynamicWorkflowSummary { + let completed = 0; + let failed = 0; + let cancelled = 0; + for (const snapshot of snapshots) { + if (snapshot.phase === 'completed') completed += 1; + if (snapshot.phase === 'failed') failed += 1; + if (snapshot.phase === 'cancelled') cancelled += 1; + } + return { + active: snapshots.length - completed - failed - cancelled, + completed, + failed, + cancelled, + }; +} + +function brailleBar( + ticks: number, + phase: AgentDynamicWorkflowPhase, + width: number, + colors: ColorPalette, + phaseElapsedMs: number, + phaseColorOverride?: string, +): string { + const innerWidth = Math.max(1, width); + if (phase === 'pending') return ''; + if (phase === 'failed') return bracketBar(failedBrailleBar(ticks, innerWidth, phaseElapsedMs, colors), colors); + const displayTicks = phase === 'completed' ? completedDisplayTicks(ticks, innerWidth, phaseElapsedMs) : ticks; + if (phase === 'cancelled') { + const cancelledColor = phaseColorOverride ?? colors.warning; + return bracketBar( + accumulatedBrailleBar(displayTicks, innerWidth, cancelledColor, colors, () => cancelledColor), + colors, + ); + } + const colorMap: Record<Exclude<AgentDynamicWorkflowPhase, 'pending' | 'failed' | 'cancelled'>, string> = { + queued: colors.textDim, + suspended: colors.textDim, + running: colors.success, + completed: colors.success, + }; + return bracketBar(accumulatedBrailleBar(displayTicks, innerWidth, colorMap[phase], colors), colors); +} + +function cancelledProgressColor( + member: AgentDynamicWorkflowMember, + phase: AgentDynamicWorkflowPhase, + colors: ColorPalette, +): string | undefined { + if (phase !== 'cancelled') return undefined; + return member.cancelledBarColor ?? colors.warning; +} + +function bracketBar(content: string, colors: ColorPalette): string { + const bracket = chalk.hex(colors.textMuted); + return bracket('[') + content + bracket(']'); +} + +function phaseColor(phase: AgentDynamicWorkflowPhase, colors: ColorPalette): string { + const map: Record<AgentDynamicWorkflowPhase, string> = { + pending: colors.textDim, + queued: colors.textDim, + suspended: colors.textDim, + running: colors.textDim, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +interface StatusBarCount { + readonly phase: StatusBarPhase; + readonly count: number; +} + +function renderStatusPipBar( + members: readonly AgentDynamicWorkflowMember[], + width: number, + colors: ColorPalette, +): string { + const safeWidth = Math.max(1, width); + const counts = statusBarCounts(members); + if (counts.length === 0) { + return chalk.hex(colors.textMuted)(STATUS_BAR_CHAR.repeat(safeWidth)); + } + + const segmentWidths = allocateSegmentWidths(counts.map((entry) => entry.count), safeWidth); + return counts.map((entry, index) => { + const segmentWidth = segmentWidths[index] ?? 0; + if (segmentWidth <= 0) return ''; + return chalk.hex(statusBarColor(entry.phase, colors))(STATUS_BAR_CHAR.repeat(segmentWidth)); + }).join(''); +} + +function renderStatusLabel(label: string, color: string): string { + return ` ${chalk.hex(color)(label)}`; +} + +function activityPrefixForTotalStatus(status: TotalStatus, colors: ColorPalette): string { + const marks: Record<TotalStatus, string> = { + completed: SUCCESS_MARK.trimEnd(), + failed: FAILURE_MARK.trimEnd(), + aborted: CANCELLED_MARK.trimEnd(), + working: '', + suspended: '', + }; + const mark = marks[status]; + return mark.length > 0 + ? ` ${chalk.hex(totalStatusColor(status, colors))(mark)}` + : ACTIVITY_SPINNER_PLACEHOLDER; +} + +function statusBarCounts(members: readonly AgentDynamicWorkflowMember[]): StatusBarCount[] { + const counts = new Map<StatusBarPhase, number>(); + for (const member of members) { + const phase = statusBarPhase(member.phase); + counts.set(phase, (counts.get(phase) ?? 0) + 1); + } + return STATUS_BAR_ORDER.flatMap((phase) => { + const count = counts.get(phase) ?? 0; + return count > 0 ? [{ phase, count }] : []; + }); +} + +function statusBarPhase(phase: AgentDynamicWorkflowPhase): StatusBarPhase { + const map: Record<AgentDynamicWorkflowPhase, StatusBarPhase> = { + pending: 'queued', + queued: 'queued', + suspended: 'suspended', + running: 'working', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + }; + return map[phase]; +} + +function statusBarColor(phase: StatusBarPhase, colors: ColorPalette): string { + const map: Record<StatusBarPhase, string> = { + queued: colors.textMuted, + working: colors.primary, + suspended: colors.textMuted, + completed: colors.success, + failed: colors.error, + cancelled: colors.warning, + }; + return map[phase]; +} + +function totalStatus( + members: readonly AgentDynamicWorkflowMember[], + force: { readonly failed: boolean; readonly aborted: boolean }, +): TotalStatus { + if (force.aborted) return 'aborted'; + const phases = new Set(members.map((m) => m.phase)); + const hasActive = phases.has('pending') || phases.has('queued') || phases.has('suspended') || phases.has('running'); + if (!hasActive && members.length > 0) { + if (phases.has('cancelled')) return 'aborted'; + if (phases.has('completed')) return 'completed'; + return 'failed'; + } + if (force.failed) return 'failed'; + if (phases.has('suspended') && !phases.has('running')) return 'suspended'; + return 'working'; +} + +function totalStatusLabel(status: TotalStatus): string { + const map: Record<TotalStatus, string> = { + working: WORKING_LABEL, + completed: COMPLETED_LABEL, + suspended: SUSPENDED_LABEL, + failed: FAILED_LABEL, + aborted: ABORTED_LABEL, + }; + return map[status]; +} + +function totalStatusColor(status: TotalStatus, colors: ColorPalette): string { + const map: Record<TotalStatus, string> = { + working: colors.success, + completed: colors.success, + suspended: colors.textDim, + failed: colors.error, + aborted: colors.warning, + }; + return map[status]; +} + +function totalStatusLabelColor( + status: TotalStatus, + members: readonly AgentDynamicWorkflowMember[], + colors: ColorPalette, +): string { + if (status === 'working' && !members.some((member) => member.phase === 'completed')) { + return colors.primary; + } + return totalStatusColor(status, colors); +} + +function allocateSegmentWidths(counts: readonly number[], width: number): number[] { + const total = counts.reduce((sum, count) => sum + count, 0); + if (total <= 0 || width <= 0) return counts.map(() => 0); + + const exact = counts.map((count) => count * width / total); + const widths = exact.map(Math.floor); + let remaining = width - widths.reduce((sum, value) => sum + value, 0); + const order = exact + .map((value, index) => ({ index, fraction: value - Math.floor(value) })) + .toSorted((a, b) => b.fraction - a.fraction || a.index - b.index); + + for (const entry of order) { + if (remaining <= 0) break; + widths[entry.index] = (widths[entry.index] ?? 0) + 1; + remaining -= 1; + } + return widths; +} + +function renderCellLabel( + member: AgentDynamicWorkflowMember, + snapshot: AgentDynamicWorkflowSnapshot, + width: number, + colors: ColorPalette, +): string { + const latestLine = latestNonEmptyLine(snapshot.latestModelText); + if (snapshot.phase === 'running') { + return truncateWithColor(runningCellLabelText(member), width, colors.textDim); + } + if (snapshot.phase === 'failed' && member.failureText !== undefined) { + return truncateWithColor(`${FAILURE_MARK}${member.failureText}`, width, colors.error); + } + if (snapshot.phase === 'completed') { + return renderCompletedCellLabel(member.completedText ?? latestLine, width, colors); + } + if (snapshot.phase === 'cancelled') { + return renderCancelledCellLabel(member, width, colors); + } + return truncateWithColor(PHASE_LABELS[snapshot.phase], width, phaseColor(snapshot.phase, colors)); +} + +function runningCellLabelText(member: AgentDynamicWorkflowMember): string { + const latestLine = latestNonEmptyLine(member.latestModelText); + const itemText = collapseWhitespace(member.itemText); + const text = latestLine.length > 0 ? latestLine : itemText; + return text.length > 0 ? text : PHASE_LABELS.running; +} + +function renderCancelledCellLabel( + member: AgentDynamicWorkflowMember, + width: number, + colors: ColorPalette, +): string { + const labelText = member.cancelledLabelText ?? ABORTED_LABEL; + const labelColor = member.cancelledLabelColor ?? colors.warning; + const markColor = member.cancelledMarkColor ?? colors.warning; + const labelStyle = chalk.hex(labelColor); + return truncateToWidth( + chalk.hex(markColor)(CANCELLED_MARK) + labelStyle(labelText), + width, + labelStyle('…'), + ); +} + +function renderCompletedCellLabel( + text: string, + width: number, + colors: ColorPalette, +): string { + const finalText = normalizeFinalOutputText(text); + const label = finalText === undefined ? SUCCESS_MARK.trimEnd() : `${SUCCESS_MARK}${finalText}`; + return truncateWithColor(label, width, colors.success); +} + +function compactTerminalMark( + member: AgentDynamicWorkflowMember, + phase: AgentDynamicWorkflowPhase, + colors: ColorPalette, +): string { + if (phase === 'completed') return chalk.hex(colors.success)(SUCCESS_MARK.trimEnd()); + if (phase === 'failed') return chalk.hex(colors.error)(FAILURE_MARK.trimEnd()); + if (phase === 'cancelled') { + return chalk.hex(member.cancelledMarkColor ?? colors.warning)(CANCELLED_MARK.trimEnd()); + } + return ''; +} + +function renderPendingCell( + member: AgentDynamicWorkflowMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const itemText = collapseWhitespace(member.itemText); + const label = itemText.length > 0 ? itemText : QUEUED_LABEL; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(label, labelWidth, colors.textDim); +} + +function renderQueuedCell( + member: AgentDynamicWorkflowMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + truncateWithColor(QUEUED_LABEL, labelWidth, colors.textDim); +} + +function renderCancelledUnstartedCell( + member: AgentDynamicWorkflowMember, + width: number, + colors: ColorPalette, +): string { + const id = chalk.hex(colors.primary)(member.id); + const prefix = `${id} `; + const labelWidth = Math.max(1, width - visibleWidth(prefix)); + return prefix + renderCancelledCellLabel(member, labelWidth, colors); +} + +function truncateWithColor(text: string, width: number, color: string): string { + const colorize = chalk.hex(color); + return truncateToWidth(colorize(text), width, colorize('…')); +} + +function truncateStartToWidth(text: string, width: number): string { + if (visibleWidth(text) <= width) return text; + const ellipsis = '…'; + const ellipsisWidth = visibleWidth(ellipsis); + if (width <= ellipsisWidth) return truncateToWidth(ellipsis, width); + + const targetWidth = width - ellipsisWidth; + const segments = Array.from(text); + let tail = ''; + let tailWidth = 0; + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] ?? ''; + const segmentWidth = visibleWidth(segment); + if (tailWidth + segmentWidth > targetWidth) break; + tail = segment + tail; + tailWidth += segmentWidth; + } + return ellipsis + tail; +} + +function collapseWhitespace(text: string): string { + return text.replaceAll(/\s+/g, ' ').trim(); +} + +function normalizeFailureText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const nestedFailureText = nestedAgentDynamicWorkflowFailureText(text); + const normalized = stripAgentDynamicWorkflowPrefix(collapseWhitespace(nestedFailureText ?? text)); + return normalized.length > 0 ? normalized : undefined; +} + +function nestedAgentDynamicWorkflowFailureText(text: string): string | undefined { + const xmlFailureText = nestedAgentDynamicWorkflowXmlFailureText(text); + if (xmlFailureText !== undefined) return nestedAgentDynamicWorkflowFailureText(xmlFailureText) ?? xmlFailureText; + + if (!/^\s*agent_dynamic_workflow:\s*failed\b/m.test(text)) return undefined; + const match = /^\s*subagent error:\s*([\s\S]*?)(?=\n\[agent \d+\]\n|$)/m.exec(text); + if (match === null) return undefined; + const failureText = match[1]; + if (failureText === undefined) return undefined; + return nestedAgentDynamicWorkflowFailureText(failureText) ?? failureText; +} + +function nestedAgentDynamicWorkflowXmlFailureText(text: string): string | undefined { + if (!/<agent_dynamic_workflow_result\b/.test(text)) return undefined; + const failed = parseAgentDynamicWorkflowXmlResultStatuses(text).find((entry) => { + return entry.status === 'failed' && entry.failureText !== undefined; + }); + return failed?.failureText; +} + +function stripAgentDynamicWorkflowPrefix(text: string): string { + return text.replace(/^agent_dynamic_workflow:\s*(?:failed|completed)?\s*/i, '').trim(); +} + +function normalizeFinalOutputText(text: string | undefined): string | undefined { + if (text === undefined) return undefined; + const normalized = collapseWhitespace(text); + return normalized.length > 0 ? normalized : undefined; +} + +function latestNonEmptyLine(text: string): string { + const lines = text.split(/\r?\n/); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = collapseWhitespace(lines[index] ?? ''); + if (line.length > 0) return line; + } + return ''; +} + +function countPartialJsonObjectEntries(text: string, startIndex: number): number { + let count = 0; + let expectKey = true; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '}') return count; + if (ch === ',') { + expectKey = true; + continue; + } + if (ch !== '"') continue; + + const parsed = parsePartialJsonString(text, i + 1); + if (expectKey) { + if (parsed.closed || parsed.value.length > 0) count += 1; + expectKey = false; + } + if (!parsed.closed) return count; + i = parsed.nextIndex; + } + return count; +} + +function parsePartialJsonString( + text: string, + startIndex: number, +): { value: string; closed: boolean; nextIndex: number } { + let value = ''; + for (let i = startIndex; i < text.length; i += 1) { + const ch = text[i]; + if (ch === '"') return { value, closed: true, nextIndex: i }; + if (ch !== '\\') { + value += ch; + continue; + } + + const escaped = text[i + 1]; + if (escaped === undefined) return { value, closed: false, nextIndex: i }; + switch (escaped) { + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + case 'b': value += '\b'; break; + case 'f': value += '\f'; break; + case '"': + case '\\': + case '/': + value += escaped; + break; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (hex.length < 4) return { value, closed: false, nextIndex: i }; + const code = Number.parseInt(hex, 16); + if (Number.isNaN(code)) return { value, closed: false, nextIndex: i }; + value += String.fromCodePoint(code); + i += 4; + break; + } + default: + value += escaped; + } + i += 1; + } + return { value, closed: false, nextIndex: text.length }; +} + +function padAnsi(text: string, width: number): string { + const truncated = truncateToWidth(text, width); + return truncated + ' '.repeat(Math.max(0, width - visibleWidth(truncated))); +} + +function completedDisplayTicks(ticks: number, width: number, phaseElapsedMs: number): number { + const fullBarTicks = width * BRAILLE_LEVELS.length; + if (ticks >= fullBarTicks) return fullBarTicks; + const fillProgress = Math.max(0, Math.min(1, phaseElapsedMs / COMPLETE_FILL_MS)); + return Math.min(fullBarTicks, Math.ceil(ticks + (fullBarTicks - ticks) * fillProgress)); +} + +function failedBrailleBar( + ticks: number, + width: number, + phaseElapsedMs: number, + colors: ColorPalette, +): string { + const redCellCount = Math.ceil( + completedDisplayTicks(ticks, width, phaseElapsedMs) / BRAILLE_LEVELS.length, + ); + const placeholderColor = darkenRedHexColor(colors.error); + return accumulatedBrailleBar( + ticks, + width, + colors.error, + colors, + (cellIndex) => cellIndex < redCellCount ? placeholderColor : colors.textDim, + ); +} + +function darkenRedHexColor(hex: string): string { + return darkenHexColor( + hex, + FAILED_PLACEHOLDER_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + FAILED_PLACEHOLDER_NON_RED_FACTOR, + ); +} + +function cancelledLabelColor(colors: ColorPalette): string { + return darkenHexColor(colors.warning, CANCELLED_LABEL_DARKEN_FACTOR); +} + +function darkenHexColor( + hex: string, + redFactor: number, + greenFactor = redFactor, + blueFactor = redFactor, +): string { + const match = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex); + if (match === null) return hex; + const darken = (channel: string, factor: number): string => + Math.max(0, Math.min(255, Math.round(Number.parseInt(channel, 16) * factor))) + .toString(16) + .padStart(2, '0'); + return `#${darken(match[1]!, redFactor)}${darken(match[2]!, greenFactor)}${darken( + match[3]!, + blueFactor, + )}`; +} + +function accumulatedBrailleBar( + ticks: number, + width: number, + filledColor: string, + colors: ColorPalette, + emptyColorForCell?: (cellIndex: number) => string, +): string { + const dotsPerCell = BRAILLE_LEVELS.length; + const cycleSize = width * dotsPerCell; + const safeTicks = Math.max(0, Math.ceil(ticks)); + const completedCycles = Math.floor(safeTicks / cycleSize); + const cycleTicks = safeTicks % cycleSize; + const activeCells = cycleTicks === 0 ? 0 : Math.ceil(cycleTicks / dotsPerCell); + const separatorIndex = completedCycles > 0 && activeCells > 0 && activeCells < width + ? activeCells + : -1; + + let out = ''; + let pending = ''; + let pendingColor: string | undefined; + const flush = (): void => { + if (pending.length === 0 || pendingColor === undefined) return; + out += chalk.hex(pendingColor)(pending); + pending = ''; + }; + const append = (char: string, color: string): void => { + if (pendingColor !== color) { + flush(); + pendingColor = color; + } + pending += char; + }; + + for (let i = 0; i < width; i += 1) { + if (i === separatorIndex) { + append(BRAILLE_RIGHT_COLUMN_FULL, filledColor); + continue; + } + + const cellStart = i * dotsPerCell; + const countThisCycle = Math.max(0, Math.min(dotsPerCell, cycleTicks - cellStart)); + const count = countThisCycle > 0 ? countThisCycle : completedCycles > 0 ? dotsPerCell : 0; + append( + count === 0 ? BRAILLE_EMPTY : BRAILLE_LEVELS[count - 1]!, + count === 0 ? emptyColorForCell?.(i) ?? colors.textDim : filledColor, + ); + } + flush(); + return out; +} diff --git a/apps/pythinker-code/src/tui/components/messages/agent-group.ts b/apps/pythinker-code/src/tui/components/messages/agent-group.ts index b38c31f4..2008ff40 100644 --- a/apps/pythinker-code/src/tui/components/messages/agent-group.ts +++ b/apps/pythinker-code/src/tui/components/messages/agent-group.ts @@ -15,11 +15,9 @@ * - Ungrouping is not implemented. Once formed, a group stays grouped. */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@pymodel/pi-tui'; +import { Container, Spacer, Text } from '@pymodel/pi-tui'; -import { MarkdownPreviewComponent } from '#/tui/components/messages/markdown-preview'; -import { formatThinkingSpinnerLabel } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { formatTokenCount } from '#/utils/usage/usage-format'; @@ -28,6 +26,8 @@ import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; const THROTTLE_MS = 200; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + interface AgentEntry { readonly toolCallId: string; readonly tc: ToolCallComponent; @@ -43,16 +43,10 @@ interface PhaseCounts { readonly terminal: number; } -interface ActivityPreview { - readonly isLast: boolean; - readonly component: MarkdownPreviewComponent; -} - export class AgentGroupComponent extends Container { private readonly entries: AgentEntry[] = []; private readonly headerText: Text; private readonly bodyContainer: Container; - private readonly activityPreviews = new Map<string, ActivityPreview>(); private throttleTimer: ReturnType<typeof setTimeout> | null = null; private lastFlushPhases = new Map<string, ToolCallSubagentSnapshot['phase']>(); private _invalidating = false; @@ -140,6 +134,9 @@ export class AgentGroupComponent extends Container { const isLast = idx === snapshots.length - 1; this.appendLines(snap, isLast); }); + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } this.lastFlushPhases.clear(); this.entries.forEach((entry, i) => { @@ -188,7 +185,7 @@ export class AgentGroupComponent extends Container { const agentType = snap.agentName ?? 'agent'; const desc = snap.toolCallDescription || '(no description)'; const tail = formatLineTail(snap); - const namePart = currentTheme.fg('textStrong', agentType); + const namePart = currentTheme.fg('primary', agentType); const descPart = dim(`· ${desc}`); const stats = formatStats(snap); const line1 = ` ${branch1} ${namePart} ${descPart}${stats}${tail}`; @@ -207,25 +204,24 @@ export class AgentGroupComponent extends Container { // Terminal states omit the second line. return; } - // Running or not-yet-started agents show the latest Markdown activity row. + // Running or not-yet-started agents show latest activity, with a fallback. const activity = snap.latestActivity ?? fallbackActivityForPhase(snap.phase); - let preview = this.activityPreviews.get(snap.toolCallId); - if (preview === undefined || preview.isLast !== isLast) { - const prefix = ` ${branch2} `; - preview = { - isLast, - component: new MarkdownPreviewComponent(activity, { - firstPrefix: prefix, - continuationPrefix: prefix, - tailRows: 1, - appearance: 'dim', - }), - }; - this.activityPreviews.set(snap.toolCallId, preview); - } else { - preview.component.setText(activity); - } - this.bodyContainer.addChild(preview.component); + this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); + } + + /** + * Show the Ctrl+B hint while at least one agent in the group is still + * running in the foreground (i.e. can be detached). Hide it once every + * agent is done, failed, or already backgrounded. + */ + private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean { + return snapshots.some( + (s) => + s.phase === 'running' || + s.phase === 'queued' || + s.phase === 'spawning' || + s.phase === undefined, + ); } /** Releases throttle timers so destroyed components cannot refresh later. */ @@ -305,7 +301,10 @@ function formatBreakdownParts(counts: PhaseCounts): string[] { } function formatStats(snap: ToolCallSubagentSnapshot): string { - const parts = [`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`]; + const parts: string[] = []; + if (snap.model !== undefined) parts.push(snap.model); + if (snap.effort !== undefined) parts.push(snap.effort); + parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`); if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds)); if (snap.tokens > 0) parts.push(formatTokens(snap.tokens)); return currentTheme.dim(` · ${parts.join(' · ')}`); @@ -335,7 +334,7 @@ function fallbackActivityForPhase(phase: ToolCallSubagentSnapshot['phase']): str case 'queued': return 'Waiting to start…'; case 'running': - return formatThinkingSpinnerLabel(); + return 'Still working…'; case 'spawning': case undefined: return 'Starting…'; diff --git a/apps/pythinker-code/src/tui/components/messages/assistant-message.ts b/apps/pythinker-code/src/tui/components/messages/assistant-message.ts index 29859337..dad01676 100644 --- a/apps/pythinker-code/src/tui/components/messages/assistant-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/assistant-message.ts @@ -1,50 +1,103 @@ /** - * Renders an assistant message using the Pythinker markdown theme. + * Renders an assistant message using pi-tui Markdown. * * Displays a white bullet prefix with markdown content indented * to align after the bullet. */ -import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Container, Markdown, truncateToWidth, visibleWidth, type Component } from '@pymodel/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; -import { createPythinkerMarkdownTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; +import { markOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; + +type AssistantMarkdownOptions = { + transient?: boolean; +}; export class AssistantMessageComponent implements Component { private contentContainer: Container; + private markdown: Markdown | undefined; + private markdownTransient = false; private lastText = ''; + private lastTransient = false; private showBullet: boolean; + private renderCache: { width: number; lines: string[] } | undefined; + constructor(showBullet: boolean = true) { this.showBullet = showBullet; this.contentContainer = new Container(); } + private markRenderDirty(): void { + this.renderCache = undefined; + } + setShowBullet(show: boolean): void { + if (this.showBullet === show) return; this.showBullet = show; + this.markRenderDirty(); } - updateContent(text: string): void { - const displayText = text; - if (displayText === this.lastText) return; + updateContent(text: string, opts?: AssistantMarkdownOptions): void { + const displayText = text.trim(); + const transient = opts?.transient === true; + + if (displayText === this.lastText && transient === this.lastTransient) return; + this.lastText = displayText; - this.contentContainer.clear(); - if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, createPythinkerMarkdownTheme())); + this.lastTransient = transient; + this.markRenderDirty(); + + if (displayText.length === 0) { + this.contentContainer.clear(); + this.markdown = undefined; + this.markdownTransient = false; + return; } + + if (this.markdown === undefined || this.markdownTransient !== transient) { + this.contentContainer.clear(); + this.markdown = new Markdown( + displayText, + 0, + 0, + createMarkdownTheme({ transient }), + undefined, + createMarkdownOptions(), + ); + this.markdownTransient = transient; + this.contentContainer.addChild(this.markdown); + return; + } + + this.markdown.setText(displayText); } invalidate(): void { // Markdown caches ANSI colour codes keyed on (text, width). When the // theme changes the cached strings contain stale colours, so we rebuild - // the Markdown child with the new theme. + // the Markdown child with the new theme while preserving transient mode. + this.markRenderDirty(); this.contentContainer.clear(); + this.markdown = undefined; + if (this.lastText.trim().length > 0) { - this.contentContainer.addChild( - new Markdown(this.lastText.trim(), 0, 0, createPythinkerMarkdownTheme()), + this.markdown = new Markdown( + this.lastText.trim(), + 0, + 0, + createMarkdownTheme({ transient: this.lastTransient }), + undefined, + createMarkdownOptions(), ); + this.markdownTransient = this.lastTransient; + this.contentContainer.addChild(this.markdown); } } @@ -54,6 +107,14 @@ export class AssistantMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + const prefix = this.showBullet ? STATUS_BULLET : MESSAGE_INDENT; const contentWidth = Math.max(1, safeWidth - visibleWidth(prefix)); const contentLines = this.contentContainer.render(contentWidth); @@ -64,6 +125,10 @@ export class AssistantMessageComponent implements Component { i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = markOsc133Zone(lines.map((line) => truncateToWidth(line, safeWidth, '…'))); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } diff --git a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts index 994f4fbb..8813f6ae 100644 --- a/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/components/messages/background-agent-status.ts @@ -1,4 +1,4 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@pymodel/pi-tui'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; @@ -15,22 +15,17 @@ export class BackgroundAgentStatusComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - // Only the bullet carries the status. A background task is ambient — it is - // not what the user asked for — so the wording stays dim and the eye picks - // the line out by colour of the dot alone, never by a fully coloured line. - const bulletTone: keyof ColorPalette = + const tone: keyof ColorPalette = this.data.phase === 'started' - ? 'textDim' + ? 'primary' : this.data.phase === 'completed' ? 'success' : 'error'; const bullet = - this.data.phase === 'failed' - ? currentTheme.fg(bulletTone, FAILURE_MARK) - : currentTheme.fg(bulletTone, STATUS_BULLET); + this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); const text = - currentTheme.fg('textDim', this.data.headline) + + currentTheme.fg(tone, this.data.headline) + (this.data.detail !== undefined && this.data.detail.length > 0 ? currentTheme.fg('textDim', ` (${this.data.detail})`) : ''); diff --git a/apps/pythinker-code/src/tui/components/messages/cron-message.ts b/apps/pythinker-code/src/tui/components/messages/cron-message.ts index 5ca2acf0..18f6bc00 100644 --- a/apps/pythinker-code/src/tui/components/messages/cron-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/cron-message.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Spacer, Text, visibleWidth } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { Spacer, Text, visibleWidth } from '@pymodel/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-markers.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-markers.ts index 2fe4c2cb..ac5a8d55 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-markers.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-markers.ts @@ -1,4 +1,4 @@ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@pymodel/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -24,10 +24,10 @@ export class DynamicWorkflowModeMarkerComponent implements Component { function dynamicWorkflowMarkerLabel(state: DynamicWorkflowModeMarkerState): string { switch (state) { case 'active': - return 'Dynamic Workflow activated'; + return 'DynamicWorkflow activated'; case 'inactive': - return 'Dynamic Workflow deactivated'; + return 'DynamicWorkflow deactivated'; case 'ended': - return 'Dynamic Workflow ended'; + return 'DynamicWorkflow ended'; } } diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts deleted file mode 100644 index f5392a5e..00000000 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ /dev/null @@ -1,1221 +0,0 @@ -import { truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; - -import { - BRAILLE_SPINNER_FRAMES, - BRAILLE_SPINNER_INTERVAL_MS, - DYNAMIC_WORKFLOW_RENDERING, -} from '#/tui/constant/rendering'; -import { currentTheme } from '#/tui/theme'; -import { shimmerText } from '#/tui/utils/shimmer'; - -const RESUMED_ITEM_LABEL = '(resumed)'; -/** Divider between the cells that share a member row's free space. */ -const MEMBER_SEPARATOR = ' · '; -/** Marks a task cell whose shared preamble was dropped. One column wide. */ -const TASK_ELISION_MARK = '…'; -const ORCHESTRATING_LABEL = 'Orchestrating'; -const FINALIZING_LABEL = 'Finalizing'; -// Pad to the wider live label so the suffix column never shifts between them. -const LIVE_LABEL_WIDTH = Math.max( - visibleWidth(ORCHESTRATING_LABEL), - visibleWidth(FINALIZING_LABEL), -); -const MAX_DYNAMIC_WORKFLOW_MEMBERS = 128; - -/** Lifecycle state of one delegated agent row, driven only by observed events. */ -export type DynamicWorkflowPhase = - | 'pending' - | 'queued' - | 'running' - | 'suspended' - | 'completed' - | 'failed' - | 'cancelled'; - -/** Overall request state: collecting input, actively running, or finished. */ -export type DynamicWorkflowRequestPhase = - | 'collecting' - | 'active' - | 'completed' - | 'failed' - | 'cancelled'; - -export interface DynamicWorkflowMember { - index: number; - agentId?: string; - item: string; - phase: DynamicWorkflowPhase; - latest: string; - /** - * The part of the streamed line that has not been closed by a newline yet. - * Held apart from `latest` because `latest` may be a finished line or a tool - * label, and neither may be prepended to the next delta. - */ - carry: string; - statusDetail?: string; - startedAtMs?: number; - endedAtMs?: number; -} - -export interface DynamicWorkflowActivity { - relativeMs: number; - source: number | 'SYS'; - text: string; -} - -/** Renderer-owned state for one Dynamic Workflow tool call. */ -export interface DynamicWorkflowModel { - requestPhase: DynamicWorkflowRequestPhase; - inputComplete: boolean; - toolCallActive: boolean; - /** Accepted input count once `markInputComplete()` ran; drives aggregate counts. */ - knownTotal?: number; - description?: string; - promptTemplate?: string; - itemsStarted: number; - startedAtMs: number; - endedAtMs?: number; - members: DynamicWorkflowMember[]; - recentActivity: DynamicWorkflowActivity[]; -} - -interface DynamicWorkflowResultStatus { - readonly index: number; - readonly agentId?: string; - readonly item?: string; - readonly status: 'completed' | 'failed' | 'cancelled'; - readonly detail?: string; -} - -export interface DynamicWorkflowResultSummary { - readonly completed: number; - readonly failed: number; - readonly aborted: number; - readonly parsed: boolean; -} - -export interface DynamicWorkflowMissionControlOptions { - readonly description: string; - readonly availableRows?: () => number | undefined; -} - -const PHASE_LABELS: Record<DynamicWorkflowPhase, string> = { - pending: 'PEND', - queued: 'WAIT', - running: 'RUN', - suspended: 'HOLD', - completed: 'DONE', - failed: 'FAIL', - cancelled: 'STOP', -}; - -const PHASE_GLYPHS: Record<DynamicWorkflowPhase, string> = { - pending: '○', - queued: '○', - running: BRAILLE_SPINNER_FRAMES[0] ?? '⠋', - suspended: '◑', - completed: '✓', - failed: '×', - cancelled: '–', -}; - -const PHASE_COLORS: Record<DynamicWorkflowPhase, 'textMuted' | 'primary' | 'success' | 'warning' | 'error'> = { - pending: 'textMuted', - queued: 'textMuted', - running: 'primary', - suspended: 'warning', - completed: 'success', - failed: 'error', - cancelled: 'warning', -}; - -/** - * A truthful, observed-state summary for one DynamicWorkflow tool call. - * Animation remains owned by the injected ActivityLoader; this component only - * reads its current frame during render. - */ -export class DynamicWorkflowMissionControlComponent implements Component { - private readonly model: DynamicWorkflowModel; - private readonly availableRows: (() => number | undefined) | undefined; - private activitySpinnerText: (() => string) | undefined; - private completeItems: string[] = []; - - constructor(options: DynamicWorkflowMissionControlOptions) { - this.availableRows = options.availableRows; - this.model = { - requestPhase: 'collecting', - inputComplete: false, - toolCallActive: true, - description: normalizeText(options.description), - itemsStarted: 0, - startedAtMs: Date.now(), - members: [], - recentActivity: [], - }; - } - - invalidate(): void {} - - setActivitySpinnerText(provider: (() => string) | undefined): void { - if (!this.model.toolCallActive) return; - this.activitySpinnerText = provider; - } - - markToolCallEnded(): void { - this.model.toolCallActive = false; - this.activitySpinnerText = undefined; - } - - isToolCallActive(): boolean { - return this.model.toolCallActive; - } - - isRequestStreaming(): boolean { - return !this.model.inputComplete; - } - - updateArgs( - args: Record<string, unknown>, - options: { readonly streamingArguments?: string } = {}, - ): void { - const description = dynamicWorkflowDescriptionFromArgs(args) || - (options.streamingArguments === undefined - ? '' - : dynamicWorkflowPartialDescriptionFromArguments(options.streamingArguments)); - if (description.length > 0 || this.model.description === undefined) { - this.model.description = normalizeText(description); - } - - const completeItems = dynamicWorkflowMemberItemsFromArgs(args) - .slice(0, MAX_DYNAMIC_WORKFLOW_MEMBERS); - this.completeItems = completeItems; - const partialItems = options.streamingArguments === undefined - ? [] - : dynamicWorkflowPartialMemberItemsFromArguments(options.streamingArguments) - .slice(0, MAX_DYNAMIC_WORKFLOW_MEMBERS); - const visibleItems = completeItems.length > 0 ? completeItems : partialItems; - if (visibleItems.length > 0) { - this.model.itemsStarted = Math.max(this.model.itemsStarted, visibleItems.length); - this.ensureMemberCount(visibleItems.length); - this.updateItemTexts(visibleItems); - } - - const promptTemplate = dynamicWorkflowPromptTemplateFromArgs(args) || - (options.streamingArguments === undefined - ? '' - : dynamicWorkflowPartialPromptTemplateFromArguments(options.streamingArguments)); - if (promptTemplate.length > 0 || this.model.promptTemplate === undefined) { - this.model.promptTemplate = promptTemplate; - } - } - - markInputComplete(): void { - if (this.model.inputComplete) return; - this.model.inputComplete = true; - this.model.knownTotal = this.completeItems.length; - this.ensureMemberCount(this.completeItems.length); - this.updateItemTexts(this.completeItems); - // Streaming may have over-counted items; drop the unclaimed surplus rows. - if (this.completeItems.length > 0) { - this.model.members = this.model.members.filter( - (member) => member.index <= this.completeItems.length || member.agentId !== undefined, - ); - this.model.itemsStarted = this.model.members.length; - } - for (const member of this.model.members) { - if (member.phase === 'pending') member.phase = 'queued'; - } - if (this.model.requestPhase === 'collecting') { - this.model.requestPhase = 'active'; - this.recordActivity('SYS', 'Workflow input accepted'); - } - } - - registerSubagent(input: { - readonly agentId: string; - readonly dynamicWorkflowIndex?: number; - readonly description?: string; - }): void { - const member = this.findMemberForSubagent(input.agentId, input.dynamicWorkflowIndex); - // Never claim a member that another agent already took. - if (member === undefined || member.agentId !== undefined && member.agentId !== input.agentId) return; - - const wasUnassigned = member.agentId === undefined; - member.agentId = input.agentId; - if (member.phase === 'pending') member.phase = 'queued'; - if (input.description !== undefined && member.item.length === 0) { - member.item = normalizeText(input.description); - } - if (wasUnassigned) this.recordActivity(member.index, 'Agent spawned'); - } - - markStarted(agentId: string): void { - const member = this.findMemberByAgentId(agentId); - if (member === undefined || isTerminalPhase(member.phase)) return; - if (member.phase === 'running') return; - member.phase = 'running'; - member.startedAtMs ??= Date.now(); - delete member.statusDetail; - this.recordActivity(member.index, 'Started'); - } - - recordToolCall(input: { - readonly agentId: string; - readonly name?: string; - }): void { - const member = this.findMemberByAgentId(input.agentId); - if (member === undefined || isTerminalPhase(member.phase)) return; - if (member.phase === 'pending' || member.phase === 'queued') this.markStarted(input.agentId); - const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`; - this.setLatest(member, latest, true); - // Streamed text that follows starts a new line, never continues this label. - member.carry = ''; - } - - appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void { - const member = this.findMemberByAgentId(input.agentId); - if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return; - if (member.phase === 'pending' || member.phase === 'queued') this.markStarted(input.agentId); - const combined = `${member.carry}${input.delta}`; - // Only the text after the last newline is still being written. A delta that - // ends exactly at a newline leaves nothing pending, so carrying the closed - // line into the next delta fused a whole streamed message into one string - // that grew for as long as the agent talked. - const newlineIndex = combined.lastIndexOf('\n'); - const pending = newlineIndex < 0 ? combined : combined.slice(newlineIndex + 1); - member.carry = clampLine(pending); - - // Every line the delta closed is an event of its own. Recording only the - // last one dropped whole lines whenever a provider sent several in one - // chunk, so the same agent showed less activity on a batching provider than - // on one that streams a token at a time. - if (newlineIndex >= 0) { - for (const line of combined.slice(0, newlineIndex).split('\n')) { - this.setLatest(member, clampLine(line), true); - } - } - // The unclosed tail is shown but is not an event yet — except as the row's - // first text, which would otherwise leave the row blank until a newline. - if (pending.length > 0) { - this.setLatest(member, clampLine(pending), member.latest.length === 0); - } - } - - markSuspended(input: { - readonly agentId: string; - readonly reason: string; - readonly dynamicWorkflowIndex?: number; - readonly description?: string; - }): void { - const member = this.findMemberByAgentId(input.agentId) ?? - this.findMemberForSubagent(input.agentId, input.dynamicWorkflowIndex); - if (member === undefined || isTerminalPhase(member.phase)) return; - member.agentId = input.agentId; - if (input.description !== undefined && member.item.length === 0) { - member.item = normalizeText(input.description); - } - const detail = normalizeText(input.reason); - const changed = member.phase !== 'suspended' || member.statusDetail !== detail; - member.phase = 'suspended'; - member.statusDetail = detail.length > 0 ? detail : undefined; - if (changed) this.recordActivity(member.index, detail.length > 0 ? `Suspended: ${detail}` : 'Suspended'); - } - - markCompleted(agentId: string, summary?: string): void { - const member = this.findMemberByAgentId(agentId); - if (member === undefined) return; - this.markMemberTerminal(member, 'completed', summary); - } - - markFailed(agentId: string, failure?: string): void { - const member = this.findMemberByAgentId(agentId); - if (member === undefined) return; - this.markMemberTerminal(member, 'failed', failure); - } - - markCancelled(agentId: string): void { - const member = this.findMemberByAgentId(agentId); - if (member === undefined) return; - this.markMemberTerminal(member, 'cancelled', 'Cancelled'); - } - - markRequestFailed(failure?: string): void { - if (this.model.requestPhase === 'failed' || this.model.requestPhase === 'cancelled') return; - this.model.requestPhase = 'failed'; - this.model.endedAtMs = Date.now(); - const detail = normalizeText(failure); - this.recordActivity('SYS', detail.length > 0 ? `Workflow failed: ${detail}` : 'Workflow failed'); - } - - markActiveCancelled(): void { - if (isTerminalRequestPhase(this.model.requestPhase)) return; - this.model.requestPhase = 'cancelled'; - this.model.endedAtMs = Date.now(); - this.model.toolCallActive = false; - this.activitySpinnerText = undefined; - this.recordActivity('SYS', 'Workflow cancelled'); - } - - markWarning(message: string): void { - this.recordActivity('SYS', normalizeText(message)); - } - - applyResult(output: string): boolean { - const statuses = parseDynamicWorkflowResultStatuses(output); - if (!isDynamicWorkflowResult(output)) return false; - - // Input was accepted with zero items: infer the total from the result rows. - if (this.model.inputComplete && this.model.knownTotal === 0 && statuses.length > 0) { - this.model.knownTotal = statuses.length; - } - const knownTotal = this.model.knownTotal; - for (const status of statuses) { - // Result rows beyond the accepted input count are out-of-band; ignore them. - if (knownTotal !== undefined && knownTotal > 0 && status.index > knownTotal) continue; - const member = status.agentId === undefined - ? this.ensureMemberAt(status.index) - : this.findMemberByAgentId(status.agentId) ?? this.ensureMemberAt(status.index); - if (status.item !== undefined && (member.item.length === 0 || member.item === RESUMED_ITEM_LABEL)) { - member.item = normalizeText(status.item); - } - if (status.agentId !== undefined && member.agentId === undefined) member.agentId = status.agentId; - this.markMemberTerminal(member, status.status, status.detail); - } - - if (!isTerminalRequestPhase(this.model.requestPhase)) { - this.model.requestPhase = 'completed'; - this.model.endedAtMs = Date.now(); - this.recordActivity('SYS', 'Workflow result received'); - } - return true; - } - - render(width: number): string[] { - const safeWidth = Math.max(1, Math.floor(width)); - const nowMs = this.model.endedAtMs ?? Date.now(); - const maxRows = this.availableRows?.(); - const rowBudget = maxRows === undefined || !Number.isFinite(maxRows) - ? Number.POSITIVE_INFINITY - : Math.max(0, Math.floor(maxRows)); - - if (rowBudget <= 0) return []; - if (safeWidth < DYNAMIC_WORKFLOW_RENDERING.frameMinWidth) { - const lines = [this.renderTitle(safeWidth)]; - if (rowBudget > 1) { - lines.push(...this.renderContent(safeWidth, nowMs, rowBudget - 1)); - } - return lines; - } - - const top = this.renderFrameTop(safeWidth); - if (rowBudget === 1) return [top]; - const bottom = this.renderFrameBottom(safeWidth); - if (rowBudget === 2) return [top, bottom]; - - const contentWidth = safeWidth - DYNAMIC_WORKFLOW_RENDERING.frameHorizontalInset; - const content = this.renderContent(contentWidth, nowMs, rowBudget - 2); - return [ - top, - ...content.map((line) => this.renderFrameBodyLine(line, safeWidth)), - bottom, - ]; - } - - private renderContent(width: number, nowMs: number, rowBudget: number): string[] { - const lines: string[] = []; - if (rowBudget <= 0) return lines; - - const helper = this.renderHelper(width); - const members = this.model.members.toSorted((left, right) => left.index - right.index); - const essentialRows = 1 + (helper === undefined ? 0 : 1) + (members.length > 0 ? 2 : 0); - // While the workflow is live, keep blank rows around the aggregate so the - // framed card reads as title, body, and footer; terminal states pack tight. - const spaceAroundAggregate = - !isTerminalRequestPhase(this.model.requestPhase) && rowBudget >= essentialRows + 2; - - if (spaceAroundAggregate) lines.push(''); - lines.push(this.renderAggregate(width, nowMs)); - if (lines.length >= rowBudget) return lines; - if (spaceAroundAggregate) lines.push(''); - - if (helper !== undefined) { - lines.push(helper); - if (lines.length >= rowBudget) return lines; - } - - if (members.length > 0 && rowBudget - lines.length >= 2) { - if (width >= DYNAMIC_WORKFLOW_RENDERING.frameMinWidth) { - lines.push(this.renderTableHeader(width)); - } - const slots = rowBudget - lines.length; - const needsMore = members.length > slots; - const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots; - const visibleMembers = members.slice(0, Math.max(0, memberSlots)); - // Measured across every member, not the visible ones: a prefix that came - // and went as rows scrolled would rewrite the task column under the eye. - const sharedPrefix = sharedTaskPrefix(members); - for (const member of visibleMembers) { - lines.push(this.renderMember(member, width, nowMs, sharedPrefix)); - } - const hidden = members.length - visibleMembers.length; - if (hidden > 0 && lines.length < rowBudget) { - lines.push(truncateToWidth( - currentTheme.fg('textMuted', `+ ${String(hidden)} more agents`), - width, - )); - } - } - - if (lines.length >= rowBudget || this.model.recentActivity.length === 0) return lines; - lines.push(truncateToWidth(currentTheme.fg('textDim', 'Recent activity'), width)); - for (const entry of this.model.recentActivity) { - if (lines.length >= rowBudget) break; - lines.push(this.renderActivity(entry, width)); - } - return lines; - } - - private renderTitle(width: number): string { - const title = currentTheme.boldFg('workflowTitle', 'Dynamic Workflow'); - const titleWidth = visibleWidth(title); - const description = this.model.description; - if (description === undefined || description.length === 0 || width <= titleWidth) { - return titleWidth <= width ? title : truncateToWidth(title, width); - } - - const suffix = currentTheme.fg('textDim', ` · ${description}`); - const suffixWidth = width - titleWidth; - return title + ( - visibleWidth(suffix) <= suffixWidth ? suffix : truncateToWidth(suffix, suffixWidth) - ); - } - - private renderFrameTop(width: number): string { - const left = currentTheme.fg('border', '╭─ '); - const title = this.renderTitle(width - 5); - const fillWidth = Math.max(0, width - visibleWidth(left) - visibleWidth(title) - 2); - const right = currentTheme.fg('border', ` ${'─'.repeat(fillWidth)}╮`); - return `${left}${title}${right}`; - } - - private renderFrameBodyLine(content: string, width: number): string { - const contentWidth = width - DYNAMIC_WORKFLOW_RENDERING.frameHorizontalInset; - const clipped = truncateToWidth(content, contentWidth); - const padding = ' '.repeat(Math.max(0, contentWidth - visibleWidth(clipped))); - const border = currentTheme.fg('border', '│'); - return `${border} ${clipped}${padding} ${border}`; - } - - private renderFrameBottom(width: number): string { - return currentTheme.fg('border', `╰${'─'.repeat(width - 2)}╯`); - } - - private renderAggregate(width: number, nowMs: number): string { - const terminal = isTerminalRequestPhase(this.model.requestPhase); - const frame = Math.floor( - Math.max(0, nowMs - this.model.startedAtMs) / - BRAILLE_SPINNER_INTERVAL_MS, - ); - const loader = terminal - ? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase)) - : this.activitySpinnerText === undefined - ? currentTheme.fg('primary', '●') - : currentTheme.fg( - 'primary', - BRAILLE_SPINNER_FRAMES[frame % BRAILLE_SPINNER_FRAMES.length]!, - ); - const aggregateMembers = this.aggregateMembers(); - // All spawned agents are done but the tool result has not arrived yet: - // the label says so instead of pretending orchestration is still active. - // Every member counts — including out-of-band rows beyond knownTotal — - // so the label never claims "done" above a row still marked running. - const finalizing = !terminal && - this.model.knownTotal !== undefined && - this.model.knownTotal > 0 && - aggregateMembers.length === this.model.knownTotal && - this.model.members.every((member) => isTerminalPhase(member.phase)); - // The live label shimmers from elapsed time; no timer is created because - // the host owns animation and only re-renders this block. - const label = terminal - ? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase)) - : shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - altShimmerToken: 'warningShimmer', - bandHalfWidth: 4, - }); - const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH); - const prefix = `${loader} ${paddedLabel}`; - const completed = aggregateMembers.filter((member) => member.phase === 'completed').length; - const failed = aggregateMembers.filter((member) => member.phase === 'failed').length; - const cancelled = aggregateMembers.filter((member) => member.phase === 'cancelled').length; - const elapsed = elapsedSeconds(this.model.startedAtMs, this.model.endedAtMs ?? nowMs); - const countText = this.model.knownTotal === undefined - ? '' - : `${String(completed)}/${String(this.model.knownTotal)} complete`; - const outcomeText = [ - failed > 0 ? `${String(failed)} failed` : '', - cancelled > 0 ? `${String(cancelled)} stopped` : '', - ].filter((part) => part.length > 0).join(' · '); - const elapsedText = `${String(elapsed)}s elapsed`; - const suffix = [countText, outcomeText, elapsedText] - .filter((part) => part.length > 0) - .join(' · '); - return truncateToWidth( - [prefix, suffix].filter((part) => part.length > 0).join(' '), - width, - ); - } - - private renderHelper(width: number): string | undefined { - if (this.model.knownTotal !== undefined || isTerminalRequestPhase(this.model.requestPhase)) { - return undefined; - } - return truncateToWidth(currentTheme.fg('textMuted', 'Waiting for delegated agents'), width); - } - - private renderTableHeader(width: number): string { - const header = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth - ? [ - padToWidth('ID', 3), - padToWidth('PROGRESS', DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth), - padToWidth('STATE', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth), - 'TASK', - ].join(' ') - : `${padToWidth('ID', 3)} ${padToWidth('STATUS', DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} TASK`; - return truncateToWidth(currentTheme.fg('textDim', header), width); - } - - private renderMember( - member: DynamicWorkflowMember, - width: number, - nowMs: number, - sharedPrefix: string, - ): string { - const id = currentTheme.fg('primary', String(member.index).padStart(3, '0')); - // All running rows share the workflow's clock, so they spin in step instead - // of drifting apart by whenever each agent happened to start. - const frame = Math.floor( - Math.max(0, nowMs - this.model.startedAtMs) / DYNAMIC_WORKFLOW_RENDERING.progressFrameMs, - ); - const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth; - const prefix = showProgress - ? `${id} ${ - centerToWidth( - renderProgressGlyph(member.phase, frame), - DYNAMIC_WORKFLOW_RENDERING.memberProgressWidth, - ) - } ${padToWidth(renderStateLabel(member.phase), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} ` - : `${id} ${padToWidth(renderCompactStatus(member.phase, frame), DYNAMIC_WORKFLOW_RENDERING.stateColumnWidth)} `; - const task = member.item || 'Delegated agent'; - // The elision is display-only: the dedup below still compares whole items, - // so a streamed line that merely repeats the task is still suppressed. - const shownTask = sharedPrefix.length > 0 && member.item.startsWith(sharedPrefix) - ? `${TASK_ELISION_MARK}${member.item.slice(sharedPrefix.length)}` - : task; - const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined; - const detail = member.phase === 'suspended' || isTerminalPhase(member.phase) - ? member.statusDetail ?? latest - : latest ?? member.statusDetail; - const elapsed = member.startedAtMs === undefined - ? undefined - : `${String(elapsedSeconds(member.startedAtMs, member.endedAtMs ?? nowMs))}s`; - const free = Math.max(1, width - visibleWidth(prefix)); - - // The elapsed cell is short and fixed, so it is reserved first — but only - // while the task still keeps its floor. - const elapsedPart = showProgress && elapsed !== undefined - ? `${MEMBER_SEPARATOR}${currentTheme.fg('textMuted', elapsed)}` - : ''; - const elapsedWidth = visibleWidth(elapsedPart); - const keepsElapsed = elapsedPart.length > 0 && - free - elapsedWidth >= DYNAMIC_WORKFLOW_RENDERING.memberTaskMinWidth; - const rest = free - (keepsElapsed ? elapsedWidth : 0); - - // The task names the row, so it is measured before the detail rather than - // with whatever the detail leaves over: a finished agent returns its whole - // summary as the detail, which used to collapse the task to one character. - // The share keeps a short task from starving the detail in turn. - const taskCap = Math.max( - DYNAMIC_WORKFLOW_RENDERING.memberTaskMinWidth, - Math.floor(rest * DYNAMIC_WORKFLOW_RENDERING.memberTaskShare), - ); - const detailBudget = showProgress && detail !== undefined && detail.length > 0 - ? rest - Math.min(visibleWidth(shownTask), taskCap) - MEMBER_SEPARATOR.length - : 0; - const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING.memberDetailMinWidth - ? `${MEMBER_SEPARATOR}${truncateToWidth(currentTheme.fg('textDim', detail ?? ''), detailBudget)}` - : ''; - - // Whatever the detail did not take goes back to the task. - const taskText = truncateToWidth( - currentTheme.fg('text', shownTask), - Math.max(1, rest - visibleWidth(detailPart)), - ); - return truncateToWidth( - `${prefix}${taskText}${detailPart}${keepsElapsed ? elapsedPart : ''}`, - width, - ); - } - - private renderActivity(entry: DynamicWorkflowActivity, width: number): string { - const source = entry.source === 'SYS' ? 'SYS' : String(entry.source).padStart(3, '0'); - const seconds = Math.floor(Math.max(0, entry.relativeMs) / 1_000); - return truncateToWidth( - `${currentTheme.fg('textMuted', source)} ${currentTheme.fg('textMuted', `+${String(seconds)}s`)} ${currentTheme.fg('textDim', entry.text)}`, - width, - ); - } - - private aggregateMembers(): DynamicWorkflowMember[] { - const members = this.model.members - .toSorted((left, right) => left.index - right.index); - const total = this.model.knownTotal; - return total === undefined || total <= 0 ? members : members.slice(0, total); - } - - private findMemberForSubagent( - agentId: string, - dynamicWorkflowIndex: number | undefined, - ): DynamicWorkflowMember | undefined { - const existing = this.findMemberByAgentId(agentId); - if (existing !== undefined) return existing; - if ( - dynamicWorkflowIndex !== undefined && - Number.isInteger(dynamicWorkflowIndex) && - dynamicWorkflowIndex > 0 && - dynamicWorkflowIndex <= MAX_DYNAMIC_WORKFLOW_MEMBERS - ) { - return this.ensureMemberAt(dynamicWorkflowIndex); - } - const unassigned = this.model.members - .toSorted((left, right) => left.index - right.index) - .find((member) => member.agentId === undefined); - const nextIndex = this.nextMemberIndex(); - return unassigned ?? ( - nextIndex <= MAX_DYNAMIC_WORKFLOW_MEMBERS ? this.ensureMemberAt(nextIndex) : undefined - ); - } - - private findMemberByAgentId(agentId: string): DynamicWorkflowMember | undefined { - return this.model.members.find((member) => member.agentId === agentId); - } - - private ensureMemberAt(index: number): DynamicWorkflowMember { - this.ensureMemberCount(index); - const member = this.model.members.find((candidate) => candidate.index === index); - if (member === undefined) throw new Error(`Missing Dynamic Workflow member ${String(index)}`); - return member; - } - - private ensureMemberCount(count: number): void { - const safeCount = Math.min( - MAX_DYNAMIC_WORKFLOW_MEMBERS, - Math.max(0, Math.floor(count)), - ); - for (let index = 1; index <= safeCount; index += 1) { - if (this.model.members.some((member) => member.index === index)) continue; - this.model.members.push({ - index, - item: '', - phase: this.model.inputComplete ? 'queued' : 'pending', - latest: '', - carry: '', - }); - } - } - - private nextMemberIndex(): number { - return this.model.members.reduce((maximum, member) => Math.max(maximum, member.index), 0) + 1; - } - - private updateItemTexts(items: readonly string[]): void { - items.forEach((item, index) => { - const member = this.ensureMemberAt(index + 1); - member.item = normalizeText(item); - }); - } - - private markMemberTerminal( - member: DynamicWorkflowMember, - phase: Extract<DynamicWorkflowPhase, 'completed' | 'failed' | 'cancelled'>, - detail: string | undefined, - ): void { - if (isTerminalPhase(member.phase)) return; - const normalizedDetail = normalizeText(detail); - member.phase = phase; - member.endedAtMs = Date.now(); - member.statusDetail = normalizedDetail.length > 0 ? normalizedDetail : undefined; - const label = phase === 'completed' ? 'Completed' : phase === 'failed' ? 'Failed' : 'Cancelled'; - this.recordActivity(member.index, normalizedDetail.length > 0 ? `${label}: ${normalizedDetail}` : label); - } - - private setLatest(member: DynamicWorkflowMember, latest: string, recordActivity: boolean): void { - const normalized = normalizeText(latest); - if (normalized.length === 0 || member.latest === normalized) return; - member.latest = normalized; - if (recordActivity) this.recordActivity(member.index, normalized); - } - - private recordActivity(source: number | 'SYS', text: string): void { - const entry: DynamicWorkflowActivity = { - relativeMs: Math.max(0, Date.now() - this.model.startedAtMs), - source, - text: normalizeText(text), - }; - const entries = this.model.recentActivity; - entries.push(entry); - entries.splice(0, Math.max(0, entries.length - 3)); - } -} - -/** Item list from the completed tool-call `items` argument. */ -function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] { - const items = args['items']; - if (!Array.isArray(items)) return []; - // Blank entries are dropped by the engine before any agent is launched, so - // counting them here would leave a phantom row waiting forever and pin the - // header below its total. Non-strings are kept: the engine rejects those, and - // itemLabel renders them readably. - return items - .filter((item) => typeof item !== 'string' || item.trim().length > 0) - .map(itemLabel); -} - -/** - * The schema requires plain strings, but a model may still emit objects. Render - * a readable field instead of `[object Object]`; the tool call fails validation - * either way. - */ -function itemLabel(item: unknown): string { - if (typeof item === 'string') return item; - if (typeof item !== 'object' || item === null) return String(item); - const record = item as Record<string, unknown>; - for (const key of ['prompt', 'description', 'title', 'task']) { - const value = record[key]; - if (typeof value === 'string' && value.length > 0) return value; - } - return ''; -} - -/** - * Best-effort `items` read from a partially streamed JSON arguments string. - * Only top-level array members count: strings nested inside an object or array - * member (and object keys) are skipped, not counted as items. - */ -export function dynamicWorkflowPartialItemsFromArguments(argumentsText: string): string[] { - const match = /"items"\s*:\s*\[/u.exec(argumentsText); - if (match === null) return []; - const items: string[] = []; - let depth = 0; - for (let index = match.index + match[0].length; index < argumentsText.length; index += 1) { - const character = argumentsText[index]; - if (character === '{' || character === '[') { - // A nested member still occupies one item slot. - if (depth === 0) items.push(''); - depth += 1; - continue; - } - if (character === '}' || character === ']') { - if (depth === 0) return items; - depth -= 1; - continue; - } - if (character !== '"') continue; - const parsed = parsePartialJsonString(argumentsText, index + 1); - if (depth === 0) items.push(parsed.value); - if (!parsed.closed) return items; - index = parsed.nextIndex; - } - return items; -} - -/** Description from the completed tool-call `description` argument. */ -export function dynamicWorkflowDescriptionFromArgs(args: Record<string, unknown>): string { - const description = args['description']; - return typeof description === 'string' ? description : ''; -} - -/** Best-effort `description` read from a partially streamed JSON arguments string. */ -export function dynamicWorkflowPartialDescriptionFromArguments(argumentsText: string): string { - const match = /"description"\s*:\s*"/.exec(argumentsText); - if (match === null) return ''; - return parsePartialJsonString(argumentsText, match.index + match[0].length).value; -} - -/** Parses a `dynamic_workflow_result` document into summary counts; `parsed: false` when the output is not one. */ -export function dynamicWorkflowResultSummaryFromOutput(output: string): DynamicWorkflowResultSummary { - const envelope = dynamicWorkflowResultEnvelope(output); - if (envelope === undefined) { - return { completed: 0, failed: 0, aborted: 0, parsed: false }; - } - - const statuses = parseDynamicWorkflowResultStatuses(output); - if (statuses.length > 0) { - return { - completed: statuses.filter((status) => status.status === 'completed').length, - failed: statuses.filter((status) => status.status === 'failed').length, - aborted: statuses.filter((status) => status.status === 'cancelled').length, - parsed: true, - }; - } - return { ...dynamicWorkflowSummaryFromEnvelope(envelope), parsed: true }; -} - -/** True when `output` carries a complete `dynamic_workflow_result` envelope. */ -export function isDynamicWorkflowResult(output: string): boolean { - return dynamicWorkflowResultEnvelope(output) !== undefined; -} - -function dynamicWorkflowMemberItemsFromArgs(args: Record<string, unknown>): string[] { - return [...dynamicWorkflowResumeItemsFromArgs(args), ...dynamicWorkflowItemsFromArgs(args)]; -} - -function dynamicWorkflowResumeItemsFromArgs(args: Record<string, unknown>): string[] { - const resumeAgentIds = args['resume_agent_ids']; - if (typeof resumeAgentIds !== 'object' || resumeAgentIds === null || Array.isArray(resumeAgentIds)) { - return []; - } - return Object.keys(resumeAgentIds).map(() => RESUMED_ITEM_LABEL); -} - -function dynamicWorkflowPartialMemberItemsFromArguments(argumentsText: string): string[] { - return [ - ...dynamicWorkflowPartialResumeItemsFromArguments(argumentsText), - ...dynamicWorkflowPartialItemsFromArguments(argumentsText), - ]; -} - -function dynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] { - const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText); - if (match === null) return []; - return Array.from( - { length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) }, - () => RESUMED_ITEM_LABEL, - ); -} - -function dynamicWorkflowPromptTemplateFromArgs(args: Record<string, unknown>): string { - const promptTemplate = args['prompt_template']; - return typeof promptTemplate === 'string' ? promptTemplate : ''; -} - -function dynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string { - const match = /"prompt_template"\s*:\s*"/.exec(argumentsText); - if (match === null) return ''; - return parsePartialJsonString(argumentsText, match.index + match[0].length).value; -} - -function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResultStatus[] { - const envelope = dynamicWorkflowResultEnvelope(output); - if (envelope === undefined) return []; - const statuses: DynamicWorkflowResultStatus[] = []; - // Indexes are validated and deduplicated: an explicit index is honored only - // once and within range; a duplicated one is dropped, not remapped. - const usedIndexes = new Set<number>(); - const tagPattern = /<subagent\b([^>]*)>/g; - let match: RegExpExecArray | null; - while ( - statuses.length < MAX_DYNAMIC_WORKFLOW_MEMBERS && - (match = tagPattern.exec(envelope)) !== null - ) { - const closeIndex = envelope.indexOf('</subagent>', tagPattern.lastIndex); - if (closeIndex < 0) break; - const attrs = match[1] ?? ''; - const body = envelope.slice(tagPattern.lastIndex, closeIndex); - const outcome = xmlAttribute(attrs, 'outcome'); - if ( - outcome === 'completed' || - outcome === 'failed' || - outcome === 'aborted' || - outcome === 'cancelled' || - outcome === 'schema_error' - ) { - // Omitted `index` falls back to the lowest free slot so unordered tags - // still render in ascending row order. - const explicitIndexText = xmlAttribute(attrs, 'index'); - const explicitIndex = explicitIndexText === undefined - ? undefined - : Number(explicitIndexText); - const fallbackIndex = firstAvailableResultIndex(usedIndexes); - const index = explicitIndexText === undefined ? fallbackIndex : explicitIndex; - if ( - index !== undefined && - Number.isInteger(index) && - index > 0 && - index <= MAX_DYNAMIC_WORKFLOW_MEMBERS && - !usedIndexes.has(index) - ) { - usedIndexes.add(index); - statuses.push({ - index, - agentId: xmlAttribute(attrs, 'agent_id'), - item: xmlAttribute(attrs, 'item'), - status: outcome === 'aborted' || outcome === 'cancelled' - ? 'cancelled' - : outcome === 'schema_error' - ? 'failed' - : outcome, - detail: normalizeText(decodeXmlEntities(body)), - }); - } - } - tagPattern.lastIndex = closeIndex + '</subagent>'.length; - } - return statuses; -} - -function firstAvailableResultIndex(usedIndexes: ReadonlySet<number>): number | undefined { - for (let index = 1; index <= MAX_DYNAMIC_WORKFLOW_MEMBERS; index += 1) { - if (!usedIndexes.has(index)) return index; - } - return undefined; -} - -function dynamicWorkflowResultEnvelope(output: string): string | undefined { - let candidate = output.trim(); - const prefix = /^dynamic_workflow:\s*(?:(?:completed|failed|cancelled|aborted)\s*)?/i.exec(candidate); - if (prefix !== null) candidate = candidate.slice(prefix[0].length).trimStart(); - const opening = /^<dynamic_workflow_result\b[^>]*>/.exec(candidate); - if (opening === null) return undefined; - const close = candidate.indexOf('</dynamic_workflow_result>', opening[0].length); - if (close < 0) return undefined; - return candidate.slice(opening[0].length, close); -} - -function dynamicWorkflowSummaryFromEnvelope(envelope: string): Omit<DynamicWorkflowResultSummary, 'parsed'> { - const summary = /<summary\b[^>]*>([\s\S]*?)<\/summary>/.exec(envelope)?.[1] ?? ''; - return { - completed: summaryCount(summary, 'completed'), - failed: summaryCount(summary, 'failed'), - aborted: summaryCount(summary, 'aborted'), - }; -} - -function summaryCount(summary: string, label: string): number { - const value = new RegExp(`\\b${label}\\s*:\\s*(\\d+)`, 'i').exec(summary)?.[1]; - return value === undefined ? 0 : Number(value); -} - -function xmlAttribute(attributes: string, name: string): string | undefined { - const value = new RegExp(`\\b${name}="([^"]*)"`).exec(attributes)?.[1]; - return value === undefined ? undefined : decodeXmlEntities(value); -} - -/** Decodes XML entities (named and numeric); invalid or surrogate refs pass through. */ -function decodeXmlEntities(value: string): string { - return value.replaceAll( - /&(amp|quot|apos|lt|gt|#\d+|#x[\da-f]+);/giu, - (entity, reference: string) => { - switch (reference.toLowerCase()) { - case 'amp': return '&'; - case 'quot': return '"'; - case 'apos': return "'"; - case 'lt': return '<'; - case 'gt': return '>'; - } - const radix = reference[1]?.toLowerCase() === 'x' ? 16 : 10; - const digits = radix === 16 ? reference.slice(2) : reference.slice(1); - const codePoint = Number.parseInt(digits, radix); - if ( - !Number.isInteger(codePoint) || - codePoint < 0 || - codePoint > 0x10_FFFF || - codePoint >= 0xD800 && codePoint <= 0xDFFF - ) { - return entity; - } - return String.fromCodePoint(codePoint); - }, - ); -} - -function requestPhaseLabel(phase: DynamicWorkflowRequestPhase): string { - const labels: Record<DynamicWorkflowRequestPhase, string> = { - collecting: ORCHESTRATING_LABEL, - active: ORCHESTRATING_LABEL, - completed: 'Completed', - failed: 'Failed', - cancelled: 'Cancelled', - }; - return labels[phase]; -} - -function requestPhaseSymbol(phase: DynamicWorkflowRequestPhase): string { - const symbols: Record<DynamicWorkflowRequestPhase, string> = { - collecting: '●', - active: '●', - completed: '✓', - failed: '×', - cancelled: '–', - }; - return symbols[phase]; -} - -function requestPhaseColor(phase: DynamicWorkflowRequestPhase): 'primary' | 'success' | 'error' | 'warning' { - const colors: Record<DynamicWorkflowRequestPhase, 'primary' | 'success' | 'error' | 'warning'> = { - collecting: 'primary', - active: 'primary', - completed: 'success', - failed: 'error', - cancelled: 'warning', - }; - return colors[phase]; -} - -function isTerminalPhase(phase: DynamicWorkflowPhase): boolean { - return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; -} - -function isTerminalRequestPhase(phase: DynamicWorkflowRequestPhase): boolean { - return phase === 'completed' || phase === 'failed' || phase === 'cancelled'; -} - -function elapsedSeconds(startedAtMs: number, endedAtMs: number): number { - return Math.floor(Math.max(0, endedAtMs - startedAtMs) / 1_000); -} - -/** - * Keeps the head of one streamed line. The row shows the head and clips the - * rest, so dropping the tail is invisible — and it is the only bound on a line - * the model never closes with a newline. - */ -function clampLine(text: string): string { - return text.slice(0, DYNAMIC_WORKFLOW_RENDERING.memberLatestMaxChars); -} - -function normalizeText(text: string | undefined): string { - return text?.replaceAll(/\s+/g, ' ').trim() ?? ''; -} - -/** - * The preamble every task repeats, or `''` when dropping it would not help. - * - * `prompt_template` is optional, so a caller may pass a whole prompt as each - * item. Every row then opens with the same paragraph and the TASK column clips - * inside it — six rows reading `You are auditing the pythinker-code mono...` - * name nothing. Dropping the shared head once puts the tail that identifies the - * row back on screen. - * - * All-or-nothing on purpose: eliding a prefix that only some rows carry would - * make two cells at the same column mean different things. - */ -function sharedTaskPrefix(members: readonly DynamicWorkflowMember[]): string { - const items = members.map((member) => member.item).filter((item) => item.length > 0); - const first = items[0]; - if (first === undefined || items.length < 2) return ''; - - // Skips `first` against itself: that comparison can only return its own - // length, and it walks the whole string to say so on every animation frame. - let length = first.length; - for (const item of items.slice(1)) { - length = commonPrefixLength(first, item, length); - if (length === 0) return ''; - } - - // Cut at the last space inside the shared text. A cut mid-word reads as - // corruption, and a space is always a whole code unit, so ending there is - // also what keeps the slice off the middle of a surrogate pair. - // - // Backing off to before the last shared word is what leaves every row - // something after the mark: items are normalized, so none of them ends in a - // space, and the shortest one therefore still holds the word the cut skipped. - const boundary = first.lastIndexOf(' ', length - 1); - if (boundary < 0) return ''; - const prefix = first.slice(0, boundary + 1); - if (visibleWidth(prefix) < DYNAMIC_WORKFLOW_RENDERING.memberTaskSharedPrefixMinWidth) return ''; - return prefix; -} - -function commonPrefixLength(left: string, right: string, limit: number): number { - const bound = Math.min(limit, left.length, right.length); - let index = 0; - while (index < bound && left[index] === right[index]) index += 1; - return index; -} - -function renderProgressGlyph(phase: DynamicWorkflowPhase, frame: number): string { - const glyph = phase === 'running' - ? DYNAMIC_WORKFLOW_RENDERING.progressFrames[frame % DYNAMIC_WORKFLOW_RENDERING.progressFrames.length] ?? - PHASE_GLYPHS.running - : PHASE_GLYPHS[phase]; - return currentTheme.fg(PHASE_COLORS[phase], glyph); -} - -function renderStateLabel(phase: DynamicWorkflowPhase): string { - return currentTheme.fg(PHASE_COLORS[phase], PHASE_LABELS[phase]); -} - -function renderCompactStatus(phase: DynamicWorkflowPhase, frame: number): string { - return `${renderProgressGlyph(phase, frame)} ${renderStateLabel(phase)}`; -} - -function centerToWidth(text: string, width: number): string { - const paddingWidth = Math.max(0, width - visibleWidth(text)); - const left = Math.floor(paddingWidth / 2); - return `${' '.repeat(left)}${text}${' '.repeat(paddingWidth - left)}`; -} - -function padToWidth(text: string, width: number): string { - return text + ' '.repeat(Math.max(0, width - visibleWidth(text))); -} - -function countPartialJsonObjectEntries(text: string, startIndex: number): number { - let count = 0; - let expectingKey = true; - for (let index = startIndex; index < text.length; index += 1) { - const character = text[index]; - if (character === '}') return count; - if (character === ',') { - expectingKey = true; - continue; - } - if (character !== '"') continue; - const parsed = parsePartialJsonString(text, index + 1); - if (expectingKey) { - if (parsed.closed || parsed.value.length > 0) count += 1; - expectingKey = false; - } - if (!parsed.closed) return count; - index = parsed.nextIndex; - } - return count; -} - -function parsePartialJsonString( - text: string, - startIndex: number, -): { value: string; closed: boolean; nextIndex: number } { - let value = ''; - for (let index = startIndex; index < text.length; index += 1) { - const character = text[index]; - if (character === '"') return { value, closed: true, nextIndex: index }; - if (character !== '\\') { - value += character; - continue; - } - const escaped = text[index + 1]; - if (escaped === undefined) return { value, closed: false, nextIndex: index }; - const escapedValues: Record<string, string> = { - '"': '"', - '\\': '\\', - '/': '/', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - }; - if (escaped === 'u') { - const hex = text.slice(index + 2, index + 6); - if (/^[0-9a-fA-F]{4}$/.test(hex)) { - value += String.fromCodePoint(Number.parseInt(hex, 16)); - index += 5; - continue; - } - } - value += escapedValues[escaped] ?? escaped; - index += 1; - } - return { value, closed: false, nextIndex: text.length }; -} diff --git a/apps/pythinker-code/src/tui/components/messages/goal-markers.ts b/apps/pythinker-code/src/tui/components/messages/goal-markers.ts index eca5c1e5..9e0a6daa 100644 --- a/apps/pythinker-code/src/tui/components/messages/goal-markers.ts +++ b/apps/pythinker-code/src/tui/components/messages/goal-markers.ts @@ -7,7 +7,7 @@ * the richer completion card (the `/goal` box), not this marker. */ -import { truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { truncateToWidth, type Component } from '@pymodel/pi-tui'; import type { GoalChange } from '@pymodel/pythinker-code-sdk'; import { STATUS_BULLET } from '#/tui/constant/symbols'; diff --git a/apps/pythinker-code/src/tui/components/messages/goal-panel.ts b/apps/pythinker-code/src/tui/components/messages/goal-panel.ts index a42baf5c..7fad309f 100644 --- a/apps/pythinker-code/src/tui/components/messages/goal-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/goal-panel.ts @@ -19,7 +19,7 @@ import { visibleWidth, wrapTextWithAnsi, type Component, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import type { GoalSnapshot, GoalStatus } from '@pymodel/pythinker-code-sdk'; import { MESSAGE_INDENT } from '#/tui/constant/rendering'; @@ -166,7 +166,9 @@ export function buildGoalReportLines(goal: GoalSnapshot, wrapWidth: number = WRA ), ); } - lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs))), row('Turns', value(`${goal.turnsUsed}`)), row('Tokens', value(formatTokenCount(goal.tokensUsed)))); + lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs)))); + lines.push(row('Turns', value(`${goal.turnsUsed}`))); + lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed)))); if (!isComplete) { const stop = formatStopRow(goal); lines.push( diff --git a/apps/pythinker-code/src/tui/components/messages/markdown-preview.ts b/apps/pythinker-code/src/tui/components/messages/markdown-preview.ts deleted file mode 100644 index 0f33a229..00000000 --- a/apps/pythinker-code/src/tui/components/messages/markdown-preview.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - Markdown, - truncateToWidth, - visibleWidth, - type Component, - type DefaultTextStyle, -} from '@earendil-works/pi-tui'; - -import { - createPythinkerMarkdownTheme, - createPythinkerThinkingMarkdownTheme, - currentTheme, -} from '#/tui/theme'; - -type Prefix = string | (() => string); - -export interface MarkdownPreviewOptions { - readonly firstPrefix: Prefix; - readonly continuationPrefix: Prefix; - readonly tailRows?: number; - readonly appearance?: 'default' | 'dim' | 'thinking'; -} - -export class MarkdownPreviewComponent implements Component { - private markdown: Markdown; - private cachedWidth: number | undefined; - private cachedLines: string[] | undefined; - private palette = currentTheme.palette; - - constructor( - private text: string, - private readonly options: MarkdownPreviewOptions, - ) { - this.markdown = this.createMarkdown(); - } - - setText(text: string): void { - if (this.text === text) return; - this.text = text; - this.markdown.setText(text); - this.clearCache(); - } - - invalidate(): void { - this.palette = currentTheme.palette; - this.markdown = this.createMarkdown(); - this.clearCache(); - } - - render(width: number): string[] { - if (this.palette !== currentTheme.palette) { - this.invalidate(); - } - - const safeWidth = Math.max(0, width); - if (safeWidth === 0) return ['']; - if (this.cachedWidth === safeWidth && this.cachedLines !== undefined) { - return this.cachedLines; - } - - const firstPrefix = this.resolvePrefix(this.options.firstPrefix); - const continuationPrefix = this.resolvePrefix(this.options.continuationPrefix); - const prefixWidth = Math.max(visibleWidth(firstPrefix), visibleWidth(continuationPrefix)); - const contentWidth = Math.max(1, safeWidth - prefixWidth); - const rendered = this.text.length > 0 ? this.markdown.render(contentWidth) : ['']; - const tailRows = this.options.tailRows; - const visibleRows = - tailRows !== undefined && rendered.length > tailRows - ? rendered.slice(rendered.length - tailRows) - : rendered; - const lines = visibleRows.map((line, index) => - truncateToWidth( - `${index === 0 ? firstPrefix : continuationPrefix}${line}`, - safeWidth, - '…', - ), - ); - - this.cachedWidth = safeWidth; - this.cachedLines = lines; - return lines; - } - - private createMarkdown(): Markdown { - const appearance = this.options.appearance ?? 'default'; - const defaultTextStyle: DefaultTextStyle | undefined = - appearance === 'thinking' - ? { - color: (text) => currentTheme.fg('textDim', text), - italic: true, - } - : appearance === 'dim' - ? { color: (text) => currentTheme.fg('textDim', text) } - : undefined; - const theme = - appearance === 'thinking' - ? createPythinkerThinkingMarkdownTheme() - : createPythinkerMarkdownTheme(); - return new Markdown(this.text, 0, 0, theme, defaultTextStyle); - } - - private resolvePrefix(prefix: Prefix): string { - return typeof prefix === 'function' ? prefix() : prefix; - } - - private clearCache(): void { - this.cachedWidth = undefined; - this.cachedLines = undefined; - } -} diff --git a/apps/pythinker-code/src/tui/components/messages/mcp-status-panel.ts b/apps/pythinker-code/src/tui/components/messages/mcp-status-panel.ts index c9a2402c..3dac045a 100644 --- a/apps/pythinker-code/src/tui/components/messages/mcp-status-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/mcp-status-panel.ts @@ -12,6 +12,7 @@ const STATUS_PRIORITY: Record<McpServerInfo['status'], number> = { pending: 2, connected: 3, disabled: 4, + removed: 5, }; const STATUS_LABEL: Record<McpServerInfo['status'], string> = { @@ -20,6 +21,7 @@ const STATUS_LABEL: Record<McpServerInfo['status'], string> = { 'needs-auth': 'needs auth', failed: 'failed', disabled: 'disabled', + removed: 'removed', }; const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ @@ -28,6 +30,7 @@ const SUMMARY_ORDER: readonly McpServerInfo['status'][] = [ 'needs-auth', 'failed', 'disabled', + 'removed', ]; function statusPainter( @@ -42,12 +45,13 @@ function statusPainter( case 'pending': return (text) => currentTheme.fg('warning', text); case 'disabled': + case 'removed': return (text) => currentTheme.fg('textDim', text); } } function formatToolCount(server: McpServerInfo): string { - if (server.status === 'disabled') return '—'; + if (server.status === 'disabled' || server.status === 'removed') return '—'; return `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; } @@ -144,7 +148,9 @@ export function buildMcpStatusReportLines(options: McpStatusReportOptions): stri } } - lines.push('', ` ${value(buildSummary(servers))}`, ` ${muted('Configure with')} ${value('/mcp-config')}`); + lines.push(''); + lines.push(` ${value(buildSummary(servers))}`); + lines.push(` ${muted('Configure with')} ${value('/mcp-config')}`); return lines; } diff --git a/apps/pythinker-code/src/tui/components/messages/plan-box.ts b/apps/pythinker-code/src/tui/components/messages/plan-box.ts index d6b2c620..d7e47ca8 100644 --- a/apps/pythinker-code/src/tui/components/messages/plan-box.ts +++ b/apps/pythinker-code/src/tui/components/messages/plan-box.ts @@ -7,9 +7,10 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@earendil-works/pi-tui'; +import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@pymodel/pi-tui'; import chalk from 'chalk'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; const LEFT_MARGIN = 2; // two-space indent matching other tool call children @@ -41,7 +42,7 @@ export class PlanBoxComponent implements Component { // parse + wrap output keyed on (text, width), so reusing the same // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. - this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, createMarkdownOptions()); this.status = opts?.status; } diff --git a/apps/pythinker-code/src/tui/components/messages/plugin-command.ts b/apps/pythinker-code/src/tui/components/messages/plugin-command.ts new file mode 100644 index 00000000..1e9fcad4 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/plugin-command.ts @@ -0,0 +1,58 @@ +/** + * Plugin command invocation card. + * + * When the user runs `/plugin:command args`, the TUI renders a compact card + * instead of expanding the command body into the user bubble: + * + * ▶ /plugin:command + * args + * + * The args line is optional. Core expands the command body into the LLM + * context; the TUI only consumes the `plugin_command.activated` event. + */ + +import { Container, Text, Spacer } from '@pymodel/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +const ARGS_PREVIEW_MAX = 200; + +export class PluginCommandComponent extends Container { + private headText: Text; + private previewText?: Text; + private readonly label: string; + private readonly args?: string; + + constructor(pluginId: string, commandName: string, args?: string) { + super(); + this.label = `/${pluginId}:${commandName}`; + this.args = args; + this.addChild(new Spacer(1)); + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText = new Text(head, 0, 0); + this.addChild(this.headText); + const trimmed = args?.trim() ?? ''; + if (trimmed.length > 0) { + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText = new Text(' ' + currentTheme.fg('textDim', preview), 0, 0); + this.addChild(this.previewText); + } + } + + override invalidate(): void { + const head = + currentTheme.boldFg('primary', '▶ Invoked command: ') + + currentTheme.boldFg('roleUser', this.label); + this.headText.setText(head); + if (this.previewText !== undefined && this.args !== undefined) { + const trimmed = this.args.trim(); + const preview = + trimmed.length > ARGS_PREVIEW_MAX ? trimmed.slice(0, ARGS_PREVIEW_MAX) + '…' : trimmed; + this.previewText.setText(' ' + currentTheme.fg('textDim', preview)); + } + super.invalidate(); + } +} diff --git a/apps/pythinker-code/src/tui/components/messages/plugins-status-panel.ts b/apps/pythinker-code/src/tui/components/messages/plugins-status-panel.ts index 3eb44f12..031fa243 100644 --- a/apps/pythinker-code/src/tui/components/messages/plugins-status-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/plugins-status-panel.ts @@ -109,11 +109,14 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st if (info.manifest?.skillInstructions !== undefined) { lines.push(`${muted('Skill instructions:')} ${value('present')}`); } - lines.push('', value(`Skills (${info.manifest?.skills?.length ?? 0}):`)); + lines.push(''); + lines.push(value(`Skills (${info.manifest?.skills?.length ?? 0}):`)); for (const dir of info.manifest?.skills ?? []) lines.push(` ${muted('-')} ${value(dir)}`); if (info.mcpServers.length > 0) { - lines.push('', value(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled):`), muted(` Enabled by default; disable with /plugins mcp disable ${info.id} <server>.`)); + lines.push(''); + lines.push(value(`MCP servers (${info.enabledMcpServerCount}/${info.mcpServerCount} enabled):`)); + lines.push(muted(` Enabled by default; disable with /plugins mcp disable ${info.id} <server>.`)); for (const server of info.mcpServers) { const enabled = server.enabled ? success('enabled') : muted('disabled'); lines.push(` ${muted('-')} ${value(server.name)} ${enabled} ${muted(`(${server.runtimeName})`)}`); @@ -135,18 +138,21 @@ export function buildPluginsInfoLines(input: PluginsInfoPanelInput): readonly st const iface = info.manifest?.interface; if (iface !== undefined) { - lines.push('', value('Display:')); + lines.push(''); + lines.push(value('Display:')); if (iface.shortDescription !== undefined) lines.push(` ${muted('-')} ${value(iface.shortDescription)}`); if (iface.developerName !== undefined) lines.push(` ${muted('-')} ${value(`by ${iface.developerName}`)}`); if (iface.websiteURL !== undefined) lines.push(` ${muted('-')} ${value(iface.websiteURL)}`); } if (info.manifest?.keywords !== undefined && info.manifest.keywords.length > 0) { - lines.push('', muted(`Keywords: ${info.manifest.keywords.join(', ')}`)); + lines.push(''); + lines.push(muted(`Keywords: ${info.manifest.keywords.join(', ')}`)); } if (info.diagnostics.length > 0) { - lines.push('', value('Diagnostics:')); + lines.push(''); + lines.push(value('Diagnostics:')); for (const d of info.diagnostics) { const paint = d.severity === 'error' ? error : d.severity === 'warn' ? warning : muted; lines.push(` ${paint(`[${d.severity}]`)} ${value(d.message)}`); diff --git a/apps/pythinker-code/src/tui/components/messages/read-group.ts b/apps/pythinker-code/src/tui/components/messages/read-group.ts index 1a9aea70..fe39aa59 100644 --- a/apps/pythinker-code/src/tui/components/messages/read-group.ts +++ b/apps/pythinker-code/src/tui/components/messages/read-group.ts @@ -20,8 +20,8 @@ * src/missing.ts · failed */ -import type { TUI } from '@earendil-works/pi-tui'; -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import type { TUI } from '@pymodel/pi-tui'; +import { Container, Spacer, Text } from '@pymodel/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; @@ -135,7 +135,7 @@ export class ReadGroupComponent extends Container { if (pending > 0) { const bullet = currentTheme.fg('text', STATUS_BULLET); - const label = currentTheme.boldFg('textStrong', `Reading ${String(total)} files…`); + const label = currentTheme.boldFg('primary', `Reading ${String(total)} files…`); return `${bullet}${label}`; } @@ -147,7 +147,7 @@ export class ReadGroupComponent extends Container { } const bullet = currentTheme.fg('success', STATUS_BULLET); - const label = currentTheme.boldFg('textStrong', `Read ${String(total)} files`); + const label = currentTheme.boldFg('primary', `Read ${String(total)} files`); const linesPart = dim(` · ${String(totalLines)} ${totalLines === 1 ? 'line' : 'lines'}`); const failPart = failed > 0 ? currentTheme.fg('error', ` · ${String(failed)} failed`) : ''; return `${bullet}${label}${linesPart}${failPart}`; diff --git a/apps/pythinker-code/src/tui/components/messages/shell-execution.ts b/apps/pythinker-code/src/tui/components/messages/shell-execution.ts index 1a28c7c6..71a02b37 100644 --- a/apps/pythinker-code/src/tui/components/messages/shell-execution.ts +++ b/apps/pythinker-code/src/tui/components/messages/shell-execution.ts @@ -1,5 +1,5 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { Container, Text } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { Container, Text } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; @@ -48,8 +48,15 @@ export class ShellExecutionComponent extends Container { const allLines = command.split('\n'); const lines = previewLines === undefined ? allLines : allLines.slice(0, previewLines); for (const [i, line] of lines.entries()) { - const prefix = i === 0 ? '$ ' : ' '; - this.addChild(new Text(currentTheme.dim(prefix + line), 2, 0)); + // Distinguish the command (input) from the result (output): the `$` + // prompt uses the dedicated shell-mode hue, the command body uses + // `textDim`, and the result below is rendered one step dimmer in + // `textMuted` so the two stay separable without a connecting glyph. + const text = + i === 0 + ? currentTheme.fg('shellMode', '$ ') + currentTheme.dim(line) + : ` ${currentTheme.dim(line)}`; + this.addChild(new Text(text, 2, 0)); } } @@ -68,24 +75,23 @@ export class ShellExecutionComponent extends Container { maxLines: previewLines, tail: tailOutput, expandHint, + color: 'textMuted', }), ); } } export const shellExecutionResultRenderer: ResultRenderer = ( - toolCall: ToolCallBlockData, + _toolCall: ToolCallBlockData, result: ToolResultBlockData, ctx, ): Component[] => [ + // Result only. The command preview is owned by ToolCallComponent's + // buildCallPreview across the whole lifecycle (streaming, running, and + // done); rendering it here too would duplicate the command once the result + // lands. new ShellExecutionComponent({ - command: typeof toolCall.args['command'] === 'string' ? toolCall.args['command'] : '', result, expanded: ctx.expanded, - // Header truncates long bash commands to 60 chars. When the user expands - // the card with ctrl+o, reveal the full command (no line cap) so they - // can read what actually ran. - showCommand: ctx.expanded, - commandPreviewLines: undefined, }), ]; diff --git a/apps/pythinker-code/src/tui/components/messages/shell-run.ts b/apps/pythinker-code/src/tui/components/messages/shell-run.ts new file mode 100644 index 00000000..7077edde --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/shell-run.ts @@ -0,0 +1,134 @@ +import { Container, Text } from '@pymodel/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const RUNNING_TAIL_LINES = 5; +const TIMER_INTERVAL_MS = 1000; +// Cap the live running buffer so a command that spews output for minutes can't +// grow memory without bound or make every render re-strip a multi-MB string. +// Only affects the transient running tail; the final view uses the full +// captured stdout/stderr passed to finish(). +const MAX_COMBINED_CHARS = 256 * 1024; +const KEEP_COMBINED_CHARS = 64 * 1024; + +/** + * Live view for a user-initiated `!` shell command. Two phases: + * + * - running: dim, ANSI-stripped tail of the combined output, a `+N lines` + * overflow marker, an elapsed `(Xs)` timer that ticks every second, and a + * `(ctrl+b to run in background)` hint — matching claude-code's running card + * so warnings are grey rather than red while the command works. + * - finished: the standard `formatBashOutputForDisplay` view (stderr red only + * on failure), the timer stopped and the running chrome removed. + * + * Hardened so a misbehaving command can never crash the TUI: the running + * buffer is capped, and every render/render-request path swallows errors. + */ +export class ShellRunComponent extends Container { + private readonly textComponent: Text; + private combined = ''; + private running = true; + private backgrounded = false; + private disposed = false; + private finalStdout = ''; + private finalStderr = ''; + private finalIsError?: boolean; + private readonly startedAt = Date.now(); + private timer: ReturnType<typeof setInterval> | undefined; + + constructor(private readonly requestRender: () => void) { + super(); + this.textComponent = new Text(this.renderText(), 0, 0); + this.addChild(this.textComponent); + this.timer = setInterval(() => this.tick(), TIMER_INTERVAL_MS); + } + + append(text: string): void { + if (this.disposed || !this.running || text.length === 0) return; + this.combined += text; + if (this.combined.length > MAX_COMBINED_CHARS) { + this.combined = this.combined.slice(-KEEP_COMBINED_CHARS); + } + this.flush(); + } + + finish(stdout: string, stderr: string, isError?: boolean): void { + if (this.disposed || !this.running) return; + this.running = false; + this.finalStdout = stdout; + this.finalStderr = stderr; + this.finalIsError = isError; + this.clearTimer(); + this.flush(); + } + + finishBackgrounded(): void { + if (this.disposed || !this.running) return; + this.running = false; + this.backgrounded = true; + this.clearTimer(); + this.flush(); + } + + dispose(): void { + this.disposed = true; + this.clearTimer(); + } + + private tick(): void { + if (!this.running) return; + this.flush(); + } + + private flush(): void { + if (this.disposed) return; + try { + this.textComponent.setText(this.renderText()); + this.requestRender(); + } catch { + // Never let a render/render-request error escape into a timer or event + // handler — an uncaught exception there can take down the whole TUI. + } + } + + private clearTimer(): void { + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private renderText(): string { + try { + if (this.backgrounded) { + return ` ${currentTheme.fg('textDim', 'Moved to background.')}`; + } + if (!this.running) { + return formatBashOutputForDisplay(this.finalStdout, this.finalStderr, this.finalIsError) + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + } + const elapsed = Math.floor((Date.now() - this.startedAt) / 1000); + const dim = (s: string): string => currentTheme.fg('textDim', s); + const trimmed = sanitizeShellOutput(this.combined).trimEnd(); + let body: string; + let extra = 0; + if (trimmed.length === 0) { + body = ` ${dim('Running…')}`; + } else { + const lines = trimmed.split('\n'); + const tail = lines.slice(-RUNNING_TAIL_LINES); + extra = Math.max(0, lines.length - RUNNING_TAIL_LINES); + body = tail.map((line) => ` ${dim(line)}`).join('\n'); + } + const timing = ` ${dim(`${extra > 0 ? `+${extra} lines ` : ''}(${elapsed}s)`)}`; + const hint = ` ${dim('(ctrl+b to run in background)')}`; + return `${body}\n${timing}\n${hint}`; + } catch { + return ' (output unavailable)'; + } + } +} diff --git a/apps/pythinker-code/src/tui/components/messages/skill-activation.ts b/apps/pythinker-code/src/tui/components/messages/skill-activation.ts index e0a8f901..f8a06718 100644 --- a/apps/pythinker-code/src/tui/components/messages/skill-activation.ts +++ b/apps/pythinker-code/src/tui/components/messages/skill-activation.ts @@ -12,7 +12,7 @@ * metadata. */ -import { Container, Text, Spacer } from '@earendil-works/pi-tui'; +import { Container, Text, Spacer } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { SkillActivationTrigger } from '#/tui/types'; @@ -35,8 +35,8 @@ export class SkillActivationComponent extends Container { this.args = args; this.addChild(new Spacer(1)); const head = - currentTheme.boldFg('textStrong', '▶ Activated skill: ') + - currentTheme.boldFg('textStrong', name); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', name); this.headText = new Text(head, 0, 0); this.addChild(this.headText); const trimmed = args?.trim() ?? ''; @@ -50,8 +50,8 @@ export class SkillActivationComponent extends Container { override invalidate(): void { const head = - currentTheme.boldFg('textStrong', '▶ Activated skill: ') + - currentTheme.boldFg('textStrong', this.name); + currentTheme.boldFg('primary', '▶ Activated skill: ') + + currentTheme.boldFg('roleUser', this.name); this.headText.setText(head); if (this.previewText !== undefined && this.args !== undefined) { const trimmed = this.args.trim(); diff --git a/apps/pythinker-code/src/tui/components/messages/status-message.ts b/apps/pythinker-code/src/tui/components/messages/status-message.ts index 7377a3f5..e1503e1f 100644 --- a/apps/pythinker-code/src/tui/components/messages/status-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/status-message.ts @@ -1,4 +1,4 @@ -import { Container, Spacer, Text } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; @@ -12,20 +12,35 @@ export class StatusMessageComponent extends Container { super(); this.content = content; this.color = color; - const text = color === undefined - ? currentTheme.fg('textDim', content) - : currentTheme.fg(color, content); - this.textComponent = new Text(` ${text}`, 0, 0); + this.textComponent = new Text(this.renderText(), 0, 0); this.addChild(this.textComponent); } + // Update the body in place (used for live-streamed `!` shell output) without + // remounting the component. + updateContent(content: string): void { + this.content = content; + this.textComponent.setText(this.renderText()); + } + override invalidate(): void { - const text = this.color === undefined - ? currentTheme.fg('textDim', this.content) - : currentTheme.fg(this.color, this.content); - this.textComponent.setText(` ${text}`); + this.textComponent.setText(this.renderText()); super.invalidate(); } + + // Indent every line, not just the first. The `content` may be multi-line + // (e.g. `!` shell output); prefixing the whole string once would only indent + // the first line and leave the rest at column 0. Strip carriage returns + // first: a trailing `\r` (e.g. from CRLF server error pages) is zero-width + // for the line wrapper, so the padding spaces appended after it overwrite + // the visible content and the line renders blank. + private renderText(): string { + const colored = + this.color === undefined + ? currentTheme.fg('textDim', this.content) + : currentTheme.fg(this.color, this.content); + return colored.replaceAll('\r', '').split('\n').map((line) => ` ${line}`).join('\n'); + } } export class NoticeMessageComponent extends Container { diff --git a/apps/pythinker-code/src/tui/components/messages/status-panel.ts b/apps/pythinker-code/src/tui/components/messages/status-panel.ts index 205d0fb6..3f539c60 100644 --- a/apps/pythinker-code/src/tui/components/messages/status-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/status-panel.ts @@ -5,7 +5,13 @@ * separate from the TUI orchestration layer. */ -import type { ModelAlias, PermissionMode, SessionStatus } from '@pymodel/pythinker-code-sdk'; +import { + effectiveModelAlias, + type ModelAlias, + type PermissionMode, + type SessionStatus, + type ThinkingEffort, +} from '@pymodel/pythinker-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; import { currentTheme } from '#/tui/theme'; @@ -17,7 +23,11 @@ import { usagePercent, } from '#/utils/usage/usage-format'; -import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; +import { + buildExtraUsageSection, + buildManagedUsageReportLines, + type ManagedUsageReport, +} from './usage-panel'; interface FieldRow { readonly label: string; @@ -31,11 +41,7 @@ export interface StatusReportOptions { readonly workDir: string; readonly sessionId: string; readonly sessionTitle: string | null; - readonly thinkingLevel: string; - /** Fast-mode request; `fastModeSupported` must be true for the row to show on/off. */ - readonly fastMode?: boolean; - /** Whether the active model supports fast mode; gates the Fast mode row. */ - readonly fastModeSupported?: boolean; + readonly thinkingEffort: ThinkingEffort; readonly permissionMode: PermissionMode; readonly planMode: boolean; readonly contextUsage: number; @@ -52,15 +58,16 @@ type Colorize = (text: string) => string; function displayModelName(alias: string, models: Record<string, ModelAlias>): string { const model = models[alias]; - return model?.displayName ?? model?.model ?? alias; + const effective = model === undefined ? undefined : effectiveModelAlias(model); + return effective?.displayName ?? effective?.model ?? alias; } function formatModelStatus(options: StatusReportOptions): string { const model = options.status?.model ?? options.model; if (model.trim().length === 0) return 'not set'; - const thinking = options.status?.thinkingLevel ?? options.thinkingLevel; - return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; + const effort = options.status?.thinkingEffort ?? options.thinkingEffort; + return `${displayModelName(model, options.availableModels)} (thinking ${effort})`; } function addFieldRows( @@ -99,20 +106,12 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; - const fastMode = options.status?.fastMode ?? options.fastMode ?? false; - const fastModeSupported = - options.status?.fastModeSupported ?? options.fastModeSupported ?? false; const sessionId = options.sessionId.trim().length > 0 ? options.sessionId : 'none'; const rows: FieldRow[] = [ { label: 'Model', value: formatModelStatus(options) }, { label: 'Directory', value: options.workDir }, { label: 'Permissions', value: permission }, { label: 'Plan mode', value: planMode ? 'on' : 'off' }, - // "unavailable" when the active model cannot use fast mode; otherwise on/off. - { - label: 'Fast mode', - value: fastModeSupported ? (fastMode ? 'on' : 'off') : 'unavailable', - }, { label: 'Session', value: sessionId }, ]; const title = options.sessionTitle?.trim(); @@ -128,7 +127,8 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { addFieldRows(lines, rows, muted, value, errorStyle); const { ratio, tokens, maxTokens } = contextValues(options); - lines.push('', accent('Context window')); + lines.push(''); + lines.push(accent('Context window')); if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); @@ -146,7 +146,19 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { managedUsageError: options.managedUsageError, }); if (managedSection.length > 0) { - lines.push('', ...managedSection); + lines.push(''); + lines.push(...managedSection); + } + + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); } return lines; diff --git a/apps/pythinker-code/src/tui/components/messages/step-summary.ts b/apps/pythinker-code/src/tui/components/messages/step-summary.ts new file mode 100644 index 00000000..e2ed4338 --- /dev/null +++ b/apps/pythinker-code/src/tui/components/messages/step-summary.ts @@ -0,0 +1,36 @@ +import type { Component } from '@pymodel/pi-tui'; + +import { currentTheme } from '#/tui/theme'; + +/** + * A collapsed summary of older content within a turn. Accumulates counts of + * merged steps (thinking blocks and tool calls) and folded assistant messages, + * rendering them as a single muted line, e.g. + * `… thinking 5 times, call 50 tools, 12 messages`. + */ +export class StepSummaryComponent implements Component { + private thinking = 0; + private tool = 0; + private message = 0; + + get isEmpty(): boolean { + return this.thinking === 0 && this.tool === 0 && this.message === 0; + } + + addCounts(thinking: number, tool: number, message = 0): void { + this.thinking += thinking; + this.tool += tool; + this.message += message; + } + + invalidate(): void {} + + render(_width: number): string[] { + const parts: string[] = []; + if (this.thinking > 0) parts.push(`thinking ${this.thinking} times`); + if (this.tool > 0) parts.push(`call ${this.tool} tools`); + if (this.message > 0) parts.push(`${this.message} messages`); + if (parts.length === 0) return []; + return [currentTheme.dim(`\u2026 ${parts.join(', ')}`)]; + } +} diff --git a/apps/pythinker-code/src/tui/components/messages/thinking.ts b/apps/pythinker-code/src/tui/components/messages/thinking.ts index 6f6c5cc7..2448d5f6 100644 --- a/apps/pythinker-code/src/tui/components/messages/thinking.ts +++ b/apps/pythinker-code/src/tui/components/messages/thinking.ts @@ -5,18 +5,17 @@ * Supports expand/collapse via Ctrl+O (shared with tool output). */ -import { Markdown, type Component, type TUI } from '@earendil-works/pi-tui'; +import { Text, type Component, type TUI } from '@pymodel/pi-tui'; import { - formatThinkingSpinnerLabel, - MESSAGE_INDENT, BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS, + MESSAGE_INDENT, THINKING_PREVIEW_LINES, } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { currentTheme, createPythinkerThinkingMarkdownTheme } from '#/tui/theme'; -import { shimmerText } from '#/tui/utils/shimmer'; +import { currentTheme } from '#/tui/theme'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export type ThinkingRenderMode = 'live' | 'finalized'; @@ -27,13 +26,14 @@ export class ThinkingComponent implements Component { private expanded = false; private readonly ui: TUI | undefined; private spinnerFrame = 0; - private animationFrame = 0; private spinnerInterval: ReturnType<typeof setInterval> | undefined; - // Hold a single Markdown instance so pi-tui's (text, width) → lines cache + // Hold a single Text instance so pi-tui's (text, width) → lines cache // actually survives across renders. Re-constructing per render destroys // the cache and forces full re-wrap on every frame, which dominates CPU // once the transcript accumulates many finalized thinking blocks. - private textComponent: Markdown; + private readonly textComponent: Text; + + private renderCache: { width: number; lines: string[] } | undefined; constructor( text: string, @@ -45,33 +45,35 @@ export class ThinkingComponent implements Component { this.showMarker = showMarker; this.mode = mode; this.ui = ui; - this.textComponent = this.createMarkdown(text); + this.textComponent = new Text(this.styled(text), 0, 0); if (mode === 'live') { this.startSpinner(); } } - invalidate(): void { - // Markdown caches its default-style ANSI prefix on first render; rebuild - // the instance so a theme switch re-styles with the new palette. - this.textComponent = this.createMarkdown(this.text); + private markRenderDirty(): void { + this.renderCache = undefined; } - private createMarkdown(text: string): Markdown { - return new Markdown(text, 0, 0, createPythinkerThinkingMarkdownTheme(), { - color: (t) => currentTheme.fg('textDim', t), - italic: true, - }); + invalidate(): void { + this.markRenderDirty(); + this.textComponent.setText(this.styled(this.text)); } setText(text: string): void { if (this.text === text) return; this.text = text; - this.textComponent.setText(text); + this.markRenderDirty(); + this.textComponent.setText(this.styled(text)); + } + + private styled(text: string): string { + return currentTheme.italicFg('textDim', text); } finalize(): void { this.mode = 'finalized'; + this.markRenderDirty(); this.stopSpinner(); } @@ -82,53 +84,61 @@ export class ThinkingComponent implements Component { setExpanded(expanded: boolean): void { if (this.expanded === expanded) return; this.expanded = expanded; + this.markRenderDirty(); } render(width: number): string[] { - // Collapsed thinking renders no body text: while live only the spinner - // header shows (it sits directly above the prompt as the newest entry), - // and a finalized block disappears from the transcript entirely. - // Ctrl+O (expand) opts back into the full text. + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === width + ) { + return this.renderCache.lines; + } + + let rendered: string[]; if (this.mode === 'live') { const spinner = currentTheme.fg( - 'primary', + 'textDim', `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, ); - const label = shimmerText(formatThinkingSpinnerLabel(), { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - bandHalfWidth: 4, - }); - if (!this.expanded) return ['', spinner + label]; + rendered = ['', spinner + currentTheme.fg('textDim', 'thinking...')]; + if (this.expanded) { + const contentLines = this.renderContent(width); + const visibleLines = + contentLines.length > THINKING_PREVIEW_LINES + ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) + : contentLines; + rendered.push(...visibleLines.map((line) => MESSAGE_INDENT + line)); + } + } else if (!this.expanded) { + rendered = []; + } else { const contentLines = this.renderContent(width); - const visibleLines = - contentLines.length > THINKING_PREVIEW_LINES - ? contentLines.slice(contentLines.length - THINKING_PREVIEW_LINES) - : contentLines; - return ['', spinner + label, ...visibleLines.map((line) => MESSAGE_INDENT + line)]; + const lines: string[] = ['']; + for (let i = 0; i < contentLines.length; i++) { + const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; + lines.push(p + contentLines[i]); + } + rendered = lines; } - if (!this.expanded) return []; - - const contentLines = this.renderContent(width); - const rendered: string[] = ['']; - for (let i = 0; i < contentLines.length; i++) { - const p = i === 0 && this.showMarker ? currentTheme.fg('textDim', STATUS_BULLET) : MESSAGE_INDENT; - rendered.push(p + contentLines[i]); + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: rendered }; } return rendered; } private renderContent(width: number): string[] { - const contentWidth = Math.max(1, width - MESSAGE_INDENT.length); - return this.text.length > 0 ? this.textComponent.render(contentWidth) : ['']; + if (this.text.length === 0) return ['']; + return this.textComponent.render(Math.max(1, width - MESSAGE_INDENT.length)); } private startSpinner(): void { if (this.ui === undefined || this.spinnerInterval !== undefined) return; this.spinnerInterval = setInterval(() => { - this.animationFrame += 1; this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.markRenderDirty(); this.ui?.requestRender(); }, BRAILLE_SPINNER_INTERVAL_MS); } diff --git a/apps/pythinker-code/src/tui/components/messages/tool-call.ts b/apps/pythinker-code/src/tui/components/messages/tool-call.ts index 1a93f858..5cacbee5 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-call.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-call.ts @@ -5,19 +5,13 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { - Container, - Spacer, - Text, - TruncatedText, - truncateToWidth, - visibleWidth, -} from '@earendil-works/pi-tui'; -import type { Component, TUI } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import type { Component, TUI } from '@pymodel/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import { - BASH_STATUS_PULSE_INTERVAL_MS, + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, COMMAND_PREVIEW_LINES, RESULT_PREVIEW_LINES, THINKING_PREVIEW_LINES, @@ -28,37 +22,37 @@ import { } from '#/tui/constant/streaming'; import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; -import { createPythinkerMarkdownTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@pymodel/pythinker-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; import { formatTokenCount } from '#/utils/usage/usage-format'; -import { - dynamicWorkflowResultSummaryFromOutput, - isDynamicWorkflowResult, -} from './dynamic-workflow-mission-control'; -import { MarkdownPreviewComponent } from './markdown-preview'; +import { agentDynamicWorkflowResultSummaryFromOutput } from './agent-dynamic-workflow-progress'; import { PlanBoxComponent } from './plan-box'; import { ShellExecutionComponent } from './shell-execution'; import { countNonEmptyLines, pickChip } from './tool-renderers/chip'; import { buildGoalToolHeader } from './tool-renderers/goal'; import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry'; -import { TruncatedOutputComponent } from './tool-renderers/truncated'; const MAX_ARG_LENGTH = 60; const MAX_SUB_TOOL_CALLS_SHOWN = 4; -const MAX_SINGLE_SUBAGENT_TOOL_ROWS = 4; -// Hanging indent for a sub-tool's previewed output, nested under its activity row. -const SUBAGENT_SUBTOOL_OUTPUT_INDENT = 6; +// Cap the Agent `description` in the single-subagent header so a long prompt +// cannot wrap the header onto a second row and break the card's stable height. +const MAX_SUBAGENT_DESCRIPTION_LENGTH = 60; const APPROVED_PLAN_MARKER = '## Approved Plan:'; +const AUTO_APPROVED_PLAN_MARKER = '## Plan (auto-approved, not user-reviewed):'; const STREAMING_PROGRESS_INTERVAL_MS = 1000; -const SUBAGENT_ELAPSED_INTERVAL_MS = 1000; const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; const MAX_LIVE_OUTPUT_CHARS = 50_000; +/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ +const DETACH_HINT_DELAY_MS = 10_000; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + type SubagentTextKind = 'thinking' | 'text'; type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; @@ -99,6 +93,10 @@ export interface ToolCallSubagentSnapshot { readonly toolName: string; readonly toolCallDescription: string; readonly agentName: string | undefined; + /** Display name of the model the subagent is bound to, when known (live only). */ + readonly model?: string; + /** Thinking effort, present only for concrete levels (on/off hidden). */ + readonly effort?: string; readonly phase: SubagentPhase | undefined; readonly toolCount: number; readonly elapsedSeconds: number | undefined; @@ -143,22 +141,6 @@ function str(v: unknown): string { return typeof v === 'string' ? v : ''; } -function isDismissedAskUserQuestionResult(output: string): boolean { - let parsed: unknown; - try { - parsed = JSON.parse(output); - } catch { - return false; - } - if (typeof parsed !== 'object' || parsed === null) return false; - const answers = (parsed as { answers?: unknown }).answers; - return !( - typeof answers === 'object' && - answers !== null && - Object.keys(answers).length > 0 - ); -} - function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined { if (contextTokens === undefined || contextTokens <= 0) return undefined; return `${formatTokenCount(contextTokens)} tok`; @@ -193,13 +175,16 @@ function formatElapsed(seconds: number): string { } function extractApprovedPlan(output: string): string { - const markerIndex = output.indexOf(APPROVED_PLAN_MARKER); + const marker = output.includes(AUTO_APPROVED_PLAN_MARKER) + ? AUTO_APPROVED_PLAN_MARKER + : APPROVED_PLAN_MARKER; + const markerIndex = output.indexOf(marker); if (markerIndex < 0) return ''; - return output.slice(markerIndex + APPROVED_PLAN_MARKER.length).trim(); + return output.slice(markerIndex + marker.length).trim(); } interface ExitPlanModeOutcome { - readonly kind: 'approved' | 'rejected'; + readonly kind: 'approved' | 'auto_approved' | 'rejected'; readonly chosen?: string; readonly feedback?: string; readonly path?: string; @@ -215,11 +200,16 @@ const PLAN_SAVED_TO_RE = /\nPlan saved to: ([^\n]+)\n/; /** * Parses the ExitPlanMode result content string to recover the approval outcome * and optional plan path. Core-side templates live in - * `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts`: + * `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts` and + * `.../agent/permission/policies/exit-plan-mode-review-ask.ts`: * - Approved output starts with 'Exited plan mode.' and selected options * are reported as 'Selected approach: <label>'. Older outputs may start * with 'User approved option "<label>".' Plan-file mode may include * 'Plan saved to: <path>'. + * - Auto-approved output (auto permission mode skips the review ask) also + * starts with 'Exited plan mode.' but marks the plan body with + * '## Plan (auto-approved, not user-reviewed):' instead of + * '## Approved Plan:' — the user never saw or approved the plan. * - Rejected output starts with 'Plan rejected by user.' or older * 'User rejected the plan.'; feedback uses 'User rejected the plan. * Feedback:\n\n<text>'. @@ -239,6 +229,11 @@ function interpretExitPlanModeOutcome(output: string): ExitPlanModeOutcome { } const pathMatch = PLAN_SAVED_TO_RE.exec(output); const path = pathMatch?.[1]?.trim(); + if (output.includes(AUTO_APPROVED_PLAN_MARKER)) { + return path !== undefined && path.length > 0 + ? { kind: 'auto_approved', path } + : { kind: 'auto_approved' }; + } const optionMatch = SELECTED_APPROACH_RE.exec(output) ?? APPROVED_OPTION_RE.exec(output); if (optionMatch !== null) { return path !== undefined && path.length > 0 @@ -254,7 +249,8 @@ function isExitPlanModeOutcomeOutput(output: string): boolean { output.startsWith(PLAN_REJECT_PREFIX) || output.startsWith('Exited plan mode.') || APPROVED_OPTION_RE.test(output) || - output.includes(APPROVED_PLAN_MARKER) + output.includes(APPROVED_PLAN_MARKER) || + output.includes(AUTO_APPROVED_PLAN_MARKER) ); } @@ -375,7 +371,7 @@ function parseArgsPreview(value: string): Record<string, unknown> { return result; } -const PATH_KEYS = new Set(['path', 'file_path', 'notebook_path']); +const PATH_KEYS = new Set(['path', 'file_path']); function truncateArgValue(key: string, value: string): string { if (value.length <= MAX_ARG_LENGTH) return value; @@ -410,13 +406,13 @@ function formatKeyArgument( workspaceDir: string | undefined, ): string { const displayValue = - (toolName === 'Read' || toolName === 'NotebookEdit') && PATH_KEYS.has(key) + toolName === 'Read' && PATH_KEYS.has(key) ? makeWorkspaceRelativePath(value, workspaceDir) : value; return truncateArgValue(key, displayValue); } -function extractKeyArgument( +export function extractKeyArgument( toolName: string, args: Record<string, unknown>, workspaceDir?: string, @@ -426,20 +422,17 @@ function extractKeyArgument( Read: ['path', 'file_path'], Write: ['path', 'file_path'], Edit: ['path', 'file_path'], - NotebookEdit: ['notebook_path'], Grep: ['pattern'], Glob: ['pattern'], FetchURL: ['url'], WebSearch: ['query'], - ListMcpResourcesTool: ['server'], - ReadMcpResourceTool: ['uri'], // Prefer the short `description` so the header preview never spills a // multi-line `prompt` into the TUI chrome. Agent: ['description', 'prompt'], }; // Glob: concatenate multiple args into a single summary so the header - // shows pattern, optional explicit path, and include_dirs override. + // shows pattern, optional explicit path, and ignored-file inclusion. if (toolName === 'Glob') { const pattern = args['pattern']; if (typeof pattern !== 'string' || pattern.length === 0) return null; @@ -448,8 +441,8 @@ function extractKeyArgument( if (typeof path === 'string' && path.length > 0) { summary += ` · ${makeWorkspaceRelativePath(path, workspaceDir)}`; } - if (args['include_dirs'] === false) { - summary += ' · no dirs'; + if (args['include_ignored'] === true) { + summary += ' · include ignored'; } return truncateArgValue('pattern', summary); } @@ -467,17 +460,6 @@ function extractKeyArgument( return null; } -function displayToolName(toolName: string): string { - switch (toolName) { - case 'ListMcpResourcesTool': - return 'List MCP resources'; - case 'ReadMcpResourceTool': - return 'Read MCP resource'; - default: - return toolName; - } -} - function formatSubagentLabel(agentName: string | undefined): string { const raw = agentName?.trim(); if (raw === undefined || raw.length === 0) return 'SubAgent'; @@ -500,6 +482,8 @@ function tailNonEmptyLines(text: string, maxLines: number): string[] { } class PrefixedWrappedLine implements Component { + private renderCache: { width: number; lines: string[] } | undefined; + constructor( private readonly firstPrefix: string, private readonly continuationPrefix: string, @@ -508,14 +492,24 @@ class PrefixedWrappedLine implements Component { // unwrapped paragraph scrolls within a fixed window instead of growing // unbounded. The first kept row still gets `firstPrefix`. private readonly tailLines?: number, + // When set, the output is padded with empty continuation rows until it + // reaches this many display rows, so a short paragraph still fills a + // fixed-height window. Applied after `tailLines`. + private readonly minLines?: number, ) { } - invalidate(): void { } + invalidate(): void { + this.renderCache = undefined; + } render(width: number): string[] { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + if (isRenderCacheEnabled() && this.renderCache?.width === safeWidth) { + return this.renderCache.lines; + } + const prefixWidth = Math.max( visibleWidth(this.firstPrefix), visibleWidth(this.continuationPrefix), @@ -526,24 +520,25 @@ class PrefixedWrappedLine implements Component { this.tailLines !== undefined && wrapped.length > this.tailLines ? wrapped.slice(wrapped.length - this.tailLines) : wrapped; - return lines + if (this.minLines !== undefined) { + while (lines.length < this.minLines) lines.push(''); + } + const rendered = lines .map((line, index) => index === 0 ? `${this.firstPrefix}${line}` : `${this.continuationPrefix}${line}`, ) .map((line) => truncateToWidth(line, safeWidth, '…')); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; } } export class ToolCallComponent extends Container { private expanded = false; private toolCall: ToolCallBlockData; - private readonly markdownTheme = createPythinkerMarkdownTheme(); - private readonly subagentThinkingPreview = new MarkdownPreviewComponent('', { - firstPrefix: () => ` ${currentTheme.dim('◌')} `, - continuationPrefix: ' ', - tailRows: THINKING_PREVIEW_LINES, - appearance: 'thinking', - }); + private readonly markdownTheme = createMarkdownTheme(); private result: ToolResultBlockData | undefined; private ui: TUI | undefined; private planPath: string | undefined; @@ -578,8 +573,19 @@ export class ToolCallComponent extends Container { */ private subagentText = ''; private subagentThinkingText = ''; + /** Tracks whether the child agent's latest streamed delta was text or thinking, + * so the active window can follow whichever is currently live. */ + private lastSubagentStreamKind: SubagentTextKind = 'text'; // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -593,14 +599,17 @@ export class ToolCallComponent extends Container { private backgroundTaskTerminalPhase: 'done' | 'failed' | undefined; private subagentContextTokens: number | undefined; private subagentUsage: TokenUsage | undefined; + /** Display name of the model the subagent is bound to (from its `agent.status.updated`). */ + private subagentModel: string | undefined; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + private subagentEffort: string | undefined; private subagentResultSummary: string | undefined; private subagentError: string | undefined; private streamingProgressTimer: ReturnType<typeof setInterval> | undefined; private subagentElapsedTimer: ReturnType<typeof setInterval> | undefined; - private bashStatusPulseTimer: ReturnType<typeof setInterval> | undefined; - private bashStatusPulseVisible = true; private subagentStartedAtMs: number | undefined; private subagentEndedAtMs: number | undefined; + private subagentSpinnerFrame = 0; // ── Live progress lines ────────────────────────────────────────── // @@ -614,6 +623,13 @@ export class ToolCallComponent extends Container { private static readonly MAX_PROGRESS_LINES = 24; private liveOutput = ''; + /** + * Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running + * for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands. + */ + private detachHintTimer: ReturnType<typeof setTimeout> | undefined; + private detachHintVisible = false; + /** * Registered by a group container (`AgentGroupComponent` or * `ReadGroupComponent`) when this component is borrowed as a hidden state @@ -647,35 +663,74 @@ export class ToolCallComponent extends Container { this.buildSubagentBlock(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); - this.syncBashStatusPulseTimer(); + this.startDetachHintTimer(); } - override invalidate(): void { - this.headerText.setText(this.buildHeader()); - this.rebuildBody(); - super.invalidate(); - } + private renderCache: + | { width: number; lines: string[]; childRefs: Component[]; childLines: string[][] } + | undefined; override render(width: number): string[] { - this.headerText.setText(truncateToWidth(this.buildHeader(), Math.max(0, width))); - const lines = super.render(width); + const cache = this.renderCache; + const cacheValid = + isRenderCacheEnabled() && + cache !== undefined && + cache.width === width && + cache.childRefs.length === this.children.length; + + const childRefs: Component[] = []; + const childLines: string[][] = []; + let allReused = cacheValid; + + let i = 0; + for (const child of this.children) { + const lines = child.render(width); + childRefs.push(child); + childLines.push(lines); + if (cacheValid && (cache.childRefs[i] !== child || cache.childLines[i] !== lines)) { + allReused = false; + } + i++; + } + + if (allReused) { + return cache!.lines; + } + + const out: string[] = []; + for (const lines of childLines) { + for (const line of lines) out.push(line); + } const background = this.result === undefined ? this.toolCall.truncated === true ? undefined : 'toolPendingBg' - : this.result.is_error !== true - ? 'toolSuccessBg' - : 'toolErrorBg'; - if (background === undefined) return lines; - return lines.map((line, index) => - index === 0 - ? line - : currentTheme.bg( - background, - `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`, - ), - ); + : this.result.is_error === true + ? 'toolErrorBg' + : 'toolSuccessBg'; + const rendered = + background === undefined + ? out + : out.map((line, index) => + index === 0 + ? line + : currentTheme.bg( + background, + `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`, + ), + ); + if (isRenderCacheEnabled()) { + this.renderCache = { width, lines: rendered, childRefs, childLines }; + } + return rendered; + } + + override invalidate(): void { + this.renderCache = undefined; + this.headerText.setText(this.buildHeader()); + this.rebuildBody(); + super.invalidate(); } setExpanded(expanded: boolean): void { @@ -691,12 +746,13 @@ export class ToolCallComponent extends Container { setResult(result: ToolResultBlockData): void { this.result = result; - this.syncBashStatusPulseTimer(); // Result supersedes any live progress chatter; the result body is the // authoritative final state. Without this clear, a finished tool would // show both the streamed status lines and the final output stacked. this.progressLines = []; this.liveOutput = ''; + this.detachHintVisible = false; + this.stopDetachHintTimer(); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -713,7 +769,6 @@ export class ToolCallComponent extends Container { updateToolCall(toolCall: ToolCallBlockData): void { this.toolCall = toolCall; this.syncStreamingProgressTimer(); - this.syncBashStatusPulseTimer(); this.headerText.setText(this.buildHeader()); this.rebuildBody(); this.notifySnapshotChange(); @@ -756,7 +811,7 @@ export class ToolCallComponent extends Container { dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); - this.stopBashStatusPulseTimer(); + this.stopDetachHintTimer(); } /** @@ -858,14 +913,11 @@ export class ToolCallComponent extends Container { // 'spawning' and keep showing `Initializing...`. // Intermediate states without a result still use `subagentPhase`. // `backgrounded` has no result because background agents do not enter the - // transcript. - const derivedPhase: ToolCallSubagentSnapshot['phase'] = - this.backgroundTaskTerminalPhase ?? - (this.result !== undefined - ? this.result.is_error - ? 'failed' - : 'done' - : this.subagentPhase); + // transcript — but a foreground subagent detached via Ctrl+B keeps + // `subagentPhase === 'backgrounded'` even after its ToolResult lands, so + // the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse + // the standalone derivation so both paths agree. + const derivedPhase = this.getDerivedSubagentPhase(); const errorText = this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined); return { @@ -873,6 +925,8 @@ export class ToolCallComponent extends Container { toolName: this.toolCall.name, toolCallDescription: str(this.toolCall.args['description']) || str(this.toolCall.description), agentName: this.subagentAgentName, + model: this.subagentModel, + effort: this.subagentEffort, phase: derivedPhase, toolCount: finished, elapsedSeconds: this.getSubagentElapsedSeconds(), @@ -979,41 +1033,44 @@ export class ToolCallComponent extends Container { this.streamingProgressTimer = undefined; } - private syncBashStatusPulseTimer(): void { - if (!this.isRunningBash()) { - this.stopBashStatusPulseTimer(); - return; - } - if (this.ui === undefined || this.bashStatusPulseTimer !== undefined) { + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); return; } - this.bashStatusPulseVisible = true; - this.bashStatusPulseTimer = setInterval(() => { - if (!this.isRunningBash()) { - this.stopBashStatusPulseTimer(); - return; - } - this.bashStatusPulseVisible = !this.bashStatusPulseVisible; - this.headerText.setText(this.buildHeader()); + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); this.ui?.requestRender(); - }, BASH_STATUS_PULSE_INTERVAL_MS); - this.bashStatusPulseTimer.unref?.(); + }, DETACH_HINT_DELAY_MS); } - private stopBashStatusPulseTimer(): void { - if (this.bashStatusPulseTimer !== undefined) { - clearInterval(this.bashStatusPulseTimer); - this.bashStatusPulseTimer = undefined; - } - this.bashStatusPulseVisible = true; + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; } - private isRunningBash(): boolean { - return ( - this.toolCall.name === 'Bash' && - this.result === undefined && - this.toolCall.truncated !== true - ); + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); } private syncSubagentElapsedTimer(): void { @@ -1033,11 +1090,14 @@ export class ToolCallComponent extends Container { this.stopSubagentElapsedTimer(); return; } + // Drives both the braille spinner in the header and the elapsed-seconds + // refresh. Only the header text changes on a tick, so we avoid rebuilding + // the body (which would defeat the per-component render caches). + this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; this.headerText.setText(this.buildHeader()); - this.invalidate(); this.notifySnapshotChange(); this.ui?.requestRender(); - }, SUBAGENT_ELAPSED_INTERVAL_MS); + }, BRAILLE_SPINNER_INTERVAL_MS); } private stopSubagentElapsedTimer(): void { @@ -1131,6 +1191,8 @@ export class ToolCallComponent extends Container { updateSubagentMetrics(payload: { contextTokens?: number | undefined; usage?: TokenUsage | undefined; + modelDisplay?: string | undefined; + effortDisplay?: string | undefined; }): void { if (payload.contextTokens !== undefined && payload.contextTokens > 0) { this.subagentContextTokens = payload.contextTokens; @@ -1138,6 +1200,12 @@ export class ToolCallComponent extends Container { if (payload.usage !== undefined) { this.subagentUsage = payload.usage; } + if (payload.modelDisplay !== undefined) { + this.subagentModel = payload.modelDisplay; + } + if (payload.effortDisplay !== undefined) { + this.subagentEffort = payload.effortDisplay; + } this.headerText.setText(this.buildHeader()); this.invalidate(); this.notifySnapshotChange(); @@ -1203,6 +1271,22 @@ export class ToolCallComponent extends Container { this.notifySnapshotChange(); } + /** + * Mark a foreground subagent as detached-to-background. Called when a + * `background.task.started` event arrives for this agent (i.e. the user + * pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of + * flipping to `✓ Completed` when the spawn-success ToolResult lands. + */ + markBackgrounded(): void { + if (this.detachedFromForeground) return; + this.detachedFromForeground = true; + this.subagentPhase = 'backgrounded'; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Subagent id for the backing AgentTool call, used by routing to find a * tool call's backing subagent when reconciling background task lifecycle @@ -1241,6 +1325,7 @@ export class ToolCallComponent extends Container { } appendSubagentText(text: string, kind: SubagentTextKind = 'text'): void { + this.lastSubagentStreamKind = kind; if (kind === 'thinking') { this.subagentThinkingText += text; } else { @@ -1373,10 +1458,9 @@ export class ToolCallComponent extends Container { bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { bullet = currentTheme.fg('error', '✗ '); - } else if (toolCall.name === 'Bash' && !this.bashStatusPulseVisible) { - // Preserve the two-cell marker width while the calm running pulse is off. - bullet = ' '; } else { + // Solid bullet for in-flight tools — the previous marker ↔ blank + // toggle caused visible flicker on every re-render. bullet = currentTheme.fg('text', STATUS_BULLET); } @@ -1393,21 +1477,22 @@ export class ToolCallComponent extends Container { : 'Approved'; return `${label}${currentTheme.fg('success', ` · ${chipText}`)}`; } + if (outcome.kind === 'auto_approved') { + // Auto permission mode let the plan through without user review — + // a warning-toned chip keeps "the user approved this" out of the UI. + return `${label}${currentTheme.fg('warning', ' · Auto-approved')}`; + } return label; } if (toolCall.name === 'AskUserQuestion') { const isBackgroundAsk = toolCall.args['background'] === true; - if (isFinished && !isError && !isBackgroundAsk) { - if (result !== undefined && isDismissedAskUserQuestionResult(result.output)) { - return currentTheme.boldFg('warning', `${ABORTED_MARK} Dismissed`); - } - return currentTheme.boldFg('success', `${SUCCESS_MARK}Answered`); - } const label = isFinished ? isError ? 'Could not collect your input' - : 'Started background question' + : isBackgroundAsk + ? 'Started background question' + : 'Collected your answers' : isBackgroundAsk ? 'Starting background question' : 'Waiting for your input'; @@ -1415,6 +1500,20 @@ export class ToolCallComponent extends Container { return `${bullet}${currentTheme.boldFg(tone, label)}`; } + if (toolCall.name === 'Bash') { + // The command itself is rendered in the body (with a `$` prompt), so the + // header only names the action — repeating the command in parentheses + // would duplicate the body. Wording mirrors the other label-only headers + // (e.g. AskUserQuestion): the whole label takes the tone colour. + if (isTruncated) { + return `${bullet}${currentTheme.fg('error', 'Truncated')} ${currentTheme.boldFg('primary', 'Bash')}`; + } + const label = isFinished ? 'Ran a command' : 'Running a command'; + const tone = isError ? 'error' : 'primary'; + const chipStr = isFinished && result !== undefined ? this.buildHeaderChip(result) : ''; + return `${bullet}${currentTheme.boldFg(tone, label)}${chipStr}`; + } + const goalHeader = buildGoalToolHeader({ toolCall, result, @@ -1435,8 +1534,8 @@ export class ToolCallComponent extends Container { : verb; const toolLabel = decoded !== null - ? `${currentTheme.boldFg('textStrong', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` - : currentTheme.boldFg('textStrong', displayToolName(toolCall.name)); + ? `${currentTheme.boldFg('primary', decoded.toolName)}${currentTheme.dim(` · MCP/${decoded.serverName}`)}` + : currentTheme.boldFg('primary', toolCall.name); const argStr = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; let chipStr = ''; if (isFinished && result) chipStr = this.buildHeaderChip(result); @@ -1457,6 +1556,7 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1469,6 +1569,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1562,14 +1663,14 @@ export class ToolCallComponent extends Container { ? currentTheme.fg('error', '✗') : currentTheme.fg('success', '•'); const keyArg = extractKeyArgument(sub.name, sub.args, this.workspaceDir); - const nameCol = currentTheme.fg('textStrong', sub.name); + const nameCol = currentTheme.fg('primary', sub.name); const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; this.addChild(new Text(` ${mark} Used ${nameCol}${argCol}`, 0, 0)); } for (const [id, call] of this.ongoingSubCalls) { const keyArg = extractKeyArgument(call.name, call.args, this.workspaceDir); - const nameCol = currentTheme.fg('textStrong', call.name); + const nameCol = currentTheme.fg('primary', call.name); const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; void id; this.addChild(new Text(` ${currentTheme.dim('…')} Using ${nameCol}${argCol}`, 0, 0)); @@ -1667,29 +1768,38 @@ export class ToolCallComponent extends Container { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; return this.subagentPhase; } private buildSingleSubagentHeader(): string { const phase = this.getDerivedSubagentPhase(); - const isFailed = phase === 'failed'; const isDone = phase === 'done'; - const bullet = isFailed - ? currentTheme.fg('error', '✗ ') - : isDone - ? currentTheme.fg('success', STATUS_BULLET) - : currentTheme.fg('text', STATUS_BULLET); + const marker = this.buildSingleSubagentMarker(phase); const labelText = formatSubagentLabel(this.subagentAgentName); - const label = currentTheme.boldFg('textStrong', labelText); + const label = currentTheme.boldFg('primary', labelText); const status = this.formatSingleSubagentStatus(phase); - const description = str(this.toolCall.args['description']); + const rawDescription = str(this.toolCall.args['description']); + const description = + rawDescription.length > MAX_SUBAGENT_DESCRIPTION_LENGTH + ? `${rawDescription.slice(0, MAX_SUBAGENT_DESCRIPTION_LENGTH - 1)}…` + : rawDescription; const descriptionPlain = description.length > 0 ? ` (${description})` : ''; const descriptionText = descriptionPlain.length > 0 ? currentTheme.dim(descriptionPlain) : ''; - const isolationPlain = this.toolCall.args['isolation'] === 'worktree' ? ' · worktree' : ''; - const isolationText = isolationPlain.length > 0 ? currentTheme.dim(isolationPlain) : ''; - const stats = currentTheme.dim(this.formatSingleSubagentStatsText()); - return `${bullet}${label} ${status}${descriptionText}${isolationText}${stats}`; + const statsText = this.formatSingleSubagentStatsText(); + if (isDone) { + return `${marker}${currentTheme.boldFg('success', labelText)} ${currentTheme.fg('success', `Completed${descriptionPlain}${statsText}`)}`; + } + const stats = currentTheme.dim(statsText); + return `${marker}${label} ${status}${descriptionText}${stats}`; } private formatSingleSubagentStatus(phase: SubagentPhase | undefined): string { @@ -1711,9 +1821,10 @@ export class ToolCallComponent extends Container { } private formatSingleSubagentStatsText(): string { - const parts = [ - `${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`, - ]; + const parts: string[] = []; + if (this.subagentModel !== undefined) parts.push(this.subagentModel); + if (this.subagentEffort !== undefined) parts.push(this.subagentEffort); + parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`); const elapsed = this.getSubagentElapsedSeconds(); if (elapsed !== undefined) parts.push(formatElapsed(elapsed)); const tokens = @@ -1732,85 +1843,133 @@ export class ToolCallComponent extends Container { return Math.max(0, Math.floor((end - this.subagentStartedAtMs) / 1000)); } + private buildSingleSubagentMarker(phase: SubagentPhase | undefined): string { + if (phase === 'failed') return currentTheme.fg('error', '✗ '); + if (phase === 'done') return currentTheme.fg('success', STATUS_BULLET); + if (phase === 'backgrounded') return currentTheme.dim('◐ '); + // Active (queued / spawning / running): a braille spinner reads as alive + // where a static bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + return currentTheme.fg('primary', `${frame} `); + } + private buildSingleSubagentBlock(): void { - for (const activity of this.getRecentSubToolActivities()) { - const mark = - activity.phase === 'failed' - ? currentTheme.fg('error', '✗') - : activity.phase === 'done' - ? currentTheme.fg('success', '•') - : currentTheme.fg('text', '•'); - const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; - this.addChild(new Text(` ${mark} ${this.formatSubToolActivity(verb, activity)}`, 0, 0)); - this.addSubToolOutputPreview(activity); - } - - if (this.getDerivedSubagentPhase() === 'failed' && this.subagentError !== undefined) { - const errorLine = tailNonEmptyLines(this.subagentError, 1).at(-1); - if (errorLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('error', '└')} `, - ' ', - currentTheme.fg('error', errorLine), - ), - ); - } + const phase = this.getDerivedSubagentPhase(); + + // Every state shares the same skeleton — header, a one-line tool summary, + // and a fixed two-row content window — so the card height is identical + // while running and after it finishes (no end-of-run shrink). + this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + + if (phase === 'failed') { + this.addChild(this.buildSingleSubagentResultWindow('error')); + return; + } + if (phase === 'done' || phase === 'backgrounded') { + this.addChild(this.buildSingleSubagentResultWindow('output')); return; } + this.addChild(this.buildSingleSubagentActiveWindow()); + } - const outputLine = tailNonEmptyLines(this.subagentText, 1).at(-1); + /** Most-recently-started sub-tool, preferring one that is still running. */ + private getCurrentSubToolActivity(): SubToolActivity | undefined { + let latestOngoing: SubToolActivity | undefined; + let latest: SubToolActivity | undefined; + for (const activity of this.subToolActivities.values()) { + if (latest === undefined || activity.orderSeq > latest.orderSeq) latest = activity; + if ( + activity.phase === 'ongoing' && + (latestOngoing === undefined || activity.orderSeq > latestOngoing.orderSeq) + ) { + latestOngoing = activity; + } + } + return latestOngoing ?? latest; + } + + /** + * The single live stream shown in the active window. A running sub-tool with + * previewable output (Bash or any tool without a dedicated renderer) wins; + * otherwise the most-recently-updated of the child agent's text / thinking. + */ + private getActiveSubagentContent(): { text: string; tone: 'text' | 'thinking' } | undefined { + const current = this.getCurrentSubToolActivity(); if ( - this.getDerivedSubagentPhase() !== 'done' && - this.subagentThinkingText.trim().length > 0 + current?.phase === 'ongoing' && + current.output !== undefined && + current.output.trim().length > 0 && + (current.name === 'Bash' || isGenericToolResult(current.name)) ) { - // Scroll Markdown thinking within a fixed two-row, width-aware window. - this.subagentThinkingPreview.setText(this.subagentThinkingText.trimEnd()); - this.addChild(this.subagentThinkingPreview); + return { text: current.output, tone: 'text' }; } - if (outputLine !== undefined) { - this.addChild( - new PrefixedWrappedLine( - ` ${currentTheme.fg('text', '└')} `, - ' ', - currentTheme.fg('text', outputLine), - ), - ); + if (this.lastSubagentStreamKind === 'thinking' && this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + if (this.subagentText.trim().length > 0) { + return { text: this.subagentText, tone: 'text' }; } + if (this.subagentThinkingText.trim().length > 0) { + return { text: this.subagentThinkingText.trimEnd(), tone: 'thinking' }; + } + return undefined; } - private addSubToolOutputPreview(activity: SubToolActivity): void { - const output = activity.output; - if (output === undefined || output.trim().length === 0) return; - // Mirror the main agent: Bash and any tool without a dedicated renderer - // (every MCP tool included) get a truncated output preview. Recognized - // tools keep their compact activity row only. - if (activity.name !== 'Bash' && !isGenericToolResult(activity.name)) return; - this.addChild( - new TruncatedOutputComponent(output, { - // Subagent output is always fixed-truncated; it does not take part in - // the ctrl+o expand toggle, so don't advertise it either. - expanded: false, - expandHint: false, - isError: activity.phase === 'failed', - maxLines: RESULT_PREVIEW_LINES, - indent: SUBAGENT_SUBTOOL_OUTPUT_INDENT, - tail: activity.phase === 'ongoing', - }), + private buildSingleSubagentSummaryLine(): string { + const toolCount = this.subToolActivities.size; + const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; + const current = this.getCurrentSubToolActivity(); + if (current === undefined) { + return currentTheme.dim(` · ${countLabel}`); + } + const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', current.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + const mark = + current.phase === 'failed' + ? currentTheme.fg('error', ' ✗') + : current.phase === 'done' + ? currentTheme.fg('success', ' ✓') + : ''; + return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + } + + private buildSingleSubagentActiveWindow(): Component { + const gutter = currentTheme.dim('│'); + const content = this.getActiveSubagentContent(); + // Keep both tones muted: a bright `fg('text')` here flashed white whenever + // the window flipped between thinking and a brief text/tool-output segment. + const styled = + content === undefined + ? currentTheme.dim('…') + : content.tone === 'thinking' + ? currentTheme.dim(content.text) + : currentTheme.fg('textDim', content.text); + // Always exactly two rows (padded when short) so the live window matches + // the finished card's height. + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, ); } - private getRecentSubToolActivities(): SubToolActivity[] { - return [...this.subToolActivities.values()] - .toSorted((a, b) => a.orderSeq - b.orderSeq) - .slice(-MAX_SINGLE_SUBAGENT_TOOL_ROWS); - } - - private formatSubToolActivity(verb: string, activity: SubToolActivity): string { - const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); - const nameCol = currentTheme.fg('textStrong', activity.name); - const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; - return `${verb} ${nameCol}${argCol}`; + private buildSingleSubagentResultWindow(kind: 'output' | 'error'): Component { + const gutter = currentTheme.dim('│'); + const source = kind === 'error' ? this.subagentError : this.subagentText; + const text = source === undefined ? '' : tailNonEmptyLines(source, 2).join('\n'); + const styled = + kind === 'error' ? currentTheme.fg('error', text) : currentTheme.fg('text', text); + return new PrefixedWrappedLine( + ` ${gutter} `, + ` ${gutter} `, + styled, + THINKING_PREVIEW_LINES, + THINKING_PREVIEW_LINES, + ); } private buildCallPreview(): void { @@ -1833,7 +1992,14 @@ export class ToolCallComponent extends Container { this.buildStreamingPreview(this.toolCall.streamingArguments); return; } - const shouldCap = this.result !== undefined && !this.expanded; + // Cap Edit's diff as soon as args finalize, not only when the result + // lands — mirroring Write's writeShouldCap below. Otherwise the render + // tick between finalized args (streamingArguments cleared by the + // `tool.call.started` payload) and the result draws the full diff, then + // snaps back to the cap: a height collapse that triggers pi-tui's full + // redraw and wipes scrollback. Streaming frames (streamingArguments set) + // still take buildStreamingPreview above and never reach here. + const shouldCap = !this.expanded; if (name === 'Write') { const content = str(this.toolCall.args['content']); if (content.length === 0) return; @@ -1849,7 +2015,7 @@ export class ToolCallComponent extends Container { const remaining = allLines.length - shown.length; for (const [i, line] of shown.entries()) { const lineNum = currentTheme.dim(String(i + 1).padStart(4) + ' '); - this.addChild(new TruncatedText(` ${lineNum}${line.replaceAll('\t', ' ')}`)); + this.addChild(new Text(lineNum + line, 2, 0)); } if (writeShouldCap && remaining > 0) { this.addChild( @@ -1872,8 +2038,26 @@ export class ToolCallComponent extends Container { ...(shouldCap ? { maxLines: COMMAND_PREVIEW_LINES } : {}), }); for (const line of lines) { - this.addChild(new TruncatedText(` ${line.replaceAll('\t', ' ')}`)); + this.addChild(new Text(line, 2, 0)); } + } else if (name === 'Bash') { + // Surface the command in the body across the whole lifecycle — while + // streaming, running, and after the result lands. Keeping the collapsed + // command preview here (instead of yielding to the result renderer once + // the result lands) avoids a height collapse when a multi-line command + // finishes with short output: the command block stays put and only the + // live-output tail swaps for the result. Owned solely by buildCallPreview + // so the command never renders twice; shellExecutionResultRenderer + // renders the result only. + const command = str(this.toolCall.args['command']); + if (command.length === 0) return; + this.addChild( + new ShellExecutionComponent({ + command, + showCommand: true, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, + }), + ); } } @@ -1910,7 +2094,7 @@ export class ToolCallComponent extends Container { ? allLines.length - maxLines + i : i; const lineNum = currentTheme.dim(String(originalLineNumber + 1).padStart(4) + ' '); - this.addChild(new TruncatedText(` ${lineNum}${line.replaceAll('\t', ' ')}`)); + this.addChild(new Text(lineNum + line, 2, 0)); } return; } @@ -1937,7 +2121,7 @@ export class ToolCallComponent extends Container { new ShellExecutionComponent({ command: cmd, showCommand: true, - commandPreviewLines: COMMAND_PREVIEW_LINES, + commandPreviewLines: this.expanded ? undefined : COMMAND_PREVIEW_LINES, }), ); } @@ -1992,8 +2176,8 @@ export class ToolCallComponent extends Container { const { result } = this; if (result === undefined) return; - if (this.toolCall.name === 'DynamicWorkflow' && isDynamicWorkflowResult(result.output)) { - this.buildDynamicWorkflowResultSummary(result); + if (this.toolCall.name === 'AgentDynamicWorkflow') { + this.buildAgentDynamicWorkflowResultSummary(result); return; } @@ -2003,10 +2187,14 @@ export class ToolCallComponent extends Container { return; } - // Outputs that start with a `<system…>` tag are harness-injected - // reminders piggy-backing on a tool result. They are noise for the - // user, so suppress the body while keeping the header chip intact. - if (result.output.trimStart().startsWith('<system')) { + // Outputs that start with a `<system-reminder>` tag are harness-injected + // reminders piggy-backing on a tool result (e.g. a finalize hook rewrote + // the output). They are noise for the user, so suppress the body while + // keeping the header chip intact. Match the full reminder tag only: tool + // metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with a literal `<system>` + // is user data and must stay visible. + if (result.output.trimStart().startsWith('<system-reminder>')) { return; } @@ -2057,8 +2245,8 @@ export class ToolCallComponent extends Container { } } - private buildDynamicWorkflowResultSummary(result: ToolResultBlockData): void { - const summary = dynamicWorkflowResultSummaryFromOutput(result.output); + private buildAgentDynamicWorkflowResultSummary(result: ToolResultBlockData): void { + const summary = agentDynamicWorkflowResultSummaryFromOutput(result.output); const dim = (s: string): string => currentTheme.fg('textDim', s); const segments: string[] = []; @@ -2079,7 +2267,7 @@ export class ToolCallComponent extends Container { } if (segments.length > 0) { - this.addChild(new Text(`${dim('Dynamic Workflow: ')}${segments.join(dim(' · '))}`, 2, 0)); + this.addChild(new Text(`${dim('Agent dynamic_workflow: ')}${segments.join(dim(' · '))}`, 2, 0)); return; } @@ -2090,7 +2278,7 @@ export class ToolCallComponent extends Container { : result.is_error === true ? `${FAILURE_MARK.trimEnd()} Failed.` : `${SUCCESS_MARK.trimEnd()} Completed.`; - this.addChild(new Text(`${dim('Dynamic Workflow: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); + this.addChild(new Text(`${dim('Agent dynamic_workflow: ')}${currentTheme.fg(colorToken, label)}`, 2, 0)); } /** @@ -2107,6 +2295,8 @@ export class ToolCallComponent extends Container { } if (typeof parsed !== 'object' || parsed === null) return false; + const accent = (text: string) => currentTheme.fg('primary', text); + const answers = (parsed as { answers?: unknown }).answers; const note = (parsed as { note?: unknown }).note; @@ -2121,21 +2311,9 @@ export class ToolCallComponent extends Container { } for (const [question, answer] of Object.entries(answers as Record<string, unknown>)) { - const serializedAnswer = JSON.stringify(answer); - const answerText = - typeof answer === 'string' ? answer : (serializedAnswer ?? String(answer)); - this.addChild( - new MarkdownPreviewComponent(question, { - firstPrefix: () => ` ${currentTheme.dim('┌ Q')} `, - continuationPrefix: () => ` ${currentTheme.dim('│')} `, - }), - ); - this.addChild( - new MarkdownPreviewComponent(answerText, { - firstPrefix: () => ` ${currentTheme.fg('success', '└ ✓')} `, - continuationPrefix: ' ', - }), - ); + const answerText = typeof answer === 'string' ? answer : JSON.stringify(answer); + this.addChild(new Text(` ${currentTheme.dim('Q')} ${question}`, 0, 0)); + this.addChild(new Text(` ${accent('→')} ${answerText}`, 0, 0)); } return true; } diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts index 08159ecf..c7c8120f 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/chip.ts @@ -83,14 +83,8 @@ const editChip: ChipProvider = (toolCall) => { const writeChip: ChipProvider = (toolCall) => formatWriteChip(computeWriteStats(toolCall.args)); -const notebookEditChip: ChipProvider = () => '1 cell'; - -const readChip: ChipProvider = (_toolCall, result) => { - const cells = notebookCellCount(result.output); - return cells > 0 - ? pluralize(cells, 'cell') - : pluralize(countNonEmptyLines(result.output), 'line'); -}; +const readChip: ChipProvider = (_toolCall, result) => + pluralize(countNonEmptyLines(result.output), 'line'); const grepChip: ChipProvider = (_toolCall, result) => { const matches = countNonEmptyLines(result.output); @@ -120,84 +114,8 @@ const webSearchChip: ChipProvider = (_toolCall, result) => { const goalStatusOutputChip: ChipProvider = (_toolCall, result) => result.is_error ? '' : goalStatusChip(result.output); -const listMcpResourcesChip: ChipProvider = (_toolCall, result) => { - const parsed = parseJson(result.output); - return Array.isArray(parsed) ? pluralize(parsed.length, 'resource') : ''; -}; - -const readMcpResourceChip: ChipProvider = (_toolCall, result) => { - const parsed = parseJson(result.output); - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return ''; - const contents = (parsed as Record<string, unknown>)['contents']; - return Array.isArray(contents) ? pluralize(contents.length, 'content', 'contents') : ''; -}; - -const projectTaskChip: ChipProvider = (toolCall, result) => { - const id = strArg(toolCall.args, 'taskId') || result.output.match(/\bTask #(\d+)/u)?.[1]; - return id === undefined || id.length === 0 ? '' : `task #${id}`; -}; - -const taskListChip: ChipProvider = (toolCall, result) => { - if (toolCall.args['background'] === true) return ''; - const count = result.output.split('\n').filter((line) => /^#\d+\s/u.test(line)).length; - return count === 0 ? 'no tasks' : pluralize(count, 'task'); -}; - -const teamCreateChip: ChipProvider = (toolCall) => strArg(toolCall.args, 'team_name'); - -const sendMessageChip: ChipProvider = (toolCall, result) => { - const target = strArg(toolCall.args, 'to'); - if (target !== '*') return target.length === 0 ? '' : `@${target}`; - const parsed = parseJson(result.output); - if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return ''; - const recipients = (parsed as Record<string, unknown>)['recipients']; - return Array.isArray(recipients) ? pluralize(recipients.length, 'teammate') : ''; -}; - -const teamDeleteChip: ChipProvider = (_toolCall, result) => { - const parsed = parseJson(result.output); - return parsed !== null && - typeof parsed === 'object' && - !Array.isArray(parsed) && - (parsed as Record<string, unknown>)['success'] === true - ? 'deleted' - : ''; -}; - -const enterWorktreeChip: ChipProvider = (toolCall) => - strArg(toolCall.args, 'name') || 'worktree'; - -const exitWorktreeChip: ChipProvider = (toolCall) => - toolCall.args['action'] === 'remove' ? 'removed' : 'kept'; - -function parseJson(text: string): unknown { - try { - return JSON.parse(text) as unknown; - } catch { - return undefined; - } -} - -function notebookCellCount(output: string): number { - const parsed = parseJson(output); - const text = Array.isArray(parsed) - ? parsed - .filter( - (part): part is { type: 'text'; text: string } => - typeof part === 'object' && - part !== null && - (part as { type?: unknown }).type === 'text' && - typeof (part as { text?: unknown }).text === 'string', - ) - .map((part) => part.text) - .join('') - : output; - return [...text.matchAll(/<cell id=/gu)].length; -} - const REGISTRY: Record<string, ChipProvider> = { Edit: editChip, - NotebookEdit: notebookEditChip, Write: writeChip, Read: readChip, ReadMediaFile: readMediaChip, @@ -205,17 +123,6 @@ const REGISTRY: Record<string, ChipProvider> = { Glob: globChip, FetchURL: fetchChip, WebSearch: webSearchChip, - ListMcpResourcesTool: listMcpResourcesChip, - ReadMcpResourceTool: readMcpResourceChip, - TaskCreate: projectTaskChip, - TaskGet: projectTaskChip, - TaskUpdate: projectTaskChip, - TaskList: taskListChip, - TeamCreate: teamCreateChip, - TeamDelete: teamDeleteChip, - SendMessage: sendMessageChip, - EnterWorktree: enterWorktreeChip, - ExitWorktree: exitWorktreeChip, CreateGoal: goalStatusOutputChip, GetGoal: goalStatusOutputChip, }; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts index 04f20dc3..cf4a3ac2 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/goal.ts @@ -1,4 +1,4 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text } from '@pymodel/pi-tui'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/media.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/media.ts index fd753cd2..05991bc8 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/media.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/media.ts @@ -13,8 +13,8 @@ * message. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { Text } from '@pymodel/pi-tui'; import chalk from 'chalk'; import type { ChipProvider } from './chip'; @@ -27,11 +27,9 @@ export interface ReadMediaSummary { mimeType?: string; bytes?: number; url?: string; - originalSize?: string; } const PATH_TAG_RE = /^<(image|video)\s+path="([^"]+)">$/; -const ORIGINAL_SIZE_RE = /original size\s+(\d+x\d+px)/; const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s; function bytesFromBase64(b64: string): number { @@ -55,7 +53,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { let mimeType: string | undefined; let bytes: number | undefined; let url: string | undefined; - let originalSize: string | undefined; let foundMedia = false; for (const raw of parsed) { @@ -64,15 +61,11 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { const type = part['type']; if (type === 'text' && typeof part['text'] === 'string') { - const text = part['text']; - const tag = PATH_TAG_RE.exec(text); + const tag = PATH_TAG_RE.exec(part['text']); if (tag) { kind = tag[1] as 'image' | 'video'; path = tag[2]; - continue; } - const size = ORIGINAL_SIZE_RE.exec(text); - if (size) originalSize = size[1]; continue; } @@ -103,7 +96,6 @@ export function parseReadMediaOutput(output: string): ReadMediaSummary | null { if (mimeType !== undefined) summary.mimeType = mimeType; if (bytes !== undefined) summary.bytes = bytes; if (url !== undefined) summary.url = url; - if (originalSize !== undefined) summary.originalSize = originalSize; return summary; } @@ -117,7 +109,6 @@ function metaSegments(summary: ReadMediaSummary): string[] { const segs: string[] = []; if (summary.mimeType !== undefined) segs.push(summary.mimeType); if (summary.bytes !== undefined) segs.push(formatBytes(summary.bytes)); - if (summary.originalSize !== undefined) segs.push(summary.originalSize); return segs; } diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts index 0c5917fa..2a7b3953 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/registry.ts @@ -55,7 +55,6 @@ export function pickResultRenderer(toolName: string): ResultRenderer { case 'Think': return thinkSummary; case 'Edit': - case 'NotebookEdit': return editSummary; case 'Write': return writeSummary; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts index a3f929dc..206bf0e2 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/summary.ts @@ -10,8 +10,8 @@ * sees the actual error message, not a synthetic summary. */ -import type { Component } from '@earendil-works/pi-tui'; -import { Text } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; +import { Text } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { renderTruncated } from './truncated'; diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts index e619ff19..c8bfb758 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/truncated.ts @@ -1,6 +1,7 @@ -import { Text, truncateToWidth, type Component } from '@earendil-works/pi-tui'; +import { Text, truncateToWidth, type Component } from '@pymodel/pi-tui'; import { currentTheme } from '#/tui/theme'; +import type { ColorPalette } from '#/tui/theme/colors'; import type { ResultRenderer } from './types'; import { PREVIEW_LINES } from './types'; @@ -44,6 +45,10 @@ export class TruncatedOutputComponent implements Component { // When true, collapsed rendering keeps the latest visual rows instead of // the first rows. This is useful for live output from a running command. tail?: boolean; + // Foreground colour for successful (non-error) output. Defaults to + // `textDim`; Bash passes `textMuted` so its result sits one shade below + // the `textDim` command. Error output always uses `error`. + color?: keyof ColorPalette; }, ) { this.expanded = options.expanded; @@ -52,8 +57,9 @@ export class TruncatedOutputComponent implements Component { this.expandHint = options.expandHint ?? true; this.tail = options.tail ?? false; const cleaned = trimTrailingEmptyLines(output.split('\n')).join('\n'); + const successColor = options.color ?? 'textDim'; this.textComponent = new Text( - options.isError ? currentTheme.fg('error', cleaned) : currentTheme.dim(cleaned), + options.isError ? currentTheme.fg('error', cleaned) : currentTheme.fg(successColor, cleaned), this.indent, 0, ); diff --git a/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts b/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts index 94161d1a..cd14b5f1 100644 --- a/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts +++ b/apps/pythinker-code/src/tui/components/messages/tool-renderers/types.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import { RESULT_PREVIEW_LINES } from '#/tui/constant/rendering'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; diff --git a/apps/pythinker-code/src/tui/components/messages/usage-panel.ts b/apps/pythinker-code/src/tui/components/messages/usage-panel.ts index 3975fe58..d8dc6514 100644 --- a/apps/pythinker-code/src/tui/components/messages/usage-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/usage-panel.ts @@ -4,14 +4,10 @@ * the pattern stays consistent across command-triggered panels. */ -import type { Component } from '@earendil-works/pi-tui'; -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import type { - ContextUsageReport, - ModelCostRates, - SessionUsage, - TokenUsage, -} from '@pymodel/pythinker-code-sdk'; +import type { Component } from '@pymodel/pi-tui'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; +import { formatDuration } from '@pymodel/pythinker-code-oauth'; +import type { SessionUsage, TokenUsage } from '@pymodel/pythinker-code-sdk'; import { formatTokenCount, @@ -26,31 +22,54 @@ import type { ColorToken } from '#/tui/theme'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; const BOX_OVERHEAD = LEFT_MARGIN + 2 + 2 * SIDE_PADDING; -const USD_FORMATTER = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 6, -}); -const COST_RATE_ROWS = [ - ['Input', 'input'], - ['Output', 'output'], - ['Cache read', 'cacheRead'], - ['Cache write', 'cacheWrite'], -] as const satisfies readonly (readonly [string, keyof ModelCostRates])[]; type Colorize = (text: string) => string; +export interface ManagedUsageWindow { + readonly duration: number; + readonly unit: 'minute' | 'hour' | 'day' | 'week'; +} + export interface ManagedUsageRow { - readonly label: string; + readonly name?: string; + readonly window?: ManagedUsageWindow; readonly used: number; readonly limit: number; - readonly resetHint?: string; + readonly resetAt?: string; +} + +function usageRowLabel(row: ManagedUsageRow): string { + const window = row.window; + if (window !== undefined) { + if (window.unit === 'week') return 'Weekly limit'; + return `${String(window.duration)}${window.unit[0] ?? ''} limit`; + } + return row.name ?? 'Limit'; +} + +function usageRowResetHint(row: ManagedUsageRow): string | undefined { + const resetAt = row.resetAt; + if (resetAt === undefined) return undefined; + const parsed = Date.parse(resetAt); + if (!Number.isFinite(parsed)) return undefined; + const diffSec = Math.floor((parsed - Date.now()) / 1000); + if (diffSec <= 0) return 'reset'; + return `resets in ${formatDuration(diffSec)}`; +} + +export interface BoosterWalletInfo { + readonly balanceCents: number; + readonly totalCents: number; + readonly monthlyChargeLimitEnabled: boolean; + readonly monthlyChargeLimitCents: number; + readonly monthlyUsedCents: number; + readonly currency: string; } export interface ManagedUsageReport { readonly summary: ManagedUsageRow | null; readonly limits: readonly ManagedUsageRow[]; + readonly extraUsage?: BoosterWalletInfo | null; } export interface UsageReportOptions { @@ -68,12 +87,6 @@ export interface ManagedUsageReportLineOptions { readonly managedUsageError?: string; } -export interface CostReportOptions { - readonly model: string; - readonly modelCostRates?: ModelCostRates; - readonly totalCostUsd?: number; -} - function usageNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; } @@ -143,23 +156,110 @@ function buildManagedUsageSection( rows.push(...limits); const usedRatio = (r: ManagedUsageRow): number => r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; - const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); + const labels = rows.map((r) => usageRowLabel(r)); + const labelWidth = Math.max(10, ...labels.map((l) => l.length)); const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const out: string[] = [accent('Plan usage')]; - for (const row of rows) { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]!; const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); const pct = `${Math.round(ratioUsed * 100)}% used`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); - const label = row.label.padEnd(labelWidth, ' '); - const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; + const label = labels[i]!.padEnd(labelWidth, ' '); + const resetHint = usageRowResetHint(row); + const resetStr = resetHint !== undefined ? ` ${muted(resetHint)}` : ''; out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); } return out; } +function severityColor(sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' { + return sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; +} + +function currencySymbol(currency: string): string { + switch (currency.toUpperCase()) { + case 'CNY': + return '¥'; + case 'USD': + return '$'; + default: + return ''; + } +} + +interface CurrencyParts { + readonly symbol: string; + readonly number: string; +} + +function formatCurrencyParts(cents: number, currency: string): CurrencyParts { + const symbol = currencySymbol(currency); + const main = cents / 100; + const formatted = main.toFixed(2); + return symbol.length > 0 + ? { symbol, number: formatted } + : { symbol: '', number: `${formatted} ${currency}` }; +} + +export function buildExtraUsageSection( + extraUsage: BoosterWalletInfo | undefined | null, + accent: Colorize, + value: Colorize, + muted: Colorize, +): string[] { + if (extraUsage === undefined || extraUsage === null) return []; + + const hasMonthlyLimit = + extraUsage.monthlyChargeLimitEnabled && extraUsage.monthlyChargeLimitCents > 0; + + const balance = formatCurrencyParts(extraUsage.balanceCents, extraUsage.currency); + const used = formatCurrencyParts(extraUsage.monthlyUsedCents, extraUsage.currency); + const rows: Array<{ label: string; symbol: string; number: string }> = []; + let barLine: string | null = null; + + if (hasMonthlyLimit) { + const ratio = Math.max( + 0, + Math.min(extraUsage.monthlyUsedCents / extraUsage.monthlyChargeLimitCents, 1), + ); + const bar = renderProgressBar(ratio, 20); + barLine = ` ${currentTheme.fg(severityColor(ratioSeverity(ratio)), bar)}`; + const limit = formatCurrencyParts(extraUsage.monthlyChargeLimitCents, extraUsage.currency); + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', ...limit }); + rows.push({ label: 'Balance', ...balance }); + } else { + rows.push({ label: 'Used this month', ...used }); + rows.push({ label: 'Monthly limit', symbol: '', number: 'Unlimited' }); + rows.push({ label: 'Balance', ...balance }); + } + + // `Used this month` is the longest label; size the column to the widest label + // so the currency symbol starts in the same column on every row. + const labelWidth = Math.max(...rows.map((r) => r.label.length)); + // Right-align the numeric part of currency rows against each other so the + // decimal points line up (e.g. `¥ 50.00` / `¥200.00`). Text-only rows such as + // `Unlimited` carry no currency symbol, so they must not widen the numeric + // column — otherwise money values get padded with stray spaces. + const numberWidth = Math.max( + 0, + ...rows.filter((r) => r.symbol.length > 0).map((r) => visibleWidth(r.number)), + ); + const row = (label: string, symbol: string, number: string): string => { + const cell = symbol.length > 0 ? symbol + number.padStart(numberWidth, ' ') : number; + return ` ${muted(label.padEnd(labelWidth, ' '))} ${value(cell)}`; + }; + + const lines: string[] = [accent('Extra Usage')]; + if (barLine !== null) lines.push(barLine); + for (const r of rows) lines.push(row(r.label, r.symbol, r.number)); + + return lines; +} + export function buildManagedUsageReportLines(options: ManagedUsageReportLineOptions): string[] { const accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); @@ -176,91 +276,11 @@ export function buildManagedUsageReportLines(options: ManagedUsageReportLineOpti ); } -export function buildContextUsageReportLines(report: ContextUsageReport): string[] { - const accent = (text: string) => currentTheme.boldFg('primary', text); - const value = (text: string) => currentTheme.fg('text', text); - const muted = (text: string) => currentTheme.fg('textDim', text); - const maxTokens = report.maxTokens > 0 ? formatTokenCount(report.maxTokens) : 'unknown'; - const lines = [ - `${value(report.model ?? 'Model not configured')} ${value( - formatTokenCount(report.estimatedTokens), - )} / ${value(maxTokens)} tokens (${value(`${String(report.percentage)}%`)})`, - '', - accent('Estimated usage by category'), - ]; - const visibleCategories = report.categories.filter((category) => category.tokens > 0); - const categoryWidth = Math.max(1, ...visibleCategories.map((category) => category.name.length)); - for (const category of visibleCategories) { - lines.push( - ` ${muted(category.name.padEnd(categoryWidth))} ${value( - formatTokenCount(category.tokens).padStart(6), - )} ${muted(`${String(category.percentage)}%`)}`, - ); - } - if (report.tools.length > 0) { - const toolWidth = Math.max(...report.tools.map((tool) => tool.name.length)); - lines.push('', accent(`Active tools (${String(report.tools.length)})`)); - for (const tool of report.tools) { - lines.push( - ` ${muted(tool.name.padEnd(toolWidth))} ${value( - formatTokenCount(tool.tokens).padStart(6), - )} ${muted(tool.source)}`, - ); - } - } - return lines; -} - -export function buildCostReportLines(options: CostReportOptions): string[] { - const accent = (text: string) => currentTheme.boldFg('primary', text); - const value = (text: string) => currentTheme.fg('text', text); - const muted = (text: string) => currentTheme.fg('textDim', text); - const spend = options.totalCostUsd; - const spendText = - spend !== undefined && Number.isFinite(spend) && spend >= 0 - ? value(formatUsdAmount(spend)) - : muted('unavailable'); - const model = options.model.trim() || 'Not configured'; - const lines = [ - `${muted('Session spend')} ${spendText}`, - `${muted('Current model')} ${value(model)}`, - '', - accent('Rates per 1M tokens'), - ]; - - const rates: Array<readonly [string, number]> = []; - for (const [label, key] of COST_RATE_ROWS) { - const rate = options.modelCostRates?.[key]; - if (rate !== undefined && Number.isFinite(rate) && rate >= 0) { - rates.push([label, rate]); - } - } - if (rates.length === 0) { - lines.push(muted(' Pricing unavailable for this model.')); - return lines; - } - - const labelWidth = Math.max(...rates.map(([label]) => label.length)); - for (const [label, rate] of rates) { - lines.push( - ` ${muted(label.padEnd(labelWidth))} ${value(`${formatUsdAmount(rate)} / 1M tokens`)}`, - ); - } - return lines; -} - -function formatUsdAmount(amount: number): string { - if (amount > 0 && amount < 0.000001) return '<$0.000001'; - return USD_FORMATTER.format(amount); -} - export function buildUsageReportLines(options: UsageReportOptions): string[] { const accent = (text: string) => currentTheme.boldFg('primary', text); const value = (text: string) => currentTheme.fg('text', text); const muted = (text: string) => currentTheme.fg('textDim', text); const errorStyle = (text: string) => currentTheme.fg('error', text); - const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => - sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ accent('Session usage'), @@ -278,12 +298,16 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const bar = renderProgressBar(ratio, 20); const pct = `${String(usagePercent(options.contextTokens, options.maxContextTokens))}%`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); - lines.push('', accent('Context window'), ` ${barColoured} ${value(pct.padStart(6, ' '))} ` + + lines.push(''); + lines.push(accent('Context window')); + lines.push( + ` ${barColoured} ${value(pct.padStart(6, ' '))} ` + muted( `(${formatTokenCount(options.contextTokens)} / ${formatTokenCount( options.maxContextTokens, )})`, - )); + ), + ); } const managedSection = buildManagedUsageReportLines({ @@ -291,7 +315,19 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { managedUsageError: options.managedUsageError, }); if (managedSection.length > 0) { - lines.push('', ...managedSection); + lines.push(''); + lines.push(...managedSection); + } + + const extraSection = buildExtraUsageSection( + options.managedUsage?.extraUsage, + accent, + value, + muted, + ); + if (extraSection.length > 0) { + lines.push(''); + lines.push(...extraSection); } return lines; diff --git a/apps/pythinker-code/src/tui/components/messages/user-message.ts b/apps/pythinker-code/src/tui/components/messages/user-message.ts index 58288ec2..ec9f540c 100644 --- a/apps/pythinker-code/src/tui/components/messages/user-message.ts +++ b/apps/pythinker-code/src/tui/components/messages/user-message.ts @@ -2,25 +2,36 @@ * Renders a user message in the transcript. */ -import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@earendil-works/pi-tui'; +import { Spacer, Text, truncateToWidth, visibleWidth, type Component } from '@pymodel/pi-tui'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { markOsc133Zone } from '#/tui/utils/osc133'; +import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { private text: string; + private readonly bullet?: string; private spacerComponent: Spacer; private imageThumbnails: ImageThumbnail[]; - constructor(text: string, images?: ImageAttachment[]) { + private renderCache: { width: number; lines: string[] } | undefined; + + constructor(text: string, images?: ImageAttachment[], bullet?: string) { this.text = text; + this.bullet = bullet; this.spacerComponent = new Spacer(1); this.imageThumbnails = images?.map((img) => new ImageThumbnail(img)) ?? []; } + private markRenderDirty(): void { + this.renderCache = undefined; + } + invalidate(): void { + this.markRenderDirty(); for (const img of this.imageThumbnails) { img.invalidate?.(); } @@ -30,7 +41,16 @@ export class UserMessageComponent implements Component { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; - const bullet = currentTheme.fg('textDim', USER_MESSAGE_BULLET); + if ( + isRenderCacheEnabled() && + this.renderCache !== undefined && + this.renderCache.width === safeWidth + ) { + return this.renderCache.lines; + } + + const marker = this.bullet ?? USER_MESSAGE_BULLET; + const bullet = marker.length > 0 ? currentTheme.boldFg('roleUser', marker) : ''; const bulletWidth = visibleWidth(bullet); const contentWidth = Math.max(1, safeWidth - bulletWidth); @@ -41,14 +61,13 @@ export class UserMessageComponent implements Component { lines.push(line); } - // Text — re-dye on every render so theme switches are reflected - const coloredText = currentTheme.fg('textStrong', this.text); + // Text is re-dyed from the current theme; invalidate() (theme change) clears + // the render cache so the new colours are picked up on the next render. + const coloredText = currentTheme.boldFg('roleUser', this.text); const textLines = new Text(coloredText, 0, 0).render(contentWidth); for (let i = 0; i < textLines.length; i++) { const prefix = i === 0 ? bullet : ' '.repeat(bulletWidth); - const row = prefix + textLines[i]; - const padding = ' '.repeat(Math.max(0, safeWidth - visibleWidth(row))); - lines.push(currentTheme.bg('surfaceHighlight', row + padding)); + lines.push(prefix + textLines[i]); } // Images — indented to align with text after the bullet @@ -59,6 +78,38 @@ export class UserMessageComponent implements Component { } } - return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = markOsc133Zone( + lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }), + ); + if (isRenderCacheEnabled()) { + this.renderCache = { width: safeWidth, lines: rendered }; + } + return rendered; + } +} + +function isImageLine(line: string): boolean { + return line.includes('\u001B_G') || line.includes('\u001B]1337;File='); +} + +/** + * Invisible turn-boundary marker for replay. Some replayed records start a + * new turn without anything to show — the goal driver's synthetic + * continuation prompt is model-facing and never rendered live — but the + * transcript still needs a mounted boundary component so step/assistant + * folding (and window trimming) can find the turn edges. Renders zero lines. + */ +export class ReplayTurnBoundaryComponent implements Component { + invalidate(): void {} + render(_width: number): string[] { + return []; } } diff --git a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts index 8c413f68..5665011c 100644 --- a/apps/pythinker-code/src/tui/components/panes/activity-pane.ts +++ b/apps/pythinker-code/src/tui/components/panes/activity-pane.ts @@ -1,29 +1,47 @@ -import { Container, Spacer } from '@earendil-works/pi-tui'; +import { Container, Spacer, Text } from '@pymodel/pi-tui'; -import type { ActivityLoader } from '../chrome/activity-loader'; +import type { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { ACTIVITY_DETAIL_INDENT } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; export type ActivityPaneMode = 'hidden' | 'waiting' | 'thinking' | 'composing' | 'tool'; export interface ActivityPaneOptions { readonly mode: ActivityPaneMode; - readonly spinner?: ActivityLoader; + readonly spinner?: MoonLoader; + readonly tip?: string; + /** Extra dim line rendered under the spinner (e.g. step retry error detail). */ + readonly detail?: string; +} + +export function formatActivitySpinnerTip(tip: string | undefined): string { + return tip === undefined || tip.length === 0 ? '' : ` · Tip: ${tip}`; } export class ActivityPaneComponent extends Container { + private spinnerRef?: MoonLoader; + constructor(options: ActivityPaneOptions) { super(); + this.spinnerRef = options.spinner; - if (options.mode === 'waiting' || options.mode === 'tool') { - if (options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); + if ( + (options.mode === 'waiting' || options.mode === 'tool' || options.mode === 'composing') && + options.spinner !== undefined + ) { + this.addChild(new Spacer(1)); + options.spinner.setTip(formatActivitySpinnerTip(options.tip)); + this.addChild(options.spinner); + if (options.detail !== undefined && options.detail.length > 0) { + this.addChild(new Text(currentTheme.fg('textDim', options.detail), ACTIVITY_DETAIL_INDENT, 0)); } - return; } + } - if (options.mode === 'composing' && options.spinner !== undefined) { - this.addChild(new Spacer(1)); - this.addChild(options.spinner); + override render(width: number): string[] { + if (this.spinnerRef && 'setAvailableWidth' in this.spinnerRef) { + this.spinnerRef.setAvailableWidth(width); } + return super.render(width); } } diff --git a/apps/pythinker-code/src/tui/components/panes/btw-panel.ts b/apps/pythinker-code/src/tui/components/panes/btw-panel.ts index e3e8a181..f55713a0 100644 --- a/apps/pythinker-code/src/tui/components/panes/btw-panel.ts +++ b/apps/pythinker-code/src/tui/components/panes/btw-panel.ts @@ -1,15 +1,16 @@ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, MarkdownTheme } from '@pymodel/pi-tui'; import { Markdown, Text, truncateToWidth, visibleWidth, -} from '@earendil-works/pi-tui'; +} from '@pymodel/pi-tui'; import chalk from 'chalk'; -import { MarkdownPreviewComponent } from '#/tui/components/messages/markdown-preview'; -import { THINKING_PREVIEW_LINES } from '#/tui/constant/rendering'; -import { currentTheme } from '#/tui/theme'; +import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; +import { currentTheme } from '../../theme'; +import type { InlineSkillActivation } from '../../types'; +import { createMarkdownOptions } from '../../utils/markdown-options'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -19,7 +20,6 @@ interface BtwTurn { readonly prompt: string; answer: string; thinking: string; - readonly thinkingPreview: MarkdownPreviewComponent; error?: string | undefined; phase: BtwPanelPhase; } @@ -32,7 +32,10 @@ interface BtwBodyRender { export interface BtwPanelOptions { readonly markdownTheme: MarkdownTheme; readonly canUseScrollKeys: () => boolean; - readonly onPrompt: (prompt: string) => void; + readonly onPrompt: ( + prompt: string, + inlineSkillActivations?: readonly InlineSkillActivation[], + ) => void; readonly terminalRows: () => number; } @@ -46,7 +49,7 @@ export class BtwPanelComponent implements Component { constructor(private readonly options: BtwPanelOptions) {} - submit(prompt: string): void { + submit(prompt: string, inlineSkillActivations?: readonly InlineSkillActivation[]): void { const normalized = prompt.trim(); if (normalized.length === 0 || this.isRunning()) return; this.followTail = true; @@ -56,15 +59,9 @@ export class BtwPanelComponent implements Component { prompt: normalized, answer: '', thinking: '', - thinkingPreview: new MarkdownPreviewComponent('', { - firstPrefix: '', - continuationPrefix: '', - tailRows: THINKING_PREVIEW_LINES, - appearance: 'thinking', - }), phase: 'running', }); - this.options.onPrompt(normalized); + this.options.onPrompt(normalized, inlineSkillActivations); } addTransientNotice(message: string): void { @@ -82,7 +79,6 @@ export class BtwPanelComponent implements Component { const turn = this.currentTurn(); if (turn === undefined) return; turn.thinking += delta; - turn.thinkingPreview.setText(turn.thinking); } markDone(resultSummary?: string | undefined): void { @@ -102,12 +98,6 @@ export class BtwPanelComponent implements Component { prompt: '', answer: '', thinking: '', - thinkingPreview: new MarkdownPreviewComponent('', { - firstPrefix: '', - continuationPrefix: '', - tailRows: THINKING_PREVIEW_LINES, - appearance: 'thinking', - }), error, phase: 'failed', }); @@ -119,11 +109,7 @@ export class BtwPanelComponent implements Component { turn.phase = 'failed'; } - invalidate(): void { - for (const turn of this.turns) { - turn.thinkingPreview.invalidate(); - } - } + invalidate(): void {} render(width: number): string[] { const safeWidth = Math.max(4, width); @@ -214,9 +200,18 @@ export class BtwPanelComponent implements Component { const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { - lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + lines.push( + ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, createMarkdownOptions()).render(width), + ); } else if (thinking.length > 0) { - lines.push(...turn.thinkingPreview.render(width)); + const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + lines.push(...visibleThinking); } else if (turn.error === undefined) { lines.push(chalk.hex(currentTheme.palette.textDim)('Waiting for answer...')); } diff --git a/apps/pythinker-code/src/tui/components/panes/queue-pane.ts b/apps/pythinker-code/src/tui/components/panes/queue-pane.ts index 77800b97..d598cc22 100644 --- a/apps/pythinker-code/src/tui/components/panes/queue-pane.ts +++ b/apps/pythinker-code/src/tui/components/panes/queue-pane.ts @@ -1,4 +1,4 @@ -import { Container, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; +import { Container, truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; import { SELECT_POINTER } from '../../constant/symbols'; import type { QueuedMessage } from '../../types'; @@ -22,26 +22,40 @@ export class QueuePaneComponent extends Container { this.messages = options.messages; if (options.messages.length > 0) { + // Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when + // there is at least one plain-text or skill item steering would send. + const hasSteerable = options.messages.some((m) => m.mode !== 'bash'); + const canSteer = options.canSteerImmediately && hasSteerable; this.hint = options.isCompacting && !options.isStreaming ? ' ↑ to edit · will send after compaction' - : !options.canSteerImmediately - ? ' ↑ to edit · will send after current task' - : ' ↑ to edit · ctrl-s to steer immediately'; + : canSteer + ? ' ↑ to edit · ctrl-s to steer immediately' + : ' ↑ to edit · will send after current task'; } } override render(width: number): string[] { const accent = (text: string) => currentTheme.fg('accent', text); + const shell = (text: string) => currentTheme.fg('shellMode', text); const dim = (text: string) => currentTheme.fg('textDim', text); const lines: string[] = [currentTheme.fg('border', '─'.repeat(width))]; for (const item of this.messages) { const singleLine = item.text.replaceAll(/\s+/g, ' ').trim(); const prefix = ` ${SELECT_POINTER} `; - const availableWidth = Math.max(1, width - visibleWidth(prefix)); - const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); - lines.push(accent(prefix + truncated)); + if (item.mode === 'bash') { + // Shell commands get a `$ ` prompt and the shell-mode hue so they read + // as commands, not as plain text that would be sent to the model. + const prompt = '$ '; + const availableWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(prompt)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix) + shell(prompt + truncated)); + } else { + const availableWidth = Math.max(1, width - visibleWidth(prefix)); + const truncated = truncateToWidth(singleLine, availableWidth, ELLIPSIS); + lines.push(accent(prefix + truncated)); + } } if (this.hint !== undefined) { diff --git a/apps/pythinker-code/src/tui/config.ts b/apps/pythinker-code/src/tui/config.ts index 4dfa8c32..b492b12a 100644 --- a/apps/pythinker-code/src/tui/config.ts +++ b/apps/pythinker-code/src/tui/config.ts @@ -21,10 +21,6 @@ export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); -// "fixed" pins the editor + footer to the bottom of a full-height screen -// with an app-owned transcript viewport; "inline" is the legacy flow. -export const TuiLayoutSchema = z.enum(['fixed', 'inline']); - export const NotificationsConfigSchema = z.object({ enabled: z.boolean(), condition: NotificationConditionSchema, @@ -34,34 +30,32 @@ export const UpgradePreferencesSchema = z.object({ autoInstall: z.boolean(), }); -const StatusLineFileSchema = z.object({ - show_model: z.boolean().optional(), - show_effort: z.boolean().optional(), - show_token_speed: z.boolean().optional(), - show_context_bar: z.boolean().optional(), - show_git: z.boolean().optional(), - show_modes: z.boolean().optional(), - show_elapsed: z.boolean().optional(), - show_goal: z.boolean().optional(), - show_background_tasks: z.boolean().optional(), +export const STATUS_LINE_ITEMS = ['mode', 'goal', 'model', 'tasks', 'cwd', 'git', 'tips'] as const; +export type StatusLineItem = (typeof STATUS_LINE_ITEMS)[number]; + +export const StatusLineFileConfigSchema = z.object({ + items: z.array(z.string()).optional(), + command: z.string().optional(), }); export const StatusLineConfigSchema = z.object({ - showModel: z.boolean(), - showEffort: z.boolean(), - showTokenSpeed: z.boolean(), - showContextBar: z.boolean(), - showGit: z.boolean(), - showModes: z.boolean(), - showElapsed: z.boolean(), - showGoal: z.boolean(), - showBackgroundTasks: z.boolean(), + /** Ordered built-in slots for footer line 1; null means the default layout. */ + items: z.array(z.enum(STATUS_LINE_ITEMS)).nullable(), + /** User command whose first stdout line replaces footer line 1; null disables. */ + command: z.string().nullable(), }); +export type StatusLineConfig = z.infer<typeof StatusLineConfigSchema>; + +export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { + items: null, + command: null, +}; export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), - layout: TuiLayoutSchema.optional(), - copy_full_response: z.boolean().optional(), + render_latex: z.boolean().optional(), + disable_paste_burst: z.boolean().optional(), + cache_expiry_hint: z.boolean().optional(), editor: z .object({ command: z.string().optional(), @@ -78,25 +72,30 @@ export const TuiConfigFileSchema = z.object({ auto_install: z.boolean().optional(), }) .optional(), - status_line: StatusLineFileSchema.optional(), + status_line: StatusLineFileConfigSchema.optional(), }); export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, - layout: TuiLayoutSchema, - copyFullResponse: z.boolean(), + /** LaTeX math rendering in Markdown; optional only so older hand-built test + * fixtures still typecheck. */ + renderLatex: z.boolean().optional(), + disablePasteBurst: z.boolean(), + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + cacheExpiryHint: z.boolean().optional(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, - statusLine: StatusLineConfigSchema, + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + statusLine: StatusLineConfigSchema.optional(), }); export type TuiConfigFileShape = z.infer<typeof TuiConfigFileSchema>; export type TuiConfig = z.infer<typeof TuiConfigSchema>; -export type TuiLayout = z.infer<typeof TuiLayoutSchema>; export type NotificationsConfig = z.infer<typeof NotificationsConfigSchema>; export type UpgradePreferences = z.infer<typeof UpgradePreferencesSchema>; -export type StatusLineConfig = z.infer<typeof StatusLineConfigSchema>; export const DEFAULT_NOTIFICATIONS_CONFIG: NotificationsConfig = { enabled: true, @@ -107,22 +106,11 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { autoInstall: true, }; -export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { - showModel: true, - showEffort: true, - showTokenSpeed: true, - showContextBar: true, - showGit: true, - showModes: true, - showElapsed: true, - showGoal: true, - showBackgroundTasks: true, -}; - export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', - layout: 'fixed', - copyFullResponse: false, + renderLatex: true, + disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -148,7 +136,10 @@ export function getTuiConfigPath(): string { return join(getDataDir(), 'tui.toml'); } -export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Promise<TuiConfig> { +export async function loadTuiConfig( + filePath: string = getTuiConfigPath(), + warn?: (message: string) => void, +): Promise<TuiConfig> { if (!existsSync(filePath)) { await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); return DEFAULT_TUI_CONFIG; @@ -156,19 +147,22 @@ export async function loadTuiConfig(filePath: string = getTuiConfigPath()): Prom try { const text = await readFile(filePath, 'utf-8'); - return parseTuiConfig(text); + return parseTuiConfig(text, warn); } catch { throw new TuiConfigParseError(DEFAULT_TUI_CONFIG); } } -export function parseTuiConfig(tomlText: string): TuiConfig { +export function parseTuiConfig( + tomlText: string, + warn?: (message: string) => void, +): TuiConfig { if (tomlText.trim().length === 0) { return DEFAULT_TUI_CONFIG; } const raw = parseToml(tomlText) as Record<string, unknown>; const parsed = TuiConfigFileSchema.parse(raw); - return normalizeTuiConfig(parsed); + return normalizeTuiConfig(parsed, warn); } export async function saveTuiConfig( @@ -179,12 +173,31 @@ export async function saveTuiConfig( await writeFile(filePath, renderTuiConfig(config), 'utf-8'); } -export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { +export function normalizeTuiConfig( + config: TuiConfigFileShape, + warn: (message: string) => void = (message) => { + // oxlint-disable-next-line no-console + console.warn(message); + }, +): TuiConfig { const command = config.editor?.command?.trim(); + const statusLineCommand = config.status_line?.command?.trim(); + const knownItems = new Set<string>(STATUS_LINE_ITEMS); + const statusLineItems = + config.status_line?.items + ?.filter((item) => { + const known = knownItems.has(item); + if (!known) { + warn(`[tui.toml] ignoring unknown status_line item: ${item}`); + } + return known; + }) + .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, - layout: config.layout ?? DEFAULT_TUI_CONFIG.layout, - copyFullResponse: config.copy_full_response ?? DEFAULT_TUI_CONFIG.copyFullResponse, + renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, + disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -195,31 +208,46 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { autoInstall: config.upgrade?.auto_install ?? DEFAULT_UPGRADE_PREFERENCES.autoInstall, }, statusLine: { - showModel: config.status_line?.show_model ?? DEFAULT_STATUS_LINE_CONFIG.showModel, - showEffort: config.status_line?.show_effort ?? DEFAULT_STATUS_LINE_CONFIG.showEffort, - showTokenSpeed: - config.status_line?.show_token_speed ?? DEFAULT_STATUS_LINE_CONFIG.showTokenSpeed, - showContextBar: - config.status_line?.show_context_bar ?? DEFAULT_STATUS_LINE_CONFIG.showContextBar, - showGit: config.status_line?.show_git ?? DEFAULT_STATUS_LINE_CONFIG.showGit, - showModes: config.status_line?.show_modes ?? DEFAULT_STATUS_LINE_CONFIG.showModes, - showElapsed: config.status_line?.show_elapsed ?? DEFAULT_STATUS_LINE_CONFIG.showElapsed, - showGoal: config.status_line?.show_goal ?? DEFAULT_STATUS_LINE_CONFIG.showGoal, - showBackgroundTasks: - config.status_line?.show_background_tasks ?? - DEFAULT_STATUS_LINE_CONFIG.showBackgroundTasks, + items: statusLineItems, + command: + statusLineCommand === undefined || statusLineCommand.length === 0 + ? null + : statusLineCommand, }, }); } export function renderTuiConfig(config: TuiConfig): string { + // An active status_line must round-trip: any preference save rewrites the + // whole file, so the section is emitted live when set and left as a + // commented-out guide when unset. + const statusItems = config.statusLine?.items; + const statusCommand = config.statusLine?.command; + const statusLines: string[] = []; + if (statusItems !== null && statusItems !== undefined) { + statusLines.push(`items = ${JSON.stringify(statusItems)}`); + } + if (statusCommand) { + statusLines.push(`command = "${escapeTomlBasicString(statusCommand)}"`); + } + const statusSection = + statusLines.length > 0 + ? `[status_line]\n${statusLines.join('\n')}\n` + : `# [status_line] +# Pick and order the built-in footer slots: ${STATUS_LINE_ITEMS.join(', ')} +# items = ${JSON.stringify([...STATUS_LINE_ITEMS])} +# Or render your own: a command whose first stdout line replaces footer line 1. +# It receives a JSON snapshot (model, cwd, git, usage, mode) on stdin. +# command = "~/.pythinker-code/statusline.sh" +`; return `# ~/.pythinker-code/tui.toml # Client preferences for pythinker-code. # Agent/runtime settings stay in ~/.pythinker-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name -layout = "${config.layout}" # "fixed" | "inline" -copy_full_response = ${String(config.copyFullResponse)} # true skips the /copy code-block picker +render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source +disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback +cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR @@ -231,17 +259,7 @@ notification_condition = "${config.notifications.condition}" # "unfocused" | "al [upgrade] auto_install = ${String(config.upgrade.autoInstall)} # true | false -[status_line] -show_model = ${String(config.statusLine.showModel)} # Model name -show_effort = ${String(config.statusLine.showEffort)} # Thinking effort; requires show_model -show_token_speed = ${String(config.statusLine.showTokenSpeed)} # Live t/s; requires show_model -show_context_bar = ${String(config.statusLine.showContextBar)} # Context gauge and token totals -show_git = ${String(config.statusLine.showGit)} # Git branch, changes, and pull request -show_modes = ${String(config.statusLine.showModes)} # Workflow, permission, and plan modes -show_elapsed = ${String(config.statusLine.showElapsed)} # Active request elapsed time -show_goal = ${String(config.statusLine.showGoal)} # Goal badge -show_background_tasks = ${String(config.statusLine.showBackgroundTasks)} # Shell and agent task badges -`; +${statusSection}`; } function escapeTomlBasicString(value: string): string { diff --git a/apps/pythinker-code/src/tui/constant/clipboard-image-hint.ts b/apps/pythinker-code/src/tui/constant/clipboard-image-hint.ts new file mode 100644 index 00000000..eccb3f3f --- /dev/null +++ b/apps/pythinker-code/src/tui/constant/clipboard-image-hint.ts @@ -0,0 +1,3 @@ +// Timing constants for the clipboard-image hint controller. +export const FOCUS_DEBOUNCE_MS = 1_000; +export const HINT_DISPLAY_MS = 4_000; diff --git a/apps/pythinker-code/src/tui/constant/feedback.ts b/apps/pythinker-code/src/tui/constant/feedback.ts index 9e8d621f..54a2bc24 100644 --- a/apps/pythinker-code/src/tui/constant/feedback.ts +++ b/apps/pythinker-code/src/tui/constant/feedback.ts @@ -13,15 +13,19 @@ export { FEEDBACK_ISSUE_URL, FEEDBACK_TELEMETRY_EVENT, FEEDBACK_VERSION_PREFIX, + PYTHINKER_CODE_SIGNUP_URL, } from '#/constant/app'; export const FEEDBACK_STATUS_SUBMITTING = 'Submitting feedback…'; +export const FEEDBACK_STATUS_UPLOADING = 'Uploading attachments, this could take a few minutes…'; export const FEEDBACK_STATUS_SUCCESS = 'Feedback submitted, thank you!'; export const FEEDBACK_STATUS_CANCELLED = 'Feedback cancelled.'; export const FEEDBACK_STATUS_NETWORK_ERROR = 'Network error, failed to submit feedback.'; export const FEEDBACK_STATUS_FALLBACK = 'Opening GitHub Issues as fallback…'; export const FEEDBACK_STATUS_NOT_SIGNED_IN = - "You're not signed in. Opening GitHub Issues for feedback…"; + "You're not signed in. Sign up or leave feedback on GitHub:"; +export const FEEDBACK_STATUS_UPLOAD_FAILED = + 'Feedback sent; attachment upload failed — see feedback-upload.log.'; export function feedbackHttpErrorMessage(status: number): string { return `Failed to submit feedback (HTTP ${String(status)}).`; @@ -31,6 +35,10 @@ export function feedbackSessionLine(sessionId: string): string { return `Session: ${sessionId}`; } +export function feedbackIdLine(feedbackId: number): string { + return `Feedback ID: ${String(feedbackId)}`; +} + // Hint shown beneath session-level error messages in the TUI to point users // at the `/export-debug-zip` workflow so they can share diagnostics with us. export function errorReportHintLine(): string { diff --git a/apps/pythinker-code/src/tui/constant/media.ts b/apps/pythinker-code/src/tui/constant/media.ts new file mode 100644 index 00000000..d125258a --- /dev/null +++ b/apps/pythinker-code/src/tui/constant/media.ts @@ -0,0 +1,6 @@ +/** TUI-only daemon staging lifetimes for pasted media. */ + +export const IMAGE_STAGING_TTL_SECONDS = 60 * 60; +export const IMAGE_FILE_REF_MIN_REMAINING_MS = 60_000; +/** How long submit waits for a just-pasted image's background ingestion before falling back to the inline form. */ +export const IMAGE_INGESTION_SUBMIT_WAIT_MS = 2_000; diff --git a/apps/pythinker-code/src/tui/constant/mouse.ts b/apps/pythinker-code/src/tui/constant/mouse.ts deleted file mode 100644 index 2dd88781..00000000 --- a/apps/pythinker-code/src/tui/constant/mouse.ts +++ /dev/null @@ -1,18 +0,0 @@ -// SGR mouse reporting modes: button press/release (1000), drag motion while -// a button is held (1002), and SGR extended coordinates (1006). Wheel events -// arrive as button ids 64 (up) / 65 (down) under these modes. -export const MOUSE_REPORTING_ENABLE = '\u001B[?1000h\u001B[?1002h\u001B[?1006h'; -export const MOUSE_REPORTING_DISABLE = '\u001B[?1006l\u001B[?1002l\u001B[?1000l'; - -// SGR mouse frame: ESC [ < button ; col ; row (M = press/motion, m = release). -// Columns and rows are 1-based screen coordinates. -export const MOUSE_SGR_PATTERN = /^\u001B\[<(\d+);(\d+);(\d+)([Mm])$/u; - -// Transcript lines scrolled per wheel notch. -export const MOUSE_SCROLL_LINES = 3; - -// Repeat cadence while a drag selection rests on a transcript edge. -export const MOUSE_DRAG_SCROLL_INTERVAL_MS = 80; - -export const OSC52_CLIPBOARD_PREFIX = '\u001B]52;c;'; -export const OSC52_CLIPBOARD_SUFFIX = '\u0007'; diff --git a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts index ae1be886..64232353 100644 --- a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts @@ -1,7 +1,6 @@ -export { OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; +import { DEFAULT_OAUTH_PROVIDER_NAME } from '#/constant/app'; -/** Canonical model roles offered by `/model <role>`, mirroring agent-core's list across the SDK package boundary. */ -export const BUILT_IN_MODEL_ROLES = ['small', 'implementer', 'advisor'] as const; +export { DEFAULT_OAUTH_PROVIDER_NAME, OAUTH_LOGIN_REQUIRED_CODE, PRODUCT_NAME } from '#/constant/app'; export const LLM_NOT_SET_MESSAGE = 'LLM not set, send "/login" to login'; export const NO_ACTIVE_SESSION_MESSAGE = 'No active session. Send /login to login.'; @@ -9,5 +8,19 @@ export const CTRL_D_HINT = 'Press Ctrl+D again to exit'; export const CTRL_C_HINT = 'Press Ctrl+C again to exit'; export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; +export const SESSIONLESS_STARTUP_NOTICE = + 'No session yet — one will be created on your first message.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; -export const MCP_STATUS_TRANSIENT_DURATION_MS = 750; +// Time window for treating two consecutive Esc presses as a double-Esc, which +// opens the undo selector. Kept short (double-click feel) so two deliberate +// presses far apart don't accidentally trigger undo. +export const DOUBLE_ESC_WINDOW_MS = 600; + +/** Session picker page size: one backend keyset page and one picker window. */ +export const SESSION_LIST_PAGE_SIZE = 50; + +export function isManagedUsageProvider( + providerKey: string | undefined, +): providerKey is typeof DEFAULT_OAUTH_PROVIDER_NAME { + return providerKey === DEFAULT_OAUTH_PROVIDER_NAME; +} diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index fc447bf2..e0dfd53a 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -1,6 +1,15 @@ // Continuation indent for transcript rows that use a two-cell leading marker. export const MESSAGE_INDENT = ' '; +// OSC 133 semantic-zone markers (FinalTerm/shell-integration protocol): +// zero-width escape sequences prefixed onto the first/last rendered line of +// transcript messages. The fullscreen renderer strips them at paint and uses +// the A marker for previous/next-prompt navigation (Ctrl-Shift-Up/Down); in +// regular mode they pass through to native scrollback invisibly. +export const OSC133_ZONE_START = '\x1b]133;A\x07'; +export const OSC133_ZONE_END = '\x1b]133;B\x07'; +export const OSC133_ZONE_FINAL = '\x1b]133;C\x07'; + // Outer left/right padding applied to the transcript, panels, and the // statusline so the chrome's left edge lines up with the input box's // interior (the `>` prompt). The editor itself stays at column 0 — its @@ -12,155 +21,30 @@ export const RESULT_PREVIEW_LINES = 3; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; -// Animation frames are shared by the login/update loaders and live thinking. -export const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; +// Cap on the step-retry detail line under the waiting spinner, so huge +// provider error bodies (occasionally whole HTML error pages) can't flood +// the activity pane. +export const RETRY_DETAIL_MAX_CHARS = 160; +// Left indent (cells) for the detail line under the waiting spinner, aligning +// it with the label text: 1 (the spinner Text's own paddingX) + 2 (Braille +// frame) + 1 (space between frame and label). +export const ACTIVITY_DETAIL_INDENT = 4; + +// Retention caps for the subagent activity store (background-agent detail +// view): only the most recent steps are kept, older steps are discarded +// whole, and per-step text / per-call output keep bounded tails. +export const MAX_SUBAGENT_ACTIVITY_STEPS = 20; +export const SUBAGENT_STEP_TEXT_TAIL_CHARS = 4000; +export const SUBAGENT_TOOL_OUTPUT_MAX_CHARS = 8000; +// Cap on individual string argument values kept in a record (Write/Edit +// carry whole-file contents). Only header summaries and the Edit/Write line +// chips read args, so long values are truncated; chips become approximate +// beyond the cap. +export const SUBAGENT_ARG_STRING_MAX_CHARS = 16 * 1024; + +// The dense Braille cycle is shared by login/update loaders and live thinking. +export const BRAILLE_SPINNER_FRAMES = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'] as const; export const BRAILLE_SPINNER_INTERVAL_MS = 80; -// Pulse cadence for the calm running-bullet toggle on in-flight Bash tools. -export const BASH_STATUS_PULSE_INTERVAL_MS = 800; - -/** - * Layout values and observed execution stages for Dynamic Workflow. - * The protocol emits no task-percent event, so these never predict time remaining. - */ -export const DYNAMIC_WORKFLOW_RENDERING = { - frameMinWidth: 21, - frameHorizontalInset: 4, - memberProgressMinWidth: 60, - memberProgressWidth: 8, - /** Least width of the lifecycle STATE column in member rows. */ - stateColumnWidth: 6, - /** Braille frames for a running row; all rows share one clock. */ - progressFrames: BRAILLE_SPINNER_FRAMES, - /** Rotation cadence in milliseconds — deliberately slow; this is ambience, not progress. */ - progressFrameMs: 300, - /** Least room the task keeps before the detail may claim any of the row. */ - memberTaskMinWidth: 12, - /** Share of the free row the task may take before the detail gets the rest. */ - memberTaskShare: 0.6, - /** Below this the detail is dropped: a few clipped characters say nothing. */ - memberDetailMinWidth: 8, - /** - * Least shared task prefix worth eliding. A short prefix costs about as much - * to mark as it frees, so only a preamble long enough to have been clipping - * the part that names the row is dropped. - */ - memberTaskSharedPrefixMinWidth: 16, - /** - * Upper bound on one buffered output line. A model may stream a single line - * with no newline in it at all, so this is the only thing that stops the - * buffered text from growing for as long as the agent runs. - */ - memberLatestMaxChars: 512, -} as const; - -/** Live activity labels: one shown at a time, rotating on a fixed cadence. */ -export const THINKING_SPINNER_LABELS = [ - // Clear and informative - 'thinking', - 'reasoning', - 'exploring', - 'planning', - 'connecting', - 'refining', - 'verifying', - 'untangling', - 'pattern-finding', - 'clue-chasing', - - // Pythinker signature - 'pythinking', - - // Technical and constructive - 'architecting', - 'bootstrapping', - 'calculating', - 'coalescing', - 'composing', - 'computing', - 'crafting', - 'crystallizing', - 'deciphering', - 'elucidating', - 'forging', - 'harmonizing', - 'hashing', - 'incubating', - 'inferring', - 'orchestrating', - 'processing', - 'synthesizing', - 'unravelling', - - // Polished playful - 'brewing', - 'cerebrating', - 'cogitating', - 'concocting', - 'cultivating', - 'hatching', - 'marinating', - 'noodling', - 'percolating', - 'pondering', - 'puzzling', - 'recombobulating', - 'reticulating', - 'tinkering', - - // Developer-chaotic - 'token-taming', - 'bug-whispering', - 'rubber-ducking', - 'stack-divining', - 'logic-weaving', - 'thread-pulling', - 'syntax-sleuthing', - 'gizmo-tinkering', - - // Rare whimsical moments - 'booping', - 'moonwalking', - 'quantumizing', - 'razzle-dazzling', - 'vibing', - 'whirring', - 'zigzagging', -] as const; - -export const THINKING_SPINNER_LABEL_INTERVAL_MS = 12_000; - -const LIVE_INTENT_MAX_LENGTH = 120; -// Keep in sync with packages/agent-core/src/loop/tool-intent.ts. -// oxlint-disable-next-line no-control-regex -- wire text must not retain terminal escape sequences. -const ANSI_ESCAPE = /\u001B(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\|$))/gu; -const CONTROL_CHARACTER = /\p{Cc}/gu; -let liveIntent: string | undefined; - -export function setLiveIntent(text: string | undefined): void { - if (text === undefined) { - liveIntent = undefined; - return; - } - const normalized = text - .replaceAll(ANSI_ESCAPE, '') - .replaceAll(CONTROL_CHARACTER, ' ') - .replaceAll(/\s+/gu, ' ') - .trim(); - liveIntent = - Array.from(normalized).slice(0, LIVE_INTENT_MAX_LENGTH).join('').trimEnd() || undefined; -} - -/** Rotating thinking label for the given wall-clock moment; falls back to the first label. */ -export function getThinkingSpinnerLabel(nowMs: number = Date.now()): string { - const index = - Math.floor(nowMs / THINKING_SPINNER_LABEL_INTERVAL_MS) % - THINKING_SPINNER_LABELS.length; - - return THINKING_SPINNER_LABELS[index] ?? THINKING_SPINNER_LABELS[0]; -} - -/** Thinking label plus an ellipsis, for the thinking block header. */ -export function formatThinkingSpinnerLabel(nowMs: number = Date.now()): string { - return `${liveIntent ?? getThinkingSpinnerLabel(nowMs)}…`; -} +export const MOON_SPINNER_FRAMES = BRAILLE_SPINNER_FRAMES; +export const MOON_SPINNER_INTERVAL_MS = BRAILLE_SPINNER_INTERVAL_MS; diff --git a/apps/pythinker-code/src/tui/constant/streaming.ts b/apps/pythinker-code/src/tui/constant/streaming.ts index 110f8584..b8f88a01 100644 --- a/apps/pythinker-code/src/tui/constant/streaming.ts +++ b/apps/pythinker-code/src/tui/constant/streaming.ts @@ -1,7 +1,7 @@ // Extracts useful string fields from partially streamed JSON tool args. // This is intentionally a preview parser, not a full JSON parser. export const STREAMING_ARGS_FIELD_RE = - /"(i|path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; + /"(path|file_path|command|pattern|query|url|description|title|name)"\s*:\s*"((?:\\.|[^"\\])*)"/g; // Bounds live tool-argument previews; final tool.call payloads remain complete. export const STREAMING_ARGS_PREVIEW_MAX_CHARS = 64 * 1024; diff --git a/apps/pythinker-code/src/tui/constant/symbols.ts b/apps/pythinker-code/src/tui/constant/symbols.ts index 3b0c76ab..bba41d60 100644 --- a/apps/pythinker-code/src/tui/constant/symbols.ts +++ b/apps/pythinker-code/src/tui/constant/symbols.ts @@ -3,8 +3,7 @@ export const STATUS_BULLET = '● '; // Shared transcript markers. Keep widths stable because message wrapping // assumes the marker occupies the leading cells. -// U+25B8 — distinct from STATUS_BULLET (●) and avoids emoji fallback rendering. -export const USER_MESSAGE_BULLET = '▸ '; +export const USER_MESSAGE_BULLET = '✨ '; export const SUCCESS_MARK = '✓ '; export const FAILURE_MARK = '✗ '; diff --git a/apps/pythinker-code/src/tui/constant/terminal.ts b/apps/pythinker-code/src/tui/constant/terminal.ts index d4ae0541..ec87a078 100644 --- a/apps/pythinker-code/src/tui/constant/terminal.ts +++ b/apps/pythinker-code/src/tui/constant/terminal.ts @@ -1,4 +1,4 @@ -import { BEL, ESC } from "#/constant/terminal"; +import { BEL, ESC, ST } from "#/constant/terminal"; export { BEL, ESC, ST } from "#/constant/terminal"; diff --git a/apps/pythinker-code/src/tui/constant/tips.ts b/apps/pythinker-code/src/tui/constant/tips.ts new file mode 100644 index 00000000..bc438431 --- /dev/null +++ b/apps/pythinker-code/src/tui/constant/tips.ts @@ -0,0 +1,49 @@ +export interface ToolbarTip { + readonly text: string; + /** + * Long/important tips render on their own. They never pair with a + * neighbour and never appear as the second half of someone else's pair. + */ + readonly solo?: boolean; + /** + * Rotation weight: a higher value makes the tip recur more often. Defaults + * to 1. Used to give newer/important features more airtime. + */ + readonly priority?: number; +} + +/** + * Subset of toolbar tips shown behind the composing spinner. + */ +export const WORKING_TIPS: readonly ToolbarTip[] = [ + { text: 'ctrl-s to add guidance without waiting for the turn to finish', priority: 2, solo: true }, + { text: '/tasks to check progress and status for background tasks', priority: 2 }, + { text: '/init: generate AGENTS.md', priority: 2 }, + { text: 'Try /dance for a hidden Easter egg' }, + { + text: '/plugins: manage plugins — try the "Pythinker Datasource" for reliable financial, economic, and academic data', + solo: true, + priority: 3, + }, + { text: 'ask Pythinker to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, + { text: '/sessions to browse and resume earlier sessions', solo: true }, + { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, + { text: '/goal next to queue follow-up work while the current goal keeps running', solo: true }, + { text: '/web: use the Web UI for a better experience', solo: true }, + { text: '@: mention files', priority: 2 }, + { text: '! to run a shell command', priority: 2 }, +]; + +export const ALL_TIPS: readonly ToolbarTip[] = [ + ...WORKING_TIPS, + { text: 'shift+enter: newline' }, + { text: 'ctrl+c: cancel' }, + { text: '/theme to switch the terminal UI theme' }, + { text: '/auto when you want Pythinker to handle approvals and keep going unattended' }, + { text: '/yolo to skip most approvals for trusted batch work, only use it in repos you trust' }, + { text: '/help: show commands' }, + { text: '/compact compresses context when it gets long', priority: 2 }, + { text: 'ctrl-o to hide or reveal tool output switching between a clean chat view and full execution details', priority: 2 }, + { text: 'shift-tab to Plan mode to review the approach before Pythinker edits files.', priority: 2 }, + { text: '/model: switch model', priority: 2 }, +]; diff --git a/apps/pythinker-code/src/tui/constant/vim.ts b/apps/pythinker-code/src/tui/constant/vim.ts deleted file mode 100644 index 9150e206..00000000 --- a/apps/pythinker-code/src/tui/constant/vim.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Safety cap for counted `o`/`O` open-line commands: each opened line is -// allocated in the Vim buffer, so unbounded counts could exhaust memory. -export const VIM_OPEN_LINE_COUNT_CAP = 10_000; diff --git a/apps/pythinker-code/src/tui/controllers/auth-flow.ts b/apps/pythinker-code/src/tui/controllers/auth-flow.ts index 6467fd47..4d6285b6 100644 --- a/apps/pythinker-code/src/tui/controllers/auth-flow.ts +++ b/apps/pythinker-code/src/tui/controllers/auth-flow.ts @@ -1,25 +1,39 @@ import { - coerceEffortForModel, + removeProviderFromConfig, + type CreateSessionOptions, + type PythinkerConfig, type PythinkerHarness, + type OAuthRef, type Session, + type ThinkingEffort, } from '@pymodel/pythinker-code-sdk'; + +import { createPythinkerCodeUserAgent } from '#/cli/version'; + import type { SkillListSession } from '../commands'; import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/pythinker-tui'; import { refreshAllProviderModels, + type RefreshProviderHost, type RefreshProviderScope, type RefreshResult, } from '../utils/refresh-providers'; +import { thinkingEffortFromConfig } from '../utils/thinking-config'; import type { SessionEventHandler } from './session-event-handler'; import type { AppState, PythinkerTUIOptions } from '../types'; import type { TUIState } from '../tui-state'; +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + export interface AuthFlowHost { state: TUIState; session: Session | undefined; readonly harness: PythinkerHarness; readonly options: PythinkerTUIOptions; + readonly engineV2: boolean; setAppState(patch: Partial<AppState>): void; setStartupReady(): void; @@ -28,10 +42,12 @@ export interface AuthFlowHost { syncRuntimeState(session?: Session): Promise<void>; closeSession(reason: string): Promise<void>; appendStartupNotice(extra: string): void; + hydrateLazyConfigDefaults(): Promise<void>; readonly sessionEventHandler: SessionEventHandler; fetchSessions(): Promise<void>; updateTerminalTitle(): void; refreshSkillCommands(session?: SkillListSession): Promise<void>; + refreshPluginCommands(session?: Session): Promise<void>; } export class AuthFlowController { @@ -50,7 +66,7 @@ export class AuthFlowController { this.host.setAppState({ sessionId: '', model: '', - thinkingLevel: 'off', + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -70,7 +86,21 @@ export class AuthFlowController { return; } - const session = await host.harness.createSession({ + if (host.engineV2) { + // Lazy session creation (v2 engine): configure the model only; the + // session is created on the first message. The effort is carried as the + // first session's thinking override so a session-only choice (Alt+S) + // made before any session exists is applied on creation. + const patch: Partial<AppState> = { model }; + if (effort !== undefined) { + patch.thinkingEffort = effort as ThinkingEffort; + patch.lazySessionThinking = effort as ThinkingEffort; + } + host.setAppState(patch); + return; + } + + const options: MutableCreateSessionOptions = { workDir: host.state.appState.workDir, model, thinking: effort, @@ -79,13 +109,18 @@ export class AuthFlowController { : host.options.startup.yolo ? 'yolo' : undefined, - setupTrigger: host.options.startup.init - ? 'init' - : host.options.startup.maintenance - ? 'maintenance' - : undefined, planMode: host.state.appState.planMode ? true : undefined, - }); + // The post-login session is still the startup session: carry the + // --agent/--agent-file binding resolved at launch. + agentProfile: host.options.startup.agentProfile, + agentFiles: host.options.startup.agentFiles?.length + ? [...host.options.startup.agentFiles] + : undefined, + }; + if (host.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...host.state.appState.additionalDirs]; + } + const session = await host.harness.createSession(options); await host.setSession(session); host.setAppState({ sessionId: session.id, @@ -96,6 +131,7 @@ export class AuthFlowController { void host.fetchSessions(); host.updateTerminalTitle(); void host.refreshSkillCommands(host.session); + void host.refreshPluginCommands(host.session); } async clearActiveSessionAfterLogout(): Promise<void> { @@ -107,6 +143,7 @@ export class AuthFlowController { sessionTitle: null, }); await this.host.refreshSkillCommands(); + await this.host.refreshPluginCommands(); } async refreshConfigAfterLogin(): Promise<void> { @@ -118,25 +155,29 @@ export class AuthFlowController { const selected = defaultModel !== undefined ? availableModels[defaultModel] : undefined; if (defaultModel === undefined || selected === undefined) { + if (host.session === undefined && host.engineV2) { + // Session-less v2: hydrate permission/plan defaults even without a + // default model. + await host.hydrateLazyConfigDefaults(); + } host.setAppState({ availableModels, availableProviders }); return; } - // The configured effort wins; the legacy boolean falls back to high/off. - const requested = - config.thinking?.effort ?? - (config.defaultThinking === true ? 'high' : config.defaultThinking === false ? 'off' : undefined); - const effort = requested === undefined ? undefined : coerceEffortForModel(selected, requested); - await this.activateModelAfterLogin(defaultModel, effort); + await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); + if (host.session === undefined && host.engineV2) { + // Session-less v2: also hydrate permission/plan defaults from the + // refreshed config, same as startup. + await host.hydrateLazyConfigDefaults(); + host.setAppState({ availableModels, availableProviders }); + return; + } const appStatePatch: Partial<AppState> = { availableModels, availableProviders, model: defaultModel, maxContextTokens: selected.maxContextSize, }; - if (effort !== undefined) { - appStatePatch.thinkingLevel = effort; - } host.setAppState(appStatePatch); } @@ -146,7 +187,7 @@ export class AuthFlowController { availableModels: config.models ?? {}, availableProviders: config.providers ?? {}, model: '', - thinkingLevel: 'off', + thinkingEffort: 'off', maxContextTokens: 0, contextUsage: 0, contextTokens: 0, @@ -168,19 +209,68 @@ export class AuthFlowController { } private async refreshProviderModelsWithScope(scope: RefreshProviderScope): Promise<RefreshResult> { + const result = await refreshAllProviderModels(this.buildRefreshHost(), { scope }); + if (result.changed.length > 0) { + await this.refreshAvailableModels(); + } + return result; + } + + /** + * Build the refresh orchestrator's persistence host. When the harness can + * persist several config sections as ONE atomic write (the v2 engine's + * `replaceSections`), the orchestrator's two-phase contract (removeProvider + * then setConfig) is absorbed the same way the v2 engine's own refresh path + * does it: the removal is staged in memory only, and the following + * setConfig persists the complete records in a single write — so a process + * exit mid-refresh can never leave config.toml in a "provider removed, not + * yet restored" state. The v1 harness keeps the legacy host (two + * whole-document writes, each atomic on its own). + */ + private buildRefreshHost(): RefreshProviderHost { const { host } = this; - const result = await refreshAllProviderModels( - { + const resolveOAuthToken = async (providerName: string, oauthRef?: OAuthRef): Promise<string> => { + const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); + return tokenProvider.getAccessToken(); + }; + const userAgent = createPythinkerCodeUserAgent(); + if (!host.harness.supportsAtomicSectionReplace()) { + return { getConfig: () => host.harness.getConfig({ reload: true }), removeProvider: (id) => host.harness.removeProvider(id), setConfig: (patch) => host.harness.setConfig(patch), - replaceConfig: (config) => host.harness.replaceConfig(config), - }, - { scope }, - ); - if (result.changed.length > 0) { - await this.refreshAvailableModels(); + resolveOAuthToken, + userAgent, + }; } - return result; + let staged: PythinkerConfig | undefined; + const requireStaged = (): PythinkerConfig => { + if (staged === undefined) { + throw new Error('refresh host: getConfig must be called before writes'); + } + return staged; + }; + return { + getConfig: async () => { + staged = await host.harness.getConfig({ reload: true }); + return staged; + }, + removeProvider: (id) => { + staged = removeProviderFromConfig(requireStaged(), id); + return Promise.resolve(staged); + }, + setConfig: async (patch) => { + // The orchestrator always passes complete records (built from a full + // clone), so the Partial-shaped patch is a full PythinkerConfig overlay. + staged = { ...requireStaged(), ...patch } as PythinkerConfig; + // Object.entries keeps keys whose value is `undefined`, so a cleared + // section (e.g. a dangling defaultModel) is expressed as a removal in + // the atomic write; sections absent from the patch stay untouched. + await host.harness.replaceConfigSections(Object.fromEntries(Object.entries(patch))); + return staged; + }, + resolveOAuthToken, + userAgent, + }; } } diff --git a/apps/pythinker-code/src/tui/controllers/btw-panel.ts b/apps/pythinker-code/src/tui/controllers/btw-panel.ts index 1667b337..45f31e41 100644 --- a/apps/pythinker-code/src/tui/controllers/btw-panel.ts +++ b/apps/pythinker-code/src/tui/controllers/btw-panel.ts @@ -1,4 +1,4 @@ -import { Spacer } from '@earendil-works/pi-tui'; +import { Spacer } from '@pymodel/pi-tui'; import type { Event, PythinkerHarness, @@ -10,7 +10,8 @@ import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/pythinker-tui'; import { BtwPanelComponent } from '../components/panes/btw-panel'; import { formatErrorMessage } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; -import { createPythinkerMarkdownTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '../theme/pi-tui-theme'; +import type { InlineSkillActivation } from '../types'; import type { TUIState } from '../tui-state'; const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; @@ -34,20 +35,24 @@ export class BtwPanelController { constructor(private readonly host: BtwPanelHost) {} - open(agentId: string, initialPrompt: string): void { + open( + agentId: string, + initialPrompt: string, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): void { let panel: BtwPanelComponent; panel = new BtwPanelComponent({ - markdownTheme: createPythinkerMarkdownTheme(), + markdownTheme: createMarkdownTheme(), canUseScrollKeys: () => this.host.state.editor.getText().length === 0, terminalRows: () => this.host.state.terminal.rows, - onPrompt: (prompt) => { - this.promptAgent(agentId, prompt, panel); + onPrompt: (prompt, inlineSkillActivations) => { + this.promptAgent(agentId, prompt, panel, inlineSkillActivations); }, }); this.active = { agentId, panel }; this.panelsByAgentId.set(agentId, panel); this.mount(panel); - panel.submit(initialPrompt); + panel.submit(initialPrompt, inlineSkillActivations); } clear(): void { @@ -79,14 +84,14 @@ export class BtwPanelController { return true; } - sendUserInput(text: string): boolean { + sendUserInput(text: string, inlineSkillActivations?: readonly InlineSkillActivation[]): boolean { const active = this.active; if (active === undefined) return false; if (active.panel.isRunning()) { this.showBusyNotice(active, text); return true; } - active.panel.submit(text); + active.panel.submit(text, inlineSkillActivations); this.host.state.ui.setFocus(this.host.state.editor); this.host.state.ui.requestRender(); return true; @@ -165,14 +170,30 @@ export class BtwPanelController { this.host.state.ui.requestRender(); } - private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { + private promptAgent( + agentId: string, + prompt: string, + panel: BtwPanelComponent, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): void { const session = this.host.session; if (session === undefined) { panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); this.host.state.ui.requestRender(); return; } - void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { + const send = + inlineSkillActivations !== undefined && inlineSkillActivations.length > 0 + ? () => + session.promptWithSkills( + prompt, + inlineSkillActivations.map((activation) => ({ + name: activation.skillName, + args: activation.args, + })), + ) + : () => session.prompt(prompt); + void this.withInteractiveAgent(agentId, send).catch((error: unknown) => { panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); this.host.state.ui.requestRender(); }); @@ -196,11 +217,17 @@ export class BtwPanelController { } function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.reason === 'cancelled') { + return 'Interrupted by user'; + } + if (event.error?.code === 'provider.filtered') { + return 'Provider safety policy blocked the response.'; + } if (event.error !== undefined) { return `[${event.error.code}] ${event.error.message}`; } - if (event.reason === 'cancelled') { - return 'Interrupted by user'; + if (event.reason === 'blocked') { + return 'Prompt hook blocked the request.'; } return `BTW turn ended with reason: ${event.reason}`; } diff --git a/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts b/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts new file mode 100644 index 00000000..4bbe6139 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/cache-hint-controller.ts @@ -0,0 +1,528 @@ +/** + * CacheHintController — drives the "cache expired" dialog for the two trigger + * scenarios: resuming a long-idle session (fires right after the resume + * finishes loading) and submitting after an in-process idle stretch + * (intercepts the submit). Owns the frequency guards and the in-process + * activity baseline; the pure trigger rule lives in `../utils/cache-hint`. + */ + +import type { Component, Focusable } from '@pymodel/pi-tui'; +import type { PythinkerHarness, Session, TokenUsage } from '@pymodel/pythinker-code-sdk'; + +import { getCacheHintConfig, peekCacheHintConfig } from '#/utils/cache-hint-config'; +import { currentTuiConfig } from '../commands/config'; +import { + CacheHintDialogComponent, + type CacheHintAction, +} from '../components/dialogs/cache-hint-dialog'; +import { saveTuiConfig } from '../config'; +import { MAIN_AGENT_ID } from '../constant/pythinker-tui'; +import type { AppState, InlineSkillActivation } from '../types'; +import type { TUIState } from '../tui-state'; +import { evaluateCacheHint } from '../utils/cache-hint'; +import { formatErrorMessage } from '../utils/event-payload'; +import { + makeExtractionResendable, + originalsDirForSession, + type ExtractionResult, +} from '../utils/image-placeholder'; + +/** A swallowed submit: the raw text plus its media extraction (done before + * the dialog so pasted attachments survive a later store clear). */ +interface StashedSubmit { + readonly text: string; + readonly extraction?: ExtractionResult; + /** Session that owned any daemon refs inside {@link extraction}. */ + readonly sessionId: string; + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; +} + +export interface CacheHintHost { + readonly engineV2: boolean; + readonly harness: PythinkerHarness; + readonly session: Session | undefined; + readonly state: TUIState; + track(event: string, props?: Record<string, unknown>): void; + setAppState(patch: Partial<AppState>): void; + mountEditorReplacement(panel: Component & Focusable): void; + restoreEditor(): void; + restoreInputText(text: string): void; + /** + * A stashed submission going back to the editor releases its extraction's + * staged media with queue-recall semantics (consume retains, retire staged + * copies, rebase videos) — without this the retains/copies would leak. + */ + recallStashedMedia(text: string, extraction: ExtractionResult | undefined): void; + showError(message: string): void; + createNewSession(): Promise<void>; + sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void>; + sendInlineSkillUserInput( + text: string, + activations: readonly InlineSkillActivation[], + preExtracted?: ExtractionResult, + ): Promise<void>; +} + +type HintDecision = { readonly idleSeconds: number; readonly totalTokens: number }; + +/** Cache-break detection: a step's cache read dropping under 95% of the + * previous step's by more than this many tokens counts as a break. */ +const CACHE_BREAK_MIN_DROP_TOKENS = 2000; +const CACHE_BREAK_DROP_RATIO = 0.95; + +interface CacheBreakBaseline { + readonly model: string; + readonly effort: string; + readonly usage: TokenUsage; + readonly time: number; +} + +export class CacheHintController { + /** Latest in-process LLM round-trip time (turn begin / turn end). */ + private lastActivityAt: number | undefined; + /** One prompt per idle cycle; reset when a real send starts a turn. */ + private idlePrompted = false; + /** Cold-cache trigger fetches at most once per idle cycle (loop guard for + * the release-and-resend path). */ + private triggerFetchAttempted = false; + /** Swallowed submits waiting on the cold-cache interception chain. */ + private pendingInterceptions = 0; + /** FIFO chain serializing swallowed submits so they keep submit order. */ + private interceptionTail: Promise<void> = Promise.resolve(); + /** Set while a stashed message is being released back into the send path. */ + private releasingStashed = false; + /** Whether the idle dialog's triggering message was restored, not sent. */ + private lastDialogRestored = false; + /** Inputs restored this cycle — chained restores append (newline-joined) + * instead of overwriting the editor. */ + private restoredTexts: string[] = []; + /** Resume scenario fires at most once per session per TUI instance. */ + private readonly resumedSessions = new Set<string>(); + /** Last measured main-loop step usage for cache-break detection. */ + private breakBaseline: CacheBreakBaseline | undefined; + + constructor(private readonly host: CacheHintHost) {} + + /** + * Cache-break detection (client-side, main loop): feed each completed + * step's usage. A step whose cache read drops sharply below the previous + * one is reported as `cache_break_detected` with both usages, both + * model/effort values, the drop ratio, and the interval — a mid-session + * model/effort switch busts the cache key, and that cause is exactly what + * the report should carry. Unmeasured (missing/all-zero) usage is skipped + * without touching the baseline; compaction resets it (the drop there is + * expected). + * + * Also doubles as the cache-activity signal: a completed step is a real + * provider round trip, so the server-side cache was just refreshed — + * unlike a bare turn begin, whose prompt may still fail before any model + * request. + */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.recordActivity(); + if (usage === undefined) return; + if ( + usage.inputOther === 0 && + usage.output === 0 && + usage.inputCacheRead === 0 && + usage.inputCacheCreation === 0 + ) { + return; + } + const model = this.host.state.appState.model; + const effort = this.host.state.appState.thinkingEffort; + const now = Date.now(); + const prev = this.breakBaseline; + this.breakBaseline = { model, effort, usage, time: now }; + if (prev === undefined) return; + const prevRead = prev.usage.inputCacheRead; + const currRead = usage.inputCacheRead; + if (currRead >= prevRead * CACHE_BREAK_DROP_RATIO) return; + if (prevRead - currRead <= CACHE_BREAK_MIN_DROP_TOKENS) return; + this.host.track('cache_break_detected', { + prev_model: prev.model, + curr_model: model, + prev_effort: prev.effort, + curr_effort: effort, + prev_input_cache_read: prevRead, + curr_input_cache_read: currRead, + prev_input_other: prev.usage.inputOther, + curr_input_other: usage.inputOther, + prev_output: prev.usage.output, + curr_output: usage.output, + prev_input_cache_creation: prev.usage.inputCacheCreation, + curr_input_cache_creation: usage.inputCacheCreation, + cache_read_drop_ratio: (prevRead - currRead) / prevRead, + interval_ms: now - prev.time, + }); + } + + /** Compaction legitimately shrinks the cached prefix — reset the baseline. + * Also used when the context is cut by other means (e.g. /undo). */ + resetCacheBreakBaseline(): void { + this.breakBaseline = undefined; + } + + recordActivity(): void { + this.lastActivityAt = Date.now(); + } + + /** + * A real send starts a turn — open a fresh idle cycle. Cache activity is + * deliberately NOT recorded here: the prompt may still fail before any + * model request (rejected call, hook-blocked turn), and only a completed + * provider round trip refreshes the server-side cache. + */ + onTurnBegin(): void { + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + } + + /** Session switch / create: the new session has no in-process baseline. */ + resetRuntime(): void { + this.lastActivityAt = undefined; + this.idlePrompted = false; + this.triggerFetchAttempted = false; + this.lastDialogRestored = false; + this.restoredTexts = []; + this.breakBaseline = undefined; + } + + /** Background warm-up on session creation; never blocks, never throws. */ + refreshConfigInBackground(): void { + void this.resolveConfig(); + } + + /** Scenario 1: call right after a resume finishes loading. */ + async maybeShowOnResume(): Promise<void> { + const { host } = this; + const session = host.session; + if (!host.engineV2 || session === undefined) return; + if (this.resumedSessions.has(session.id)) return; + const main = session.getResumeState()?.agents[MAIN_AGENT_ID]; + let lastActiveAt = 0; + for (const record of main?.replay ?? []) { + // Only message/compaction records correspond to LLM round-trips; state + // records (permission/plan/config updates, approval results) can be + // appended by slash commands without touching the cache. + if (record.type !== 'message' && record.type !== 'compaction') continue; + if (record.time > lastActiveAt) lastActiveAt = record.time; + } + // `summary.updatedAt` ≈ last user prompt — a coarser but valid fallback. + if (lastActiveAt === 0) lastActiveAt = session.summary?.updatedAt ?? 0; + if (lastActiveAt === 0) return; + const config = await this.resolveConfig(); + // The config fetch above can outlive the user's patience: if they switched + // sessions meanwhile, this dialog (and its actions) would target the wrong + // session — drop it. Likewise, if they already sent the first prompt and + // a turn is now running, don't mount over the active turn. + if (host.session !== session) return; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return; + } + // Fold in-process activity into the replay-derived baseline before + // judging: a turn may have completed during the fetch, and a completed + // turn refreshes the server-side cache — the stale replay timestamp would + // warn about an expiration the user just paid to fix. Seeding the + // baseline either way also lets a resume inside the cache window expire + // via the idle-submit path while the user idles in the TUI. + lastActiveAt = Math.max(lastActiveAt, this.lastActivityAt ?? 0); + this.lastActivityAt = lastActiveAt; + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt, + totalTokens: main?.context.tokenCount, + modelId: this.upstreamModelId(), + config, + dismissed: host.state.appState.cacheExpiryHint === false, + }); + if (decision.kind === 'skip') return; + this.resumedSessions.add(session.id); + // The resume dialog also covers this idle cycle: the first submit right + // after it must not be intercepted again. + this.idlePrompted = true; + await this.showDialog('resume', decision, undefined); + } + + /** + * Scenario 2: intercept an idle submit. Returns true when swallowed. + * Synchronous in every non-hint path — the send pipeline must stay + * await-free up to `sendMessage` (tests assert `prompt()` synchronously + * right after `handleUserInput`). When the config cache is cold the submit + * is swallowed while the config is fetched (spec: the trigger must reach + * the interface); the message is then either shown the dialog or released. + */ + maybeInterceptOnSubmit( + text: string, + extraction?: ExtractionResult, + inlineSkillActivations?: readonly InlineSkillActivation[], + ): boolean { + const { host } = this; + if (!host.engineV2 || host.session === undefined) return false; + // A stashed message being released re-enters the send path here — never + // re-intercept it (that would start a second fetch loop). + if (this.releasingStashed) return false; + if (this.idlePrompted || this.lastActivityAt === undefined) return false; + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + return false; + } + if (host.state.appState.cacheExpiryHint === false) return false; + // Providers that can never match a cache rule (apiKey / self-hosted) must + // not pay the cold-fetch stall below — no hint can ever come of it. + if (this.upstreamModelId() === undefined) return false; + // Coarse floor: configured cache durations are 10min+, so anything + // fresher than a minute can never hint. + if (Date.now() - this.lastActivityAt < 60_000) return false; + const stash: StashedSubmit = { text, extraction, sessionId: host.session.id, inlineSkillActivations }; + const cached = peekCacheHintConfig(); + if (cached !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config: cached, + dismissed: false, + }); + if (decision.kind === 'skip') return false; + this.idlePrompted = true; + // Mounts synchronously inside; the action resolution runs async. + void this.showDialog('idle', decision, stash); + return true; + } + // Config cache cold: fetch at trigger time. Submits arriving while the + // interception is in flight are swallowed too and replayed through a FIFO + // chain, so a later prompt can never overtake the stashed one. A fetch + // that already failed this cycle falls through to the normal send path. + if (this.triggerFetchAttempted && this.pendingInterceptions === 0) return false; + this.triggerFetchAttempted = true; + const sessionId = host.session.id; + this.pendingInterceptions += 1; + this.interceptionTail = this.interceptionTail + .then(() => this.interceptAfterFetch(stash, sessionId)) + .finally(() => { + this.pendingInterceptions -= 1; + }); + return true; + } + + /** Cold-cache path: fetch the config, then show the dialog or release. */ + private async interceptAfterFetch(stash: StashedSubmit, sessionId: string): Promise<void> { + const { host } = this; + // A dialog already ran for this idle cycle: chained submits follow the + // fate of the message that opened it. If that message was restored + // (dismissed or its action failed), restore these too — sending them now + // would reorder the conversation. + if (this.idlePrompted) { + if (this.lastDialogRestored) { + this.restoreStashedInput(stash); + } else { + await this.releaseStashed(stash); + } + return; + } + const config = await this.resolveConfig(); + // The fetch window is unbounded for the user: if they switched sessions + // meanwhile, never send the stashed text into the wrong session — hand it + // back to the editor instead. + if (host.session?.id !== sessionId) { + this.restoreStashedInput(stash); + return; + } + // If a foreground operation (turn, /compact, …) started meanwhile, don't + // mount over it — release through the normal path, which queues behind + // the running operation. + if (host.state.appState.streamingPhase !== 'idle' || host.state.appState.isCompacting) { + await this.releaseStashed(stash); + return; + } + if (config !== undefined) { + const decision = evaluateCacheHint({ + now: Date.now(), + lastActiveAt: this.lastActivityAt ?? 0, + totalTokens: host.state.appState.contextTokens, + modelId: this.upstreamModelId(), + config, + dismissed: false, + }); + if (decision.kind === 'hint') { + this.idlePrompted = true; + await this.showDialog('idle', decision, stash); + return; + } + } + // No hint (fetch failed or rules don't match): release the message. The + // re-entry skips the fetch (fresh cache or triggerFetchAttempted) and + // flows straight to send. + await this.releaseStashed(stash); + } + + /** Release a stashed message through the normal send path, bypassing the + * interception gate so the re-entry cannot start a second fetch. */ + private async releaseStashed(stash: StashedSubmit): Promise<void> { + this.releasingStashed = true; + try { + await this.releaseToSendPath(stash); + } finally { + this.releasingStashed = false; + } + } + + private async releaseToSendPath(stash: StashedSubmit): Promise<void> { + // A session reset cleared the image store: rebuild the extraction from + // its snapshots, persisting compressed pastes' originals into the NEW + // session's originals dir so the compression caption survives the move. + const extraction = + stash.extraction !== undefined && this.host.state.appState.sessionId !== stash.sessionId + ? makeExtractionResendable(stash.extraction, originalsDirForSession(this.host.session)) + : stash.extraction; + if (stash.inlineSkillActivations !== undefined && stash.inlineSkillActivations.length > 0) { + await this.host.sendInlineSkillUserInput(stash.text, stash.inlineSkillActivations, extraction); + return; + } + await this.host.sendNormalUserInput(stash.text, extraction); + } + + /** Restore a stashed input to the editor, appending to anything already + * restored this cycle so earlier text is not overwritten, and release the + * stash's staged media with recall semantics — the restored draft still + * references its attachments, so retains are consumed (the next submit + * re-retains) and staged copies retire instead of leaking. */ + private restoreStashedInput(stash: StashedSubmit | undefined): void { + if (stash === undefined) return; + this.restoredTexts.push(stash.text); + this.host.restoreInputText(this.restoredTexts.join('\n')); + this.host.recallStashedMedia(stash.text, stash.extraction); + } + + private upstreamModelId(): string | undefined { + const { model, availableModels, availableProviders } = this.host.state.appState; + const alias = availableModels[model]; + if (alias === undefined) return undefined; + // The cache rules describe the managed service's server-side cache, so + // they only apply to OAuth-managed providers — apiKey or self-hosted + // providers never hint. + if (availableProviders[alias.provider]?.oauth === undefined) return undefined; + return alias.model; + } + + private async resolveConfig() { + let accessToken: string | undefined; + try { + accessToken = await this.host.harness.auth.getCachedAccessToken(); + } catch { + // Facade unavailable (test doubles) — never fetch. + return undefined; + } + // The endpoint is public: apiKey-only users fetch anonymously. + return getCacheHintConfig({ accessToken }); + } + + private async showDialog( + scene: 'resume' | 'idle', + decision: HintDecision, + stashed: StashedSubmit | undefined, + ): Promise<void> { + const { host } = this; + host.track('cache_hint_shown', { + scene, + model: host.state.appState.model, + idle_seconds: decision.idleSeconds, + total_tokens: decision.totalTokens, + }); + const action = await new Promise<CacheHintAction | 'dismiss'>((resolve) => { + host.state.activeDialog = 'cache-hint'; + host.mountEditorReplacement( + new CacheHintDialogComponent({ + idleSeconds: decision.idleSeconds, + totalTokens: decision.totalTokens, + onSelect: (a) => { + resolve(a); + }, + onCancel: () => { + resolve('dismiss'); + }, + }), + ); + }); + host.state.activeDialog = null; + host.restoreEditor(); + host.track('cache_hint_action', { action, scene }); + await this.runAction(action, stashed); + } + + private async runAction( + action: CacheHintAction | 'dismiss', + stashed: StashedSubmit | undefined, + ): Promise<void> { + const { host } = this; + const restoreInput = () => { + this.lastDialogRestored = true; + this.restoreStashedInput(stashed); + }; + switch (action) { + case 'dismiss': + restoreInput(); + return; + case 'never': + host.setAppState({ cacheExpiryHint: false }); + try { + await saveTuiConfig({ ...currentTuiConfig(host), cacheExpiryHint: false }); + } catch { + host.showError('Failed to save the tui.toml preference.'); + } + break; + case 'compact': { + const session = host.session; + if (session !== undefined) { + try { + await session.compact({}); + } catch (error) { + host.showError(`Compact failed: ${formatErrorMessage(error)}`); + restoreInput(); + return; + } + if (stashed !== undefined) { + // compact() is trigger-only — the engine engages asynchronously. + // Wait for the engagement barrier so the resend lands in the + // queue and drains automatically when compaction finishes. + if (!(await this.waitForCompactionStart())) { + host.showError('Compact did not start; message not sent.'); + restoreInput(); + return; + } + } + } + break; + } + case 'new': { + const previousId = host.state.appState.sessionId; + await host.createNewSession(); + if (host.state.appState.sessionId === previousId) { + // Creation failed (error already surfaced); keep the input for retry. + restoreInput(); + return; + } + break; + } + case 'continue': + break; + } + this.lastDialogRestored = false; + if (stashed !== undefined) await this.releaseStashed(stashed); + } + + /** Bounded wait for the engine to flip `isCompacting` after a compact RPC. */ + private async waitForCompactionStart(timeoutMs = 3000): Promise<boolean> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (this.host.state.appState.isCompacting) return true; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + return false; + } +} diff --git a/apps/pythinker-code/src/tui/controllers/clipboard-image-hint.ts b/apps/pythinker-code/src/tui/controllers/clipboard-image-hint.ts new file mode 100644 index 00000000..bb396937 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/clipboard-image-hint.ts @@ -0,0 +1,167 @@ +import type { TUI } from '@pymodel/pi-tui'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +import { FOCUS_DEBOUNCE_MS, HINT_DISPLAY_MS } from '../constant/clipboard-image-hint'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '../utils/terminal-focus'; +import type { FooterComponent } from '../components/chrome/footer'; + +export interface ClipboardImageHintHost { + readonly ui: TUI; + readonly footer: FooterComponent; + getModelSupportsImage(): boolean; + requestRender(): void; +} + +function getPasteImageShortcut(): string { + return process.platform === 'win32' ? 'Alt+V' : 'Ctrl+V'; +} + +export class ClipboardImageHintController { + private readonly host: ClipboardImageHintHost; + private disposeInputListener: (() => void) | undefined; + private debounceTimer: ReturnType<typeof setTimeout> | undefined; + private clearHintTimer: ReturnType<typeof setTimeout> | undefined; + private lastHintText: string | undefined; + private checkGeneration = 0; + private focused = true; + // Whether the controller has completed its first clipboard observation since + // start. The first observation only establishes a baseline: an image already + // in the clipboard when the session starts is not "new", so it must not + // trigger a hint during initialization. + private initialized = false; + // Whether a detected clipboard image is allowed to trigger a hint. After + // showing a hint for an image it disarms so the same lingering image does + // not nag on every focus. A focus check that finds the clipboard empty + // re-arms it, so the next genuinely new image notifies again. + private armed = true; + + constructor(host: ClipboardImageHintHost) { + this.host = host; + } + + start(): void { + this.disposeInputListener = this.host.ui.addInputListener((data) => { + this.handleInput(data); + }); + void this.establishInitialBaseline(); + } + + stop(): void { + this.clearDebounceTimer(); + this.clearClearHintTimer(); + this.disposeInputListener?.(); + this.disposeInputListener = undefined; + + this.checkGeneration += 1; + this.clearOwnedHint(); + this.initialized = false; + this.armed = true; + } + + private handleInput(data: string): void { + if (data === TERMINAL_FOCUS_IN) { + this.focused = true; + this.scheduleCheck(); + return; + } + if (data === TERMINAL_FOCUS_OUT) { + this.focused = false; + this.clearDebounceTimer(); + return; + } + } + + private scheduleCheck(): void { + this.clearDebounceTimer(); + this.checkGeneration += 1; + const generation = this.checkGeneration; + this.debounceTimer = setTimeout(() => void this.runCheck(generation), FOCUS_DEBOUNCE_MS); + } + + private clearDebounceTimer(): void { + if (this.debounceTimer !== undefined) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + private clearClearHintTimer(): void { + if (this.clearHintTimer !== undefined) { + clearTimeout(this.clearHintTimer); + this.clearHintTimer = undefined; + } + } + + private clearOwnedHint(): void { + if (this.host.footer.getTransientHint() === this.lastHintText) { + this.host.footer.setTransientHint(null); + this.host.requestRender(); + } + this.lastHintText = undefined; + } + + private async establishInitialBaseline(): Promise<void> { + if (!this.host.getModelSupportsImage()) return; + + this.checkGeneration += 1; + const generation = this.checkGeneration; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + + this.initialized = true; + this.armed = !hasImage; + } + + private async runCheck(generation: number): Promise<void> { + if (!this.focused) return; + if (!this.host.getModelSupportsImage()) return; + + let hasImage = false; + try { + hasImage = await clipboardHasImage(); + } catch { + return; + } + + if (generation !== this.checkGeneration) return; + if (!this.focused) return; + + // First observation after start only establishes the baseline. An image + // already in the clipboard when the session began is not "new", so we + // record the state and stay quiet instead of nagging during initialization. + if (!this.initialized) { + this.initialized = true; + this.armed = !hasImage; + return; + } + + if (!hasImage) { + // Clipboard holds no image, so the next image that appears is a new one + // worth notifying about. Re-arm and bail out. + this.armed = true; + return; + } + + // Same image we already notified about — stay quiet until it changes. + if (!this.armed) return; + + const hintText = `Image in clipboard · ${getPasteImageShortcut()} to paste`; + this.clearClearHintTimer(); + this.lastHintText = hintText; + this.armed = false; + this.host.footer.setTransientHint(hintText); + this.host.requestRender(); + + this.clearHintTimer = setTimeout(() => { + this.clearOwnedHint(); + }, HINT_DISPLAY_MS); + } +} diff --git a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts index b40c843d..5eac8b61 100644 --- a/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/pythinker-code/src/tui/controllers/editor-keyboard.ts @@ -1,10 +1,5 @@ -import { Editor, parseKey } from '@earendil-works/pi-tui'; -import { - coerceEffortForModel, - effortLevelsForModel, - type PythinkerHarness, - type Session, -} from '@pymodel/pythinker-code-sdk'; +import type { FileMeta, PythinkerHarness, Session } from '@pymodel/pythinker-code-sdk'; +import { compressImageForModel } from '@pymodel/pythinker-code-sdk'; import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image'; import { parseImageMeta } from '#/utils/image/image-mime'; @@ -13,85 +8,83 @@ import { editInExternalEditor, resolveEditorCommand } from '#/utils/process/exte import { CTRL_C_HINT, CTRL_D_HINT, + DOUBLE_ESC_WINDOW_MS, EXIT_CONFIRM_WINDOW_MS, LLM_NOT_SET_MESSAGE, + NO_ACTIVE_SESSION_MESSAGE, } from '../constant/pythinker-tui'; +import { IMAGE_STAGING_TTL_SECONDS } from '../constant/media'; import { formatErrorMessage } from '../utils/event-payload'; -import type { ImageAttachmentStore } from '../utils/image-attachment-store'; -import type { AppState, PendingExit } from '../types'; +import type { ImageAttachment, ImageAttachmentStore } from '../utils/image-attachment-store'; +import { extractMediaAttachments, imageExtensionForMime } from '../utils/image-placeholder'; +import { extractInlineSkillActivations } from '../utils/inline-skill-tokens'; +import type { PendingExit, QueuedMessage, SteerInputItem } from '../types'; import type { TUIState } from '../tui-state'; -import type { FooterEvent } from '../runtime/footer/footer-model'; import type { BtwPanelController } from './btw-panel'; -import type { FooterActionId } from '#/tui/components/chrome/footer'; -import { - defaultKeybindings, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; -import { isPrintableChar, printableChar } from '#/tui/utils/printable-key'; -import { persistDefaultModelSelection } from '#/tui/utils/persist-effort'; - -function effectiveContextBindings( - bindings: readonly ParsedKeybinding[], - context: 'Chat' | 'Footer', -): readonly ParsedKeybinding[] { - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - if (binding.context === context || binding.context === 'Global') { - winners.set(`${binding.context}\0${binding.chord.join('\0')}`, binding); - } - } - return [...winners.values()]; -} export interface EditorKeyboardHost { state: TUIState; session: Session | undefined; - readonly harness: PythinkerHarness; + /** + * True when the TUI runs on the agent-core-v2 engine (startup-selected). + * Gates the paste-time upload to the daemon file store; the v1 engine has + * no file store and keeps the submit-time inline base64 form. + */ + readonly engineV2: boolean; cancelInFlight: (() => void) | undefined; + /** + * The host's harness (PythinkerTUI always has one). Its `imageLimits` drives + * paste-time image compression; hosts without one fall back to the + * env/built-in default. + */ + harness?: PythinkerHarness | undefined; handleUserInput(text: string): void; readonly btwPanelController: BtwPanelController; - steerMessage(session: Session, input: string[]): void; - recallLastQueued(): string | undefined; + readonly skillCommandMap: Map<string, string>; + steerMessage(session: Session, input: readonly SteerInputItem[]): void; + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void; + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + }): boolean; + releaseStagingMedia(imageAttachmentIds: readonly number[], paths: readonly string[]): void; + recallLastQueued(): QueuedMessage | undefined; showError(msg: string): void; - showNotice(title: string, detail?: string): void; - setAppState(patch: Partial<AppState>): void; - dispatchFooter(event: FooterEvent): void; track(event: string, props?: Record<string, unknown>): void; updateEditorBorderHighlight(text?: string): void; + /** `undefined` means the input cannot be a `/goal` command (clear without measuring). */ + updateGoalLengthWarning(text: string | undefined): void; updateQueueDisplay(): void; toggleToolOutputExpansion(): void; + toggleTodoPanelExpansion(): void; + detachCurrentForegroundTask(): void; + cancelRunningShellCommand(): void; hideSessionPicker(): void; - showInputHistoryPicker(): Promise<void>; - showMessageActions(): void; + openUndoSelector(): void; stop(exitCode?: number): Promise<void>; + ensureSession(): Promise<Session | undefined>; + handlePlanToggle(next: boolean): void; + handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; - openFooterAction(id: FooterActionId): void; - canFocusFooter(): boolean; + updateActivityPane(): void; } export class EditorKeyboardController { private pendingExit: PendingExit | null = null; - private footerKeybindings = new KeybindingResolver([]); - private historyNextKeybindings = new KeybindingResolver([]); - private footerBindings: readonly ParsedKeybinding[] = []; - private historyNextBindings: readonly ParsedKeybinding[] = []; + private pendingUndoEsc: { readonly timer: ReturnType<typeof setTimeout> } | null = null; constructor( private readonly host: EditorKeyboardHost, private readonly imageStore: ImageAttachmentStore, - ) { - this.setKeybindings(defaultKeybindings()); - } + ) {} install(): void { const { host } = this; const editor = host.state.editor; - host.state.ui.addInputListener((data) => this.handleFooterInput(data)); - editor.onSubmit = (text: string) => { host.handleUserInput(text); }; @@ -99,6 +92,65 @@ export class EditorKeyboardController { editor.onChange = (text: string) => { if (this.pendingExit) this.clearPendingExit(); host.updateEditorBorderHighlight(text); + // Expanding paste markers costs a full-text pass, and only `/goal` + // input can trip the objective length limit — so skip the expansion + // for ordinary prompts. Submitted text is trimmed before dispatch, so + // gate on the trimmed text too. A paste marker may itself expand into + // part of the command (`[paste #…]` → `/goal …`, or completing a + // partial prefix like `/go[paste #1 …]` → `/goal …`), so any input + // containing a marker that can still become a `/goal` command must + // pass the gate as well. + const trimmed = text.trimStart(); + const mightBeGoal = + trimmed.startsWith('/goal') || + trimmed.startsWith('[paste #') || + (trimmed.startsWith('/') && trimmed.includes('[paste #')); + if (editor.inputMode !== 'bash' && mightBeGoal) { + host.updateGoalLengthWarning(editor.getExpandedText()); + } else { + host.updateGoalLengthWarning(undefined); + } + }; + + // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode + // recalls everything. The filter is locked to the mode captured when the + // user first enters history browsing (see onHistoryDraftSave), so landing on + // a shell entry mid-browse doesn't switch the filter to shell-only. + let browseMode: 'prompt' | 'bash' | null = null; + editor.setHistoryFilter((entry: string) => { + const mode = browseMode ?? editor.inputMode; + return mode === 'bash' ? entry.startsWith('!') : true; + }); + + // Recalling a `!`-prefixed entry strips the marker and returns to bash + // mode; recalling a plain entry returns to prompt mode. The filter above + // guarantees bash mode only ever lands on `!` entries, so this never + // misfires on commands typed in bash mode. + editor.onRecall = (entry: string) => { + if (entry.startsWith('!')) { + editor.setInputMode('bash'); + return entry.slice(1); + } + editor.setInputMode('prompt'); + return undefined; + }; + + // Save/restore the input mode alongside pi-tui's history draft. Without + // this, recalling a shell entry and then pressing Down back to an empty + // draft would leave the editor stuck in bash mode, so the next typed + // message would be submitted as a shell command. Also locks the history + // filter (browseMode) for the duration of the browse session. + editor.onHistoryDraftSave = () => { + browseMode = editor.inputMode; + return editor.inputMode; + }; + editor.onHistoryDraftRestore = (state: unknown) => { + editor.setInputMode(state as 'prompt' | 'bash'); + browseMode = null; + }; + + editor.onNonEscapeInput = () => { + this.clearPendingUndoEsc(); }; editor.onCtrlC = () => { @@ -110,6 +162,8 @@ export class EditorKeyboardController { return; } + // The btw panel stacks above the transcript, so Ctrl+C cancels/closes it + // before touching an in-flight compaction or stream. if (host.btwPanelController.cancelRunning()) { this.clearPendingExit(); return; @@ -121,6 +175,9 @@ export class EditorKeyboardController { if (host.state.appState.isCompacting) { this.clearPendingExit(); + + if (this.clearEditorTextIfPresent()) return; + this.cancelCurrentCompaction(); return; } @@ -128,10 +185,7 @@ export class EditorKeyboardController { if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); - if (editor.getText().length > 0) { - editor.setText(''); - return; - } + if (this.clearEditorTextIfPresent()) return; this.cancelCurrentStream(); return; @@ -158,41 +212,67 @@ export class EditorKeyboardController { this.armPendingExit('ctrl-d', CTRL_D_HINT); }; - editor.onRedraw = () => { - host.state.ui.requestRender(); - }; - editor.onEscape = () => { if (this.pendingExit) this.clearPendingExit(); if (host.state.activeDialog === 'session-picker') { host.hideSessionPicker(); + this.clearPendingUndoEsc(); return; } + // The btw panel stacks above the transcript, so Esc dismisses it before + // touching an in-flight compaction or stream. if (host.btwPanelController.closeOrCancel()) { + this.clearPendingUndoEsc(); return; } if (host.state.appState.isCompacting) { this.cancelCurrentCompaction(); + this.clearPendingUndoEsc(); return; } if (host.state.appState.streamingPhase !== 'idle') { this.cancelCurrentStream(); + this.clearPendingUndoEsc(); + return; + } + // Idle: a second Esc within the double-tap window opens the undo selector. + if (this.pendingUndoEsc !== null) { + this.clearPendingUndoEsc(); + host.openUndoSelector(); + return; } + this.armPendingUndoEsc(); }; - editor.onOpenExternalEditor = () => { - host.track('shortcut_editor'); - void this.openExternalEditor(); + editor.onShiftTab = () => { + const togglePlan = (): void => { + const next = !host.state.appState.planMode; + host.track('shortcut_plan_toggle', { enabled: next }); + host.track('shortcut_mode_switch', { to_mode: next ? 'plan' : 'agent' }); + host.handlePlanToggle(next); + }; + if (host.session === undefined) { + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + // v2 session-less: lazy-create the session, then toggle — the same + // path /plan takes. + void host.ensureSession().then((session) => { + if (session !== undefined) togglePlan(); + }); + return; + } + togglePlan(); }; - editor.onSearchHistory = () => { - host.track('shortcut_history_search'); - void host.showInputHistoryPicker(); + editor.onInputModeChange = (mode) => { + host.handleInputModeChange(mode); }; - editor.onMessageActions = () => { - host.track('shortcut_message_actions'); - host.showMessageActions(); + editor.onOpenExternalEditor = () => { + host.track('shortcut_editor'); + void this.openExternalEditor(); }; editor.onToggleToolExpand = () => { @@ -200,58 +280,174 @@ export class EditorKeyboardController { host.toggleToolOutputExpansion(); }; + editor.onToggleTodoExpand = (): boolean => { + if (!host.state.todoPanel.hasOverflow()) return false; + // Disarm a pending double-press exit confirmation so expanding the + // todo list in between two Ctrl-C presses does not accidentally exit. + this.clearPendingExit(); + host.track('shortcut_todo_expand'); + host.toggleTodoPanelExpansion(); + return true; + }; + editor.onCtrlS = () => { - if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) return; + if ( + host.state.appState.streamingPhase === 'idle' || + host.state.appState.streamingPhase === 'shell' || + host.state.appState.isCompacting + ) + return; const text = editor.getText().trim(); - const queuedTexts = host.state.queuedMessages.map((m) => m.text); - host.clearQueuedMessages(); - - const parts: string[] = []; - for (const q of queuedTexts) { - const trimmed = q.trim(); - if (trimmed.length > 0) parts.push(trimmed); + const editorIsBash = editor.inputMode === 'bash'; + + // Bash commands (`! …`) are not steerable: they stay queued so they run + // after the current task. Grouped inline-skill submissions are not + // steerable either — steer carries no skill activations, so they stay + // queued and submit intact when the session drains; the same applies to + // an editor draft carrying inline skill tokens. Steering stops at the + // first such bundle: items behind it stay queued too, or a later + // message would jump ahead of its bundle and reverse the conversational + // order. Everything else steers in queue order — plain text as a + // steered message, slash-skill items as activations fired into the + // running turn (never as literal text). + const queued = host.state.queuedMessages; + const firstBundle = queued.findIndex((m) => m.inlineSkillActivations !== undefined); + const windowBeforeFirstBundle = firstBundle === -1 ? queued : queued.slice(0, firstBundle); + const steerable = windowBeforeFirstBundle.filter((m) => m.mode !== 'bash'); + const editorHasInlineSkills = + !editorIsBash && + text.length > 0 && + host.engineV2 && + extractInlineSkillActivations(text, host.skillCommandMap).length > 0; + + type SteerRun = + | { readonly kind: 'text'; readonly items: SteerInputItem[] } + | { readonly kind: 'skill'; readonly skillName: string; readonly skillArgs: string }; + const runs: SteerRun[] = []; + let textRun: SteerInputItem[] = []; + const flushTextRun = (): void => { + if (textRun.length > 0) { + runs.push({ kind: 'text', items: textRun }); + textRun = []; + } + }; + for (const m of steerable) { + if (m.mode === 'skill' && m.skillName !== undefined) { + flushTextRun(); + runs.push({ kind: 'skill', skillName: m.skillName, skillArgs: m.skillArgs ?? '' }); + continue; + } + const trimmed = m.text.trim(); + if (trimmed.length > 0) { + // Queued items carry the parts extracted when they were submitted + // (and were already capability-validated then). + textRun.push({ + text: trimmed, + parts: m.parts, + imageAttachmentIds: m.imageAttachmentIds, + stagingPaths: m.stagingPaths, + }); + } } - if (text.length > 0) parts.push(text); - - if (parts.length > 0) { - editor.setText(''); + let editorExtraction: ReturnType<typeof extractMediaAttachments> | undefined; + if (!editorIsBash && text.length > 0 && !editorHasInlineSkills && firstBundle === -1) { + try { + // Synchronous path: an image still ingesting in the background + // extracts to its inline fallback here (no bounded wait like + // `sendNormalUserInput` — this handler cannot await without + // interleaving queue/draft edits). + editorExtraction = extractMediaAttachments(text, this.imageStore); + } catch (error) { + // Cache copy failed (e.g. the pasted video's source vanished) — + // leave the queue and the editor draft untouched. + host.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + textRun.push({ + text, + parts: editorExtraction.hasMedia ? editorExtraction.parts : undefined, + imageAttachmentIds: + editorExtraction.imageAttachmentIds.length > 0 + ? editorExtraction.imageAttachmentIds + : undefined, + stagingPaths: editorExtraction.stagingPaths, + }); + } + flushTextRun(); + + if (runs.length > 0) { + // The editor draft is fresh input: gate it on the model's media + // capabilities before splicing the queue, so a rejection leaves the + // queue and the draft untouched. + if ( + editorExtraction !== undefined && + !host.validateMediaCapabilities(editorExtraction) + ) { + host.releaseStagingMedia( + editorExtraction.imageAttachmentIds, + editorExtraction.stagingPaths, + ); + return; + } const session = host.session; if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.releaseStagingMedia( + editorExtraction?.imageAttachmentIds ?? [], + editorExtraction?.stagingPaths ?? [], + ); host.showError(LLM_NOT_SET_MESSAGE); - } else { - host.steerMessage(session, parts); + return; + } + host.state.queuedMessages = queued.filter( + (m, index) => m.mode === 'bash' || (firstBundle !== -1 && index >= firstBundle), + ); + if (!editorIsBash && !editorHasInlineSkills && firstBundle === -1) editor.setText(''); + for (const run of runs) { + if (run.kind === 'text') { + host.steerMessage(session, run.items); + } else { + host.steerSkillActivation(session, run.skillName, run.skillArgs); + } } } host.updateQueueDisplay(); host.state.ui.requestRender(); }; - editor.onCycleEffort = () => { - void this.cycleThinkingEffort(); + editor.onCtrlB = (): boolean => { + // Shell command execution is treated as a streaming phase ('shell'), so + // this gate already covers it; only idle + not-compacting falls through. + if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { + return false; + } + host.track('shortcut_background_task'); + host.detachCurrentForegroundTask(); + return true; }; editor.onUndo = () => { host.track('undo'); }; - editor.onInsertNewline = () => { - host.track('shortcut_newline'); - }; - editor.onTextPaste = () => { host.track('shortcut_paste', { kind: 'text' }); }; - editor.onCommand = (command) => { - host.handleUserInput(`/${command}`); - }; - editor.onUpArrowEmpty = () => { if (host.btwPanelController.scroll('up')) return true; if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { - editor.setText(recalled); + editor.setText(recalled.text); + // Restore the queued item's mode so a recalled `!` command runs as a + // shell command again instead of being submitted as a normal prompt. + // Skill activations recall as prompt mode: their text is the original + // `/name args` slash command, which re-parses on submit. + const mode = recalled.mode === 'bash' ? 'bash' : 'prompt'; + if (editor.inputMode !== mode) { + editor.inputMode = mode; + editor.onInputModeChange?.(mode); + } host.updateQueueDisplay(); host.state.ui.requestRender(); return true; @@ -264,26 +460,37 @@ export class EditorKeyboardController { editor.onPasteImage = async () => this.handleClipboardImagePaste(); } - setKeybindings(bindings: readonly ParsedKeybinding[]): void { - this.footerBindings = effectiveContextBindings(bindings, 'Footer').filter( - (binding) => binding.action === null || binding.action.startsWith('footer:'), - ); - this.historyNextBindings = effectiveContextBindings(bindings, 'Chat').filter( - (binding) => binding.action === null || binding.action === 'history:next', - ); - this.resetFooterKeybindings(); - } - clearPendingExit(): void { if (!this.pendingExit) return; clearTimeout(this.pendingExit.timer); - this.host.dispatchFooter({ type: 'transient-hint.updated', hint: null }); + this.host.state.footer.setTransientHint(null); this.pendingExit = null; } + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + + private armPendingUndoEsc(): void { + this.clearPendingUndoEsc(); + const timer = setTimeout(() => { + if (this.pendingUndoEsc?.timer === timer) { + this.pendingUndoEsc = null; + } + }, DOUBLE_ESC_WINDOW_MS); + this.pendingUndoEsc = { timer }; + } + + private clearPendingUndoEsc(): void { + if (!this.pendingUndoEsc) return; + clearTimeout(this.pendingUndoEsc.timer); + this.pendingUndoEsc = null; + } + private armPendingExit(kind: 'ctrl-c' | 'ctrl-d', hint: string): void { this.clearPendingExit(); - this.host.dispatchFooter({ type: 'transient-hint.updated', hint }); + this.host.state.footer.setTransientHint(hint); const timer = setTimeout(() => { if (this.pendingExit?.timer === timer) { @@ -296,43 +503,18 @@ export class EditorKeyboardController { this.host.state.ui.requestRender(); } - private cancelCurrentStream(): void { - void this.host.session?.cancel(); + private clearEditorTextIfPresent(): boolean { + const editor = this.host.state.editor; + if (editor.getText().length === 0) return false; + editor.setText(''); + return true; } - /** Ctrl-T / Shift-Tab: cycle the thinking effort to the current model's next level (wraps). */ - private async cycleThinkingEffort(): Promise<void> { - const { host } = this; - const alias = host.state.appState.model; - if (alias.trim().length === 0) { - host.showError(LLM_NOT_SET_MESSAGE); - return; - } - const model = host.state.appState.availableModels[alias]; - const levels = effortLevelsForModel(model); - if (levels.length <= 1) { - host.showNotice(`${alias} does not offer selectable thinking effort levels.`); - return; - } - const current = coerceEffortForModel(model, host.state.appState.thinkingLevel); - const next = levels[(levels.indexOf(current) + 1) % levels.length]!; - try { - await host.session?.setThinking(next); - } catch (error) { - host.showError(`Failed to set thinking effort: ${formatErrorMessage(error)}`); - return; - } - host.setAppState({ thinkingLevel: next }); - host.track('thinking_toggle', { enabled: next !== 'off', effort: next }); - // No transcript notice: the footer already shows the new level live, and - // rapid cycling would stack a line per keypress in the chat history. - try { - await persistDefaultModelSelection(host.harness, alias, next); - } catch (error) { - host.showError( - `Thinking effort set to ${next}, but failed to save default: ${formatErrorMessage(error)}`, - ); - } + private cancelCurrentStream(): void { + // Cancel any running `!` shell command (treated as a streaming phase) in + // addition to the agent turn, so Esc / Ctrl+C interrupts it too. + this.host.cancelRunningShellCommand(); + void this.host.session?.cancel(); } private cancelCurrentCompaction(): void { @@ -344,75 +526,6 @@ export class EditorKeyboardController { }); } - private handleFooterInput(data: string): { consume: boolean } | undefined { - const { host } = this; - const { footer } = host.state; - if (!host.canFocusFooter()) { - footer.clearSelection(); - this.resetFooterKeybindings(); - return undefined; - } - if (footer.selectedActionId() !== null) { - if (data === '\u001B') { - footer.clearSelection(); - return { consume: true }; - } - if ( - this.dispatchKeybindings(this.footerKeybindings, data, ['Footer'], { - 'footer:up': () => footer.selectPrevious(), - 'footer:down': () => footer.selectNext(), - 'footer:next': () => footer.selectNext(), - 'footer:previous': () => footer.selectPrevious(), - 'footer:openSelected': () => { - const action = footer.selectedActionId(); - footer.clearSelection(); - if (action !== null) host.openFooterAction(action); - }, - 'footer:clearSelection': () => footer.clearSelection(), - }) - ) { - return { consume: true }; - } - if (isPrintableChar(printableChar(data))) { - footer.clearSelection(); - return undefined; - } - return undefined; - } - - let historyNext = false; - const handled = this.dispatchKeybindings(this.historyNextKeybindings, data, ['Chat'], { - 'history:next': () => { - historyNext = true; - }, - }); - if (handled && !historyNext) return { consume: true }; - if (!historyNext || !host.canFocusFooter()) { - return undefined; - } - - Editor.prototype.handleInput.call(host.state.editor, '\u001B[B'); - if (host.state.editor.getText().length === 0) footer.selectFirst(); - return { consume: true }; - } - - private resetFooterKeybindings(): void { - this.footerKeybindings = new KeybindingResolver(this.footerBindings); - this.historyNextKeybindings = new KeybindingResolver(this.historyNextBindings); - } - - private dispatchKeybindings( - resolver: KeybindingResolver, - data: string, - contexts: Parameters<KeybindingResolver['dispatchKeyId']>[1], - handlers: Parameters<KeybindingResolver['dispatchKeyId']>[2], - ): boolean { - const keyId = parseKey(data); - return keyId === undefined || keyId === data - ? resolver.dispatchKeyId(data, contexts, handlers) - : resolver.dispatch(data, contexts, handlers); - } - private async handleClipboardImagePaste(): Promise<boolean> { let media; try { @@ -436,13 +549,128 @@ export class EditorKeyboardController { const meta = parseImageMeta(media.bytes); if (meta === null) return false; - const attachment = this.imageStore.addImage(media.bytes, meta.mime, meta.width, meta.height); + + // Register the attachment and put its placeholder in the editor before + // any of the asynchronous ingestion work below. CustomEditor only holds + // keystrokes until this handler settles, so the callback returns right + // after the placeholder lands and ingestion continues in the background — + // typing never waits on compression or the daemon upload. Submit gives a + // pending ingestion a bounded wait (`pendingImageIngestions`) and falls + // back to the inline form when it has not finished. + const attachment = this.imageStore.addImage( + media.bytes, + meta.mime, + meta.width, + meta.height, + ); this.host.state.editor.insertTextAtCursor?.(`${attachment.placeholder} `); this.host.state.ui.requestRender(); this.host.track('shortcut_paste', { kind: 'image' }); + + attachment.pending = this.finishClipboardImagePaste( + attachment, + media.bytes, + meta.mime, + meta.width, + meta.height, + ).catch((error: unknown) => { + // The raw attachment and its already-visible placeholder are still a + // valid inline fallback when optional ingestion work fails. + this.host.showError(`Failed to process pasted image: ${formatErrorMessage(error)}`); + }); return true; } + private async finishClipboardImagePaste( + attachment: ImageAttachment, + originalBytes: Uint8Array, + originalMime: string, + originalWidth: number, + originalHeight: number, + ): Promise<void> { + // Compress at ingestion — a pure data step while building the attachment, so + // the stored bytes, the inline thumbnail, the `[image #N (W×H)]` placeholder, + // and the submitted image all agree, and the agent core only ever sees an + // already-compressed image. Best effort: originals pass through on failure. + // When compression changed the bytes, the pre-compression original is kept + // on the attachment in memory: the session whose media-originals dir it + // belongs in may not exist yet at paste time, so dispatch-time caption + // resolution (`resolveOriginalCaptions`) persists it and announces the + // compression, pointing the model at the full-fidelity copy. + // The edge cap comes from the host harness's [image] config (resolved per + // paste so a config reload applies immediately); hosts without a harness + // use the env/built-in default. + const compressed = await compressImageForModel(originalBytes, originalMime, { + maxEdge: this.host.harness?.imageLimits?.maxEdgePx(), + telemetry: { + client: { + track: (event, properties) => + this.host.track(event, properties === undefined ? undefined : { ...properties }), + }, + source: 'tui_paste', + }, + }); + // Dimensions come from the compression result, not parseImageMeta: the + // compressor reports display space (EXIF orientation applied) — the space + // the sent image, the caption, and ReadMediaFile region readback share — + // while parseImageMeta reads the raw pre-rotation header. + const original = compressed.changed + ? { + bytes: originalBytes, + width: compressed.originalWidth, + height: compressed.originalHeight, + byteLength: originalBytes.length, + mime: originalMime, + } + : undefined; + // v2 only: upload the final bytes to the daemon file store so submit-time + // expansion emits a `pythinker-file://` reference instead of inline base64. + const uploaded = await this.uploadImageToDaemonFileStore( + compressed.changed ? compressed.data : originalBytes, + compressed.changed ? compressed.mimeType : originalMime, + ); + const completed = this.imageStore.completeImage(attachment, { + bytes: compressed.changed ? compressed.data : originalBytes, + mime: compressed.changed ? compressed.mimeType : originalMime, + width: compressed.width || originalWidth, + height: compressed.height || originalHeight, + original, + fileId: uploaded?.id, + fileExpiresAt: parseExpiry(uploaded), + }); + if (completed === undefined && uploaded !== undefined) { + await this.host.harness?.deleteFile(uploaded.id).catch(() => undefined); + } + this.host.state.ui.requestRender(); + } + + /** + * Paste-time upload of the final image bytes to the engine's daemon file + * store (agent-core-v2 only), run as part of the background ingestion — + * typing never waits on it, and submit only gives it the bounded + * `pendingImageIngestions` wait. Best effort: any failure returns undefined, + * so the attachment keeps no `fileId` and submit-time expansion falls back + * to the inline base64 form. + */ + private async uploadImageToDaemonFileStore( + bytes: Uint8Array, + mime: string, + ): Promise<FileMeta | undefined> { + if (!this.host.engineV2) return undefined; + const harness = this.host.harness; + if (harness === undefined) return undefined; + try { + const meta = await harness.uploadFile(bytes, { + name: `pasted-image.${imageExtensionForMime(mime)}`, + mimeType: mime, + expiresInSec: IMAGE_STAGING_TTL_SECONDS, + }); + return meta; + } catch { + return undefined; + } + } + private async openExternalEditor(): Promise<void> { const { state } = this.host; if (state.externalEditorRunning) return; @@ -453,7 +681,10 @@ export class EditorKeyboardController { } this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); - state.ui.stop(); + // Fullscreen: a plain stop() would replay the whole transcript into the + // main screen on exit; the external editor only needs the alternate + // screen released, so preserve the screen instead. + state.ui.stop({ preserveScreen: state.ui.mode === 'fullscreen' ? true : undefined }); await new Promise<void>((resolve) => { setImmediate(resolve); }); @@ -472,7 +703,18 @@ export class EditorKeyboardController { state.ui.start(); state.ui.setFocus(state.editor); state.ui.requestRender(true); + // terminal.stop() cleared the OSC 9;4 progress indicator while the + // app-side progressActive flag still reads true; resync so a turn that + // was streaming while the editor was open gets its progress back. + state.terminalState.progressActive = false; + this.host.updateActivityPane(); this.host.setExternalEditorRunning(false); } } } + +function parseExpiry(meta: FileMeta | undefined): number | undefined { + if (meta?.expires_at === undefined) return undefined; + const value = Date.parse(meta.expires_at); + return Number.isFinite(value) ? value : undefined; +} diff --git a/apps/pythinker-code/src/tui/controllers/mouse-controller.ts b/apps/pythinker-code/src/tui/controllers/mouse-controller.ts deleted file mode 100644 index 3c8a0fdb..00000000 --- a/apps/pythinker-code/src/tui/controllers/mouse-controller.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * MouseController — app-managed mouse for the fixed layout. - * - * pi-tui never enables mouse reporting, so this controller turns on SGR - * reporting itself and parses the raw frames from an input listener - * (StdinBuffer already frames SGR mouse sequences as single chunks). - * Wheel events scroll the transcript viewport; left-drag paints a - * selection that is copied to the clipboard on release (Ghostty-style - * copy-on-select). Native terminal selection stays available through the - * terminal's mouse bypass modifier (Shift/Option+drag). - */ - -import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; - -import { - MOUSE_DRAG_SCROLL_INTERVAL_MS, - MOUSE_REPORTING_DISABLE, - MOUSE_REPORTING_ENABLE, - MOUSE_SCROLL_LINES, - MOUSE_SGR_PATTERN, - OSC52_CLIPBOARD_PREFIX, - OSC52_CLIPBOARD_SUFFIX, -} from '../constant/mouse'; -import type { TuiPresentation } from '../runtime/contracts'; -import type { TUIState } from '../tui-state'; - -export interface MouseControllerHost { - state: TUIState; - presentation: TuiPresentation; -} - -const WHEEL_FLAG = 64; -const MOTION_FLAG = 32; -const BUTTON_MASK = 3; -const BUTTON_LEFT = 0; - -type DragScrollDirection = -1 | 1; - -export class MouseController { - private removeInputListener: (() => void) | undefined; - private dragScrollTimer: ReturnType<typeof setInterval> | undefined; - private dragScrollDirection: DragScrollDirection | undefined; - private dragScreenCol = 1; - private active = false; - private dragging = false; - - constructor(private readonly host: MouseControllerHost) {} - - start(): void { - if (this.active) return; - this.active = true; - this.removeInputListener = this.host.state.ui.addInputListener((data) => this.handleInput(data)); - this.host.presentation.writeTerminalControl(MOUSE_REPORTING_ENABLE); - } - - stop(): void { - if (!this.active) return; - this.active = false; - this.dragging = false; - this.stopDragScroll(); - this.removeInputListener?.(); - this.removeInputListener = undefined; - try { - this.host.presentation.writeTerminalControl(MOUSE_REPORTING_DISABLE); - } catch { - // Best-effort: the terminal may already be gone (SIGHUP path). - } - } - - private handleInput(data: string): { consume: boolean } | undefined { - const match = MOUSE_SGR_PATTERN.exec(data); - if (match === null) return undefined; - // match[1..3] are decimal button / 1-based column / 1-based row. - this.handleMouse(Number(match[1]), Number(match[2]), Number(match[3]), match[4] === 'M'); - return { consume: true }; - } - - private handleMouse(button: number, col: number, row: number, isPress: boolean): void { - const { state } = this.host; - const viewport = state.transcriptViewport; - - if ((button & WHEEL_FLAG) !== 0) { - if (!isPress) return; - this.stopDragScroll(); - const up = (button & 1) === 0; - viewport.scrollBy(up ? MOUSE_SCROLL_LINES : -MOUSE_SCROLL_LINES); - state.ui.requestRender(); - return; - } - - const isLeft = (button & BUTTON_MASK) === BUTTON_LEFT; - - if (isPress && (button & MOTION_FLAG) !== 0) { - if (!this.dragging) return; - // Clamp drag rows into the viewport so resting the pointer on the - // bottom or top edge auto-scrolls instead of ending the selection. - const edgeRow = Math.min(Math.max(row, 1), viewport.getHeight()); - const cell = viewport.screenToBuffer(edgeRow, col); - if (cell !== undefined) { - viewport.extendSelection(cell); - state.ui.requestRender(); - } - const direction = - row <= 1 ? 1 : row >= viewport.getHeight() ? -1 : undefined; - this.startDragScroll(direction, col); - return; - } - - if (isPress && isLeft) { - this.dragging = false; - this.stopDragScroll(); - if (viewport.chipHit(row, col)) { - viewport.scrollToBottom(); - viewport.clearSelection(); - state.ui.requestRender(); - return; - } - const cell = viewport.screenToBuffer(row, col); - if (cell !== undefined) { - this.dragging = true; - viewport.setSelection(cell, cell); - } else { - // Click on the chrome / footer area: just drop any selection. - viewport.clearSelection(); - } - state.ui.requestRender(); - return; - } - - // Release finishes an active drag: extend the selection to the release - // position, then copy (Ghostty-style copy-on-select) and keep the - // highlight until the next press. - if (isPress || !this.dragging) return; - this.dragging = false; - this.stopDragScroll(); - const releaseRow = Math.min(Math.max(row, 1), viewport.getHeight()); - const releaseCell = viewport.screenToBuffer(releaseRow, col); - if (releaseCell !== undefined) { - viewport.extendSelection(releaseCell); - state.ui.requestRender(); - } - const text = viewport.extractSelectionText(); - if (text.length > 0) { - try { - const encoded = Buffer.from(text, 'utf8').toString('base64'); - this.host.presentation.writeTerminalControl( - `${OSC52_CLIPBOARD_PREFIX}${encoded}${OSC52_CLIPBOARD_SUFFIX}`, - ); - } catch { - // The platform clipboard below still works when OSC 52 is unavailable. - } - void copyTextToClipboard(text).catch(() => { - // Copy is best-effort; a missing clipboard tool must not surface. - }); - } - } - - private startDragScroll(direction: DragScrollDirection | undefined, screenCol: number): void { - this.dragScreenCol = screenCol; - if (direction === undefined) { - this.stopDragScroll(); - return; - } - // Keep the existing timer when the direction is unchanged so the repeat - // cadence is not restarted by every motion event. - if (this.dragScrollTimer !== undefined && this.dragScrollDirection === direction) return; - this.stopDragScroll(); - this.dragScrollDirection = direction; - this.dragScrollTimer = setInterval(() => { - this.scrollDragSelection(); - }, MOUSE_DRAG_SCROLL_INTERVAL_MS); - } - - private scrollDragSelection(): void { - const direction = this.dragScrollDirection; - if (!this.dragging || direction === undefined) { - this.stopDragScroll(); - return; - } - - const { state } = this.host; - const viewport = state.transcriptViewport; - const previousOffset = viewport.getScrollOffset(); - viewport.scrollBy(direction); - // Stop once scrolling reaches the transcript edge; extend the selection - // through the edge row on every successful step. - if (viewport.getScrollOffset() === previousOffset) { - this.stopDragScroll(); - return; - } - - const edgeRow = direction > 0 ? 1 : viewport.getHeight(); - const cell = viewport.screenToBuffer(edgeRow, this.dragScreenCol); - if (cell !== undefined) viewport.extendSelection(cell); - state.ui.requestRender(); - } - - private stopDragScroll(): void { - if (this.dragScrollTimer !== undefined) clearInterval(this.dragScrollTimer); - this.dragScrollTimer = undefined; - this.dragScrollDirection = undefined; - } -} diff --git a/apps/pythinker-code/src/tui/controllers/plugin-update-notifier.ts b/apps/pythinker-code/src/tui/controllers/plugin-update-notifier.ts new file mode 100644 index 00000000..9bbbfc48 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/plugin-update-notifier.ts @@ -0,0 +1,208 @@ +import type { PluginSummary } from '@pymodel/pythinker-code-sdk'; + +import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + computeUpdateStatus, + loadPluginMarketplace, + type PluginMarketplace, +} from '#/utils/plugin-marketplace'; +import { + readPluginUpdateNoticeState, + writePluginUpdateNoticeState, +} from '#/utils/plugin-update-notice-state'; +import { isOfficialPluginInstall } from '../utils/plugin-source-label'; + +/** + * The slice of the SDK session the notifier reads. Structurally satisfied by + * the full SDK `Session`, and easy to fake in tests. + */ +export interface PluginUpdateNotifierSession { + listMcpServers(): Promise<readonly { name: string }[]>; + listPlugins(): Promise<readonly PluginSummary[]>; +} + +export interface PluginUpdateNotifierDeps { + readonly getSession: () => PluginUpdateNotifierSession | undefined; + readonly workDir: string; + readonly notify: (message: string) => void; + /** Overridable for tests; defaults to the shared marketplace loader. */ + readonly loadMarketplace?: () => Promise<PluginMarketplace>; + /** Overridable for tests; defaults to the updates dir under the data dir. */ + readonly stateFile?: string; +} + +const MCP_TOOL_NAME_PREFIX = 'mcp__'; +const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`; +// Plugin MCP servers run under the runtime name `plugin-<id>:<server>` +// (pluginMcpRuntimeName in packages/agent-core/src/plugin/manager.ts). +const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/; + +/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */ +export function isPluginMcpToolName(toolName: string): boolean { + return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX); +} + +/** + * Mirror of sanitizeMcpNamePart in packages/agent-core/src/mcp/tool-naming.ts. + * MCP tool names on the wire carry the sanitized server name; the collapse + * step guarantees the `__` separator never appears inside a name part. + */ +function sanitizeMcpServerName(name: string): string { + return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +/** + * Find the plugin behind a qualified MCP tool name by longest-prefix match + * against known server names. Prefix matching (rather than splitting on the + * `__` separator) survives core's 64-char truncation, which can cut the + * separator before appending the hash suffix; the boundary check keeps a + * shorter server name from matching another server's name, and longest match + * wins when one server name is a prefix of another. A name truncated inside + * the server part itself cannot be attributed reliably and stays unresolved. + */ +function matchPluginByToolName( + toolName: string, + serverPluginIds: Map<string, string>, +): string | undefined { + let best: string | undefined; + let bestLength = 0; + for (const [serverName, pluginId] of serverPluginIds) { + const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`; + if (!toolName.startsWith(prefix)) continue; + const boundary = toolName.charAt(prefix.length); + if (boundary !== '' && boundary !== '_') continue; + if (prefix.length > bestLength) { + best = pluginId; + bestLength = prefix.length; + } + } + return best; +} + +/** + * Shows a one-time "update detected" notice for outdated plugins. Callers + * report completed plugin usage (a plugin MCP tool name, or the plugin id of + * a `/<plugin>:<command>` turn — both reported once the turn's output has + * ended); the notifier checks the marketplace and persists the last notified + * version, so a plugin is re-notified only when the marketplace advertises a + * newer version than the one already shown. + * + * Entry points are fire-and-forget in production (never reject — the notice + * is a background nicety and any failure, e.g. an offline marketplace, is + * swallowed) and return an awaitable promise so tests can settle the queue + * deterministically. + */ +export class PluginUpdateNotifier { + private marketplacePromise: Promise<PluginMarketplace> | undefined; + private mcpServerPluginIds: Map<string, string> | undefined; + private readonly inFlight = new Set<string>(); + private queue: Promise<void> = Promise.resolve(); + + constructor(private readonly deps: PluginUpdateNotifierDeps) {} + + handleMcpToolCompleted(toolName: string): Promise<void> { + // Cheap bail before touching the RPC layer — most tools are not MCP tools, + // let alone plugin ones. + if (!isPluginMcpToolName(toolName)) return Promise.resolve(); + return this.resolvePluginId(toolName) + .then((pluginId) => { + if (pluginId !== undefined) return this.enqueue(pluginId); + return undefined; + }) + .catch(() => {}); + } + + handlePluginCommandCompleted(pluginId: string): Promise<void> { + return this.enqueue(pluginId); + } + + private enqueue(pluginId: string): Promise<void> { + // Serialize the read-modify-write cycle on the notice state file: two + // concurrent checks (e.g. a turn that used two outdated plugins) would + // otherwise read the same snapshot and the last write would drop the + // other plugin's entry. + this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {}); + return this.queue; + } + + private async resolvePluginId(toolName: string): Promise<string | undefined> { + const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds()); + if (hit !== undefined) return hit; + // The map is memoized, but this notifier is reused across /reload, /new, + // and session switches, so plugins installed or enabled later in the same + // app run are missing from it. Refresh once on a miss before giving up. + return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds()); + } + + private async getMcpServerPluginIds(): Promise<Map<string, string>> { + if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds; + return this.loadMcpServerPluginIds(); + } + + private async loadMcpServerPluginIds(): Promise<Map<string, string>> { + const map = new Map<string, string>(); + const session = this.deps.getSession(); + // Without a session there is nothing to list; leave the cache unset so + // the next lookup retries instead of pinning an empty map. + if (session === undefined) return map; + const servers = await session.listMcpServers(); + for (const server of servers) { + const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name); + if (match?.[1] !== undefined) { + map.set(sanitizeMcpServerName(server.name), match[1]); + } + } + this.mcpServerPluginIds = map; + return map; + } + + private async checkAndNotify(pluginId: string): Promise<void> { + if (this.inFlight.has(pluginId)) return; + this.inFlight.add(pluginId); + try { + const session = this.deps.getSession(); + if (session === undefined) return; + const marketplace = await this.loadCatalog(); + // Only the default official catalog can back an "Official Marketplace" + // notice — a custom catalog (PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL) may + // advertise anything under any id. + if (marketplace.source !== PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL) return; + const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId); + if (entry === undefined) return; + const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); + if (installed === undefined) return; + // Only official installs are tracked against the Official Marketplace — + // a local/GitHub fork that happens to share a catalog id is not it. + if (!isOfficialPluginInstall(installed)) return; + const status = computeUpdateStatus(entry.version, installed.version, true); + if (status.kind !== 'update') return; + const state = await readPluginUpdateNoticeState(this.deps.stateFile); + if (state.notified[pluginId] === status.latest) return; + this.deps.notify( + `Update detected: ${installed.displayName} ${status.latest} is available. ` + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + await writePluginUpdateNoticeState( + { ...state, notified: { ...state.notified, [pluginId]: status.latest } }, + this.deps.stateFile, + ); + } finally { + this.inFlight.delete(pluginId); + } + } + + private loadCatalog(): Promise<PluginMarketplace> { + // Cached for the app run; a failed fetch is retried on the next invocation. + this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => { + this.marketplacePromise = undefined; + throw error; + }); + return this.marketplacePromise; + } + + private loadMarketplace(): Promise<PluginMarketplace> { + const load = this.deps.loadMarketplace; + if (load !== undefined) return load(); + return loadPluginMarketplace({ workDir: this.deps.workDir }); + } +} diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index e77f7875..4b78482c 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -1,6 +1,5 @@ -import type { Component, Focusable } from '@earendil-works/pi-tui'; +import type { Component, Focusable } from '@pymodel/pi-tui'; import type { - AdvisorStatusEvent, AgentStatusUpdatedEvent, AssistantDeltaEvent, BackgroundTaskInfo, @@ -15,10 +14,10 @@ import type { GoalChange, GoalUpdatedEvent, HookResultEvent, - HookStatusEvent, Session, SessionMetaUpdatedEvent, SkillActivatedEvent, + PluginCommandActivatedEvent, ThinkingDeltaEvent, ToolCallDeltaEvent, ToolCallStartedEvent, @@ -28,11 +27,13 @@ import type { TurnStartedEvent, TurnStepCompletedEvent, TurnStepInterruptedEvent, + TurnStepRetryingEvent, TurnStepStartedEvent, + TokenUsage, WarningEvent, } from '@pymodel/pythinker-code-sdk'; -import { ActivityLoader } from '../components/chrome/activity-loader'; +import { MoonLoader } from '../components/chrome/moon-loader'; import { buildGoalMarker } from '../components/messages/goal-markers'; import { StatusMessageComponent } from '../components/messages/status-message'; import { @@ -40,18 +41,15 @@ import { type DynamicWorkflowModeMarkerState, } from '../components/messages/dynamic-workflow-markers'; import { - MCP_STATUS_TRANSIENT_DURATION_MS, OAUTH_LOGIN_REQUIRED_CODE, OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE, } from '../constant/pythinker-tui'; -import { FAILURE_MARK, STATUS_BULLET, SUCCESS_MARK } from '../constant/symbols'; -import { setLiveIntent } from '../constant/rendering'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; import { argsRecord, formatErrorPayload, formatErrorMessage, - normalizeTodoList, + isTodoItemShape, serializeToolResultOutput, stringValue, } from '../utils/event-payload'; @@ -65,10 +63,10 @@ import { formatBackgroundTaskTranscript } from '../utils/background-task-status' import { formatHookResultMarkdown } from '../utils/hook-result-format'; import { McpOAuthAuthorizationUrlOpener } from '../utils/mcp-oauth'; import { - buildMcpStartupStatusLine, formatMcpStartupStatusSummary, mcpServerStatusKey, type McpServerStatusSnapshot, + selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; import { openUrl } from '#/utils/open-url'; import { currentTheme } from '#/tui/theme'; @@ -77,6 +75,7 @@ import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; +import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; import { SubAgentEventHandler } from './subagent-event-handler'; @@ -89,16 +88,8 @@ import type { TranscriptEntry, } from '../types'; import type { TUIState } from '../tui-state'; -import type { FooterEvent } from '../runtime/footer/footer-model'; import { createGoal as startGoalCommand } from '../commands/goal'; -function mcpStatusAnimationEnabled(): boolean { - if (process.env['PYTHINKER_NO_ANIMATION']) return false; - if (process.env['CI']) return false; - if (process.env['NO_COLOR']) return false; - return true; -} - export interface SessionEventHost { state: TUIState; session: Session | undefined; @@ -108,7 +99,6 @@ export interface SessionEventHost { requireSession(): Session; setAppState(patch: Partial<AppState>): void; - dispatchFooter(event: FooterEvent): void; patchLivePane(patch: Partial<LivePaneState>): void; resetLivePane(): void; showError(msg: string): void; @@ -116,23 +106,33 @@ export interface SessionEventHost { showNotice(title: string, detail?: string): void; updateActivityPane(): void; track(event: string, props?: Record<string, unknown>): void; + recordSessionActivity(): void; + noteStepUsage(usage: TokenUsage | undefined): void; + noteCompactionFinished(): void; mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void; + handleShellStarted(event: { commandId: string; taskId: string }): void; sendNormalUserInput(text: string): void; updateTerminalTitle(): void; - refreshSkillCommands(session?: Session): Promise<void>; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; + handleTurnStarted?(event: TurnStartedEvent): void; + handleTurnEnded?(event: TurnEndedEvent): void; readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; } export class SessionEventHandler { readonly subAgentEventHandler: SubAgentEventHandler; + private readonly pluginUpdateNotifier: PluginUpdateNotifier; - constructor(private readonly host: SessionEventHost) { + constructor( + private readonly host: SessionEventHost, + pluginUpdateNotifier?: PluginUpdateNotifier, + ) { this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, @@ -140,6 +140,15 @@ export class SessionEventHandler { this.syncBackgroundTaskBadge(); }, }); + this.pluginUpdateNotifier = + pluginUpdateNotifier ?? + new PluginUpdateNotifier({ + getSession: () => this.host.session, + workDir: host.state.appState.workDir, + notify: (message) => { + this.host.showStatus(message, 'warning'); + }, + }); } // Runtime state – owned by this handler, reset between sessions. @@ -147,78 +156,52 @@ export class SessionEventHandler { backgroundTaskTranscriptedTerminal: Set<string> = new Set(); renderedSkillActivationIds: Set<string> = new Set(); + renderedPluginCommandActivationIds: Set<string> = new Set(); renderedMcpServerStatusKeys: Map<string, string> = new Map(); - hookStatusSpinners: Map<string, ActivityLoader> = new Map(); + mcpServerStatusSpinners: Map<string, MoonLoader> = new Map(); mcpServers: Map<string, McpServerStatusSnapshot> = new Map(); - private mcpServerStatusRow: ActivityLoader | StatusMessageComponent | undefined; - private mcpServerStatusTimer: ReturnType<typeof setTimeout> | undefined; - private mcpServerSnapshotReady = false; - private mcpServerSnapshotEpoch = 0; - private mcpLiveServerNames = new Set<string>(); private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; private currentTurnHasAssistantText = false; + private pluginCommandTurns: Map<string, string> = new Map(); + private pluginMcpToolsUsedInTurn: Set<string> = new Set(); private pendingModelBlockedFallback: GoalChange | undefined; private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType<typeof setTimeout> | undefined; - private readonly liveTokenSpeedByAgent = new Map< - string, - { turnId: number; startedAtMs: number; asciiChars: number; nonAsciiChars: number } - >(); + private stepRetryAttemptTimer: ReturnType<typeof setTimeout> | undefined; resetRuntimeState(): void { - setLiveIntent(undefined); this.backgroundTasks.clear(); this.backgroundTaskTranscriptedTerminal.clear(); this.subAgentEventHandler.resetRuntimeState(); this.renderedSkillActivationIds.clear(); + this.renderedPluginCommandActivationIds.clear(); this.renderedMcpServerStatusKeys.clear(); - this.mcpServerSnapshotReady = false; - this.mcpServerSnapshotEpoch += 1; - this.mcpLiveServerNames.clear(); this.mcpServers.clear(); this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; this.currentTurnHasAssistantText = false; + this.pluginCommandTurns.clear(); + this.pluginMcpToolsUsedInTurn.clear(); this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; - this.liveTokenSpeedByAgent.clear(); this.clearQueuedGoalPromotionTimer(); - this.disposeHookStatusRows(); - this.disposeMcpServerStatusRows(); - // Fast mode is session-scoped; a runtime reset must clear it with the rest. - this.host.setAppState({ - modelCostRates: undefined, - totalCostUsd: undefined, - fastMode: false, - fastModeSupported: false, - }); - this.host.dispatchFooter({ - type: 'status.updated', - changes: { - tokenSpeed: null, - tokenSpeedEstimated: false, - sessionSpendUsd: undefined, - }, - }); - } - - clearDynamicWorkflowMissionControls(): void { - this.subAgentEventHandler.clearDynamicWorkflowMissionControls(); + this.clearStepRetryAttemptTimer(); + this.stopAllMcpServerStatusSpinners(); } - hasDynamicWorkflowMissionControl(toolCallId: string): boolean { - return this.subAgentEventHandler.hasDynamicWorkflowMissionControl(toolCallId); + clearAgentDynamicWorkflowProgress(): void { + this.subAgentEventHandler.clearAgentDynamicWorkflowProgress(); } - hasActiveDynamicWorkflowToolCall(): boolean { - return this.subAgentEventHandler.hasActiveDynamicWorkflowToolCall(); + hasActiveAgentDynamicWorkflowToolCall(): boolean { + return this.subAgentEventHandler.hasActiveAgentDynamicWorkflowToolCall(); } - syncDynamicWorkflowActivitySpinner(spinner: ActivityLoader | undefined): void { - this.subAgentEventHandler.syncDynamicWorkflowActivitySpinner(spinner); + syncAgentDynamicWorkflowActivitySpinner(spinner: MoonLoader | undefined): void { + this.subAgentEventHandler.syncAgentDynamicWorkflowActivitySpinner(spinner); } startSubscription(): void { @@ -243,39 +226,40 @@ export class SessionEventHandler { async syncMcpServerStatusSnapshot(session: Session): Promise<void> { const { host } = this; - const snapshotEpoch = ++this.mcpServerSnapshotEpoch; - this.mcpServerSnapshotReady = false; - this.showMcpServerStatusLoader('MCP servers · loading…', 'primary'); let servers: readonly McpServerStatusSnapshot[]; try { servers = await session.listMcpServers(); } catch (error) { - if (snapshotEpoch !== this.mcpServerSnapshotEpoch) return; if (host.session !== session || host.aborted) return; - this.removeMcpServerStatusRow(); const message = error instanceof Error ? error.message : String(error); host.showError(`Failed to sync MCP server status: ${message}`); return; } - if (snapshotEpoch !== this.mcpServerSnapshotEpoch) return; if (host.session !== session || host.state.appState.sessionId !== session.id) return; - const liveServers = [...this.mcpServers].filter(([name]) => this.mcpLiveServerNames.has(name)); + const visible = selectMcpStartupStatusRows(servers); + const visibleNames = new Set(visible.map((server) => server.name)); + for (const server of visible) { + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; + this.renderMcpServerStatus(server); + } + this.mcpServers.clear(); - for (const [name, server] of liveServers) this.mcpServers.set(name, server); for (const server of servers) { - if (this.mcpLiveServerNames.has(server.name)) continue; this.mcpServers.set(server.name, server); + } + const hidden: McpServerStatusSnapshot[] = []; + for (const server of servers) { + if (visibleNames.has(server.name)) continue; + if (this.renderedMcpServerStatusKeys.has(server.name)) continue; this.renderedMcpServerStatusKeys.set(server.name, mcpServerStatusKey(server)); + hidden.push(server); } - this.mcpServerSnapshotReady = true; - this.syncMcpServerSummary(); - this.renderMcpServerStatusRow(); - void host.refreshSkillCommands(session); + const summary = formatMcpStartupStatusSummary(servers); + host.setAppState({ mcpServersSummary: summary || null }); } handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void { - this.trackTokenSpeed(event); if (this.subAgentEventHandler.routeChildAgentEvent(event)) return; if ('turnId' in event && event.turnId !== undefined) { @@ -288,20 +272,21 @@ export class SessionEventHandler { case 'turn.step.started': this.handleStepBegin(event); break; case 'turn.step.interrupted': this.handleStepInterrupted(event); break; case 'turn.step.completed': this.handleStepCompleted(event); break; - case 'turn.step.retrying': setLiveIntent(undefined); break; + case 'turn.step.retrying': this.handleStepRetrying(event); break; case 'tool.progress': this.handleToolProgress(event); break; + case 'shell.output': this.host.handleShellOutput(event); break; + case 'shell.started': this.host.handleShellStarted(event); break; case 'assistant.delta': this.handleAssistantDelta(event); break; case 'hook.result': this.handleHookResult(event); break; - case 'hook.status': this.handleHookStatus(event); break; case 'thinking.delta': this.handleThinkingDelta(event); break; case 'tool.call.started': this.handleToolCall(event); break; case 'tool.call.delta': this.handleToolCallDelta(event); break; case 'tool.result': this.handleToolResult(event); break; case 'agent.status.updated': this.handleStatusUpdate(event); break; - case 'advisor.status': this.handleAdvisorStatus(event); break; case 'session.meta.updated': this.handleSessionMetaChanged(event); break; case 'goal.updated': this.handleGoalUpdated(event); break; case 'skill.activated': this.handleSkillActivated(event); break; + case 'plugin_command.activated': this.handlePluginCommandActivated(event); break; case 'error': this.handleSessionError(event); break; case 'warning': this.handleSessionWarning(event); break; case 'compaction.started': this.handleCompactionBegin(event); break; @@ -314,8 +299,6 @@ export class SessionEventHandler { case 'subagent.completed': case 'subagent.failed': this.subAgentEventHandler.handleLifecycleEvent(event); break; - case 'workflow.warning': - this.subAgentEventHandler.handleWorkflowWarning(event); break; case 'background.task.started': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; @@ -326,58 +309,24 @@ export class SessionEventHandler { } } - disposeMcpServerStatusRows(): void { - this.removeMcpServerStatusRow(); - } - - private handleHookStatus(event: HookStatusEvent): void { - const { state } = this.host; - const existing = this.hookStatusSpinners.get(event.statusId); - if (!event.active) { - if (existing === undefined) return; - existing.stop(); - state.transcriptContainer.removeChild(existing); - this.hookStatusSpinners.delete(event.statusId); - state.ui.requestRender(); - return; - } - if (existing !== undefined) { - existing.setLabel(event.content); - return; - } - const tint = (text: string): string => currentTheme.fg('textMuted', text); - const spinner = new ActivityLoader(state.ui, tint, event.content); - state.transcriptContainer.addTranscriptChild(spinner, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); - this.hookStatusSpinners.set(event.statusId, spinner); - state.ui.requestRender(); - } - - private disposeHookStatusRows(): void { - for (const spinner of this.hookStatusSpinners.values()) { + stopAllMcpServerStatusSpinners(): void { + for (const spinner of this.mcpServerStatusSpinners.values()) { spinner.stop(); - this.host.state.transcriptContainer.removeChild(spinner); } - this.hookStatusSpinners.clear(); + this.mcpServerStatusSpinners.clear(); } // --------------------------------------------------------------------------- // Private handlers // --------------------------------------------------------------------------- - private handleTurnBegin(_event: TurnStartedEvent): void { - setLiveIntent(undefined); - void _event; + private handleTurnBegin(event: TurnStartedEvent): void { + this.host.handleTurnStarted?.(event); this.currentTurnHasAssistantText = false; - // Throughput belongs to the finished turn; clear it so a stale t/s rate - // never bleeds into the next turn. - this.host.dispatchFooter({ - type: 'status.updated', - changes: { tokenSpeed: null, tokenSpeedEstimated: false }, - }); - this.clearDynamicWorkflowMissionControls(); + if (event.origin?.kind === 'plugin_command') { + this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); + } + this.clearAgentDynamicWorkflowProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ @@ -410,14 +359,21 @@ export class SessionEventHandler { } private handleTurnEnd(event: TurnEndedEvent, sendQueued: (item: QueuedMessage) => void): void { - setLiveIntent(undefined); + this.host.handleTurnEnded?.(event); this.host.streamingUI.flushNow(); - this.host.dispatchFooter({ - type: 'status.updated', - changes: { tokenSpeed: null, tokenSpeedEstimated: false }, - }); + this.clearStepRetry(); if (event.reason === 'cancelled') { - this.markActiveDynamicWorkflowsCancelled(); + this.markActiveAgentDynamicWorkflowsCancelled(); + } + // Aborted foreground subagents emit no completed/failed lifecycle event + // (v2 suppresses it for aborts), so their activity records would linger + // until the session reset — prune them when the owning turn ends. + this.subAgentEventHandler.dropForegroundOnlyActivityRecords(); + if (event.reason === 'failed' && event.error?.code === 'provider.filtered') { + this.host.showStatus('Turn stopped: provider safety policy blocked the response.', 'error'); + } + if (event.reason === 'blocked') { + this.host.showStatus('Turn stopped: prompt hook blocked the request.', 'error'); } const todos = this.host.state.todoPanel.getTodos(); if (todos.length > 0 && todos.every((t) => t.status === 'done')) { @@ -425,14 +381,30 @@ export class SessionEventHandler { } this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeTurn(sendQueued); + this.host.recordSessionActivity(); this.renderPendingModelBlockedFallback(); this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; + // Plugin usage is reported once the whole turn's output has ended — but a + // cancelled turn cut the output short, so skip the notice there. + const reportPluginUsage = event.reason !== 'cancelled'; + const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId)); + if (pluginCommandPluginId !== undefined) { + this.pluginCommandTurns.delete(String(event.turnId)); + if (reportPluginUsage) { + void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId); + } + } + if (reportPluginUsage) { + for (const toolName of this.pluginMcpToolsUsedInTurn) { + void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName); + } + } + this.pluginMcpToolsUsedInTurn.clear(); this.scheduleQueuedGoalPromotion(); } private handleStepBegin(event: TurnStepStartedEvent): void { - setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.streamingUI.setStep(event.step); this.host.streamingUI.resetToolUi(); @@ -449,9 +421,19 @@ export class SessionEventHandler { } private handleStepCompleted(event: TurnStepCompletedEvent): void { - setLiveIntent(undefined); this.host.streamingUI.flushNow(); + this.clearStepRetry(); + this.host.noteStepUsage(event.usage); this.maybeShowDebugTiming(event); + + if (event.providerFinishReason === 'filtered') { + this.host.showNotice( + 'Provider safety policy blocked the response.', + `The model output was filtered (${event.rawFinishReason ?? 'content_filter'}).`, + ); + return; + } + if (event.finishReason !== 'max_tokens') return; const truncatedCount = this.host.streamingUI.markStepTruncated( @@ -469,112 +451,87 @@ export class SessionEventHandler { this.host.showNotice(title, detail); } - private trackTokenSpeed(event: Event): void { - if (this.host.state.appState.isReplaying) return; - if ( - event.type === 'turn.step.started' || - event.type === 'turn.step.retrying' || - event.type === 'turn.step.interrupted' || - event.type === 'turn.ended' - ) { - this.liveTokenSpeedByAgent.delete(event.agentId); - return; - } - if (event.type === 'turn.step.completed') { - this.updateCompletedTokenSpeed(event); - this.liveTokenSpeedByAgent.delete(event.agentId); - return; - } - - if ( - event.type !== 'assistant.delta' && - event.type !== 'thinking.delta' && - event.type !== 'tool.call.delta' - ) return; - const delta = event.type === 'tool.call.delta' ? event.argumentsPart : event.delta; - if (delta === undefined || delta.length === 0) return; - - let asciiChars = 0; - let nonAsciiChars = 0; - for (const char of delta) { - if (char.codePointAt(0)! <= 0x7f) asciiChars += 1; - else nonAsciiChars += 1; - } - - const current = this.liveTokenSpeedByAgent.get(event.agentId); - if (current === undefined || current.turnId !== event.turnId) { - this.liveTokenSpeedByAgent.set(event.agentId, { - turnId: event.turnId, - startedAtMs: Date.now(), - asciiChars, - nonAsciiChars, - }); - return; - } - - current.asciiChars += asciiChars; - current.nonAsciiChars += nonAsciiChars; - const estimatedTokens = Math.ceil(current.asciiChars / 4) + current.nonAsciiChars; - const durationMs = Date.now() - current.startedAtMs; - if (estimatedTokens < 2 || durationMs <= 0) return; - this.host.dispatchFooter({ - type: 'status.updated', - changes: { - tokenSpeed: ((estimatedTokens - 1) * 1_000) / durationMs, - tokenSpeedEstimated: true, + private handleStepRetrying(event: TurnStepRetryingEvent): void { + // The failure may arrive mid-stream, after thinking/assistant deltas have + // parked the pane in `thinking`/`composing` — drive it back to waiting so + // the retry label and detail actually render during the backoff. + this.host.patchLivePane({ mode: 'waiting' }); + this.host.setAppState({ + streamingPhase: 'waiting', + stepRetry: { + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + phase: 'backoff', }, }); + // Both engines sleep for `delayMs` before the next attempt runs, but only + // v2 re-emits `turn.step.started` for it — flip the phase on a timer so the + // stale countdown drops on the legacy engine too. + this.clearStepRetryAttemptTimer(); + this.stepRetryAttemptTimer = setTimeout(() => { + this.stepRetryAttemptTimer = undefined; + const retry = this.host.state.appState.stepRetry; + if (retry === null) return; + this.host.setAppState({ stepRetry: { ...retry, phase: 'attempt' } }); + }, event.delayMs); } - private updateCompletedTokenSpeed(event: TurnStepCompletedEvent): void { - const outputTokens = event.usage?.output; - const durationMs = event.llmStreamDurationMs; - if ( - outputTokens === undefined || - durationMs === undefined || - !Number.isFinite(outputTokens) || - !Number.isFinite(durationMs) || - outputTokens < 2 || - durationMs <= 0 - ) { - return; + private clearStepRetry(): void { + this.clearStepRetryAttemptTimer(); + if (this.host.state.appState.stepRetry === null) return; + this.host.setAppState({ stepRetry: null }); + } + + clearStepRetryAttemptTimer(): void { + if (this.stepRetryAttemptTimer !== undefined) { + clearTimeout(this.stepRetryAttemptTimer); + this.stepRetryAttemptTimer = undefined; } - this.host.dispatchFooter({ - type: 'status.updated', - changes: { - tokenSpeed: ((outputTokens - 1) * 1_000) / durationMs, - tokenSpeedEstimated: false, - }, - }); } private maybeShowDebugTiming(event: TurnStepCompletedEvent): void { if (process.env['PYTHINKER_CODE_DEBUG'] !== '1') return; const text = formatStepDebugTiming(event); - if (text !== undefined) this.host.showStatus(text); + if (text === undefined) return; + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'status', + turnId: String(event.turnId), + renderMode: 'plain', + content: text, + }); } - private markActiveDynamicWorkflowsCancelled(): void { - this.subAgentEventHandler.markActiveDynamicWorkflowsCancelled(); + private markActiveAgentDynamicWorkflowsCancelled(): void { + this.subAgentEventHandler.markActiveAgentDynamicWorkflowsCancelled(); } private isAnthropicSessionActive(): boolean { const { state } = this.host; - const providerKey = state.appState.availableModels[state.appState.model]?.provider; - if (providerKey === undefined) return false; - return state.appState.availableProviders[providerKey]?.type === 'anthropic'; + const model = state.appState.availableModels[state.appState.model]; + if (model === undefined) return false; + if (model.protocol === 'anthropic') return true; + return state.appState.availableProviders[model.provider]?.type === 'anthropic'; } private handleStepInterrupted(event: TurnStepInterruptedEvent): void { - setLiveIntent(undefined); this.host.streamingUI.flushNow(); + this.clearStepRetry(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); const reason = event.reason; if (reason === 'error') return; if (reason === 'aborted' || reason === undefined || reason === '') { - this.markActiveDynamicWorkflowsCancelled(); - this.host.showStatus('Interrupted by user', 'error'); + this.markActiveAgentDynamicWorkflowsCancelled(); + if (event.message === undefined || event.message === '') { + this.host.showStatus('Interrupted by user', 'error'); + } else { + this.host.showError(event.message); + } return; } this.host.showError( @@ -586,6 +543,14 @@ export class SessionEventHandler { private handleThinkingDelta(event: ThinkingDeltaEvent): void { const { state, streamingUI } = this.host; + // Encrypted / redacted reasoning (e.g. Pythinker over the Anthropic-compatible + // protocol) streams thinking deltas whose visible text is empty — only an + // opaque signature rides along. Models also occasionally stream whitespace- + // only thinking (e.g. a single space). Such deltas carry nothing to render, + // so switching into the `thinking` pane mode here would stop the "waiting" + // moon spinner while no ThinkingComponent is ever created (it needs visible + // text), leaving a blank, spinner-less gap until the first real text/tool + // token arrives. Keep the moon up until actual thinking text shows up. if (event.delta.trim().length === 0 && !streamingUI.hasThinkingDraft()) return; streamingUI.appendThinkingDelta(event.delta); this.host.patchLivePane({ mode: 'idle' }); @@ -634,6 +599,7 @@ export class SessionEventHandler { turnId: String(event.turnId), renderMode: 'markdown', content: formatHookResultMarkdown(event), + hookResult: true, }); this.host.patchLivePane({ mode: 'idle', @@ -643,15 +609,6 @@ export class SessionEventHandler { } private handleToolCall(event: ToolCallStartedEvent): void { - // A retired Dynamic Workflow tool call (undo / turn cleanup) must not - // remount streaming UI when the model replays it late. - if ( - event.name === 'DynamicWorkflow' && - this.subAgentEventHandler.isRetiredDynamicWorkflowToolCall(event.toolCallId) - ) { - return; - } - setLiveIntent(event.intent); const { streamingUI } = this.host; streamingUI.flushNow(); const { turnId, step } = streamingUI.getTurnContext(); @@ -665,8 +622,8 @@ export class SessionEventHandler { turnId, }; streamingUI.registerToolCall(toolCall); - if (event.name === 'DynamicWorkflow') { - this.subAgentEventHandler.handleDynamicWorkflowToolCallStarted(event.toolCallId, toolCall.args); + if (event.name === 'AgentDynamicWorkflow') { + this.subAgentEventHandler.handleAgentDynamicWorkflowToolCallStarted(event.toolCallId, toolCall.args); } this.host.patchLivePane({ mode: 'tool', @@ -676,23 +633,15 @@ export class SessionEventHandler { } private handleToolCallDelta(event: ToolCallDeltaEvent): void { - if ( - event.toolCallId.length === 0 || - // Late deltas for a retired workflow would re-create its mission control. - this.subAgentEventHandler.isRetiredDynamicWorkflowToolCall(event.toolCallId) - ) { - return; - } + if (event.toolCallId.length === 0) return; const { state, streamingUI } = this.host; streamingUI.accumulateToolCallDelta(event.toolCallId, event.name, event.argumentsPart); const preview = streamingUI.getStreamingToolCallPreview(event.toolCallId); - const intent = preview?.args['i']; - setLiveIntent(typeof intent === 'string' ? intent : undefined); if ( preview !== undefined && - preview.name === 'DynamicWorkflow' + (preview.name === 'AgentDynamicWorkflow' || this.subAgentEventHandler.hasAgentDynamicWorkflowProgress(event.toolCallId)) ) { - this.subAgentEventHandler.handleDynamicWorkflowToolCallDelta(event.toolCallId, preview.args, { + this.subAgentEventHandler.handleAgentDynamicWorkflowToolCallDelta(event.toolCallId, preview.args, { streamingArguments: preview.argumentsText, }); } @@ -723,9 +672,9 @@ export class SessionEventHandler { } private handleToolResult(event: ToolResultEvent): void { - setLiveIntent(undefined); const { streamingUI } = this.host; streamingUI.flushNow(); + this.clearStepRetry(); const resultData: ToolResultBlockData = { tool_call_id: event.toolCallId, output: serializeToolResultOutput(event.output), @@ -733,17 +682,25 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); - if (matchedCall?.name === 'DynamicWorkflow') { - this.subAgentEventHandler.handleDynamicWorkflowToolResult( - event.toolCallId, - resultData, - event.isError === true, - ); + if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) { + // Buffer plugin MCP usage for the turn; the update notice fires once the + // whole turn's output has ended (see handleTurnEnd). + this.pluginMcpToolsUsedInTurn.add(matchedCall.name); } + this.subAgentEventHandler.handleAgentDynamicWorkflowToolResult( + event.toolCallId, + resultData, + event.isError === true, + ); if (matchedCall !== undefined && matchedCall.name === 'TodoList' && !event.isError) { const rawTodos = (matchedCall.args as { todos?: unknown }).todos; if (Array.isArray(rawTodos)) { - streamingUI.setTodoList(normalizeTodoList(rawTodos)); + const sanitized = rawTodos + .filter((todo): todo is { title: string; status: 'pending' | 'in_progress' | 'done' } => + isTodoItemShape(todo), + ) + .map((t) => ({ title: t.title, status: t.status })); + streamingUI.setTodoList(sanitized); } } this.host.patchLivePane({ mode: 'waiting' }); @@ -760,31 +717,12 @@ export class SessionEventHandler { if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; if (event.planMode !== undefined) patch.planMode = event.planMode; if (event.dynamicWorkflowMode !== undefined) patch.dynamicWorkflowMode = event.dynamicWorkflowMode; - if (event.fastMode !== undefined) patch.fastMode = event.fastMode; - if (event.fastModeSupported !== undefined) patch.fastModeSupported = event.fastModeSupported; if (event.permission !== undefined) { patch.permissionMode = event.permission; } - if (event.model !== undefined) { - patch.model = event.model; - patch.modelCostRates = event.modelCostRates; - // A model switch invalidates Fast mode support unless the status event - // carried explicit fast-mode fields for the new model. - if (event.fastMode === undefined) patch.fastMode = false; - if (event.fastModeSupported === undefined) patch.fastModeSupported = false; - } else if (event.modelCostRates !== undefined) { - patch.modelCostRates = event.modelCostRates; - } - if (event.usage !== undefined) patch.totalCostUsd = event.usage.totalCostUsd; + if (event.model !== undefined) patch.model = event.model; + if (event.thinkingEffort !== undefined) patch.thinkingEffort = event.thinkingEffort; if (Object.keys(patch).length > 0) this.host.setAppState(patch); - if (event.usage !== undefined) { - this.host.dispatchFooter({ - type: 'status.updated', - changes: { - sessionSpendUsd: event.usage.totalCostUsd, - }, - }); - } if (event.dynamicWorkflowMode === false) { this.host.state.dynamicWorkflowModeEntry = undefined; if (shouldRenderDynamicWorkflowEnded) { @@ -794,9 +732,8 @@ export class SessionEventHandler { } private renderDynamicWorkflowModeMarker(state: DynamicWorkflowModeMarkerState): void { - this.host.state.transcriptContainer.addTranscriptChild( + this.host.state.transcriptContainer.addChild( new DynamicWorkflowModeMarkerComponent(state), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, ); this.host.state.ui.requestRender(); } @@ -849,10 +786,7 @@ export class SessionEventHandler { } const marker = buildGoalMarker(change, state.toolOutputExpanded, change.actor); if (marker !== null) { - state.transcriptContainer.addTranscriptChild(marker, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + state.transcriptContainer.addChild(marker); state.ui.requestRender(); } } @@ -864,10 +798,7 @@ export class SessionEventHandler { const { state } = this.host; const marker = buildGoalMarker(change, state.toolOutputExpanded, 'model'); if (marker !== null) { - state.transcriptContainer.addTranscriptChild(marker, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + state.transcriptContainer.addChild(marker); state.ui.requestRender(); } } @@ -921,7 +852,8 @@ export class SessionEventHandler { (session === undefined || this.host.session === session) && !this.host.aborted && this.host.state.appState.streamingPhase === 'idle' && - this.host.state.queuedMessages.length === 0 + this.host.state.queuedMessages.length === 0 && + !this.host.state.queuedMessageDispatchPending ); } @@ -1026,11 +958,9 @@ export class SessionEventHandler { } private handleSessionError(event: ErrorEvent): void { - setLiveIntent(undefined); this.host.streamingUI.flushNow(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.finalizeLiveTextBuffers('idle'); - this.clearDynamicWorkflowMissionControls(); if (event.code === OAUTH_LOGIN_REQUIRED_CODE) { this.host.showError(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); return; @@ -1045,110 +975,87 @@ export class SessionEventHandler { private handleSessionWarning(event: WarningEvent): void { this.host.showStatus(`Warning: ${event.message}`, 'warning'); } - private handleAdvisorStatus(event: AdvisorStatusEvent): void { - const color: ColorToken = - event.status === 'error' || event.status === 'quota_exhausted' - ? 'error' - : event.status === 'running' - ? 'success' - : 'warning'; - const message = event.message === undefined ? '' : ` · ${event.message}`; - this.host.showStatus(`Advisor ${event.name}: ${event.status}${message}`, color); - } private renderMcpServerStatus(server: McpServerStatusSnapshot): void { const key = mcpServerStatusKey(server); if (this.renderedMcpServerStatusKeys.get(server.name) === key) return; this.renderedMcpServerStatusKeys.set(server.name, key); - this.mcpLiveServerNames.add(server.name); this.mcpServers.set(server.name, server); - void this.host.refreshSkillCommands(this.host.session); - if (!this.mcpServerSnapshotReady) return; - this.syncMcpServerSummary(); - this.renderMcpServerStatusRow(); - } + const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); + this.host.setAppState({ mcpServersSummary: summary || null }); - private showMcpServerStatusLoader(label: string, color: ColorToken): void { - const existing = this.mcpServerStatusRow; - const tint = (text: string): string => currentTheme.fg(color, text); - if (existing instanceof ActivityLoader) { - existing.setColorFn(tint); - existing.setLabel(label); - return; + switch (server.status) { + case 'connected': { + const toolStr = `${server.toolCount} tool${server.toolCount === 1 ? '' : 's'}`; + const message = `MCP server "${server.name}" connected · ${toolStr} (${server.transport})`; + this.finalizeMcpServerStatusRow(server.name, message, 'success'); + return; + } + case 'failed': { + const message = `MCP server "${server.name}" failed${server.error !== undefined ? `: ${server.error}` : ''}`; + this.finalizeMcpServerStatusRow(server.name, message, 'error'); + return; + } + case 'needs-auth': { + const message = `MCP server "${server.name}" needs OAuth — run /mcp-config login ${server.name}`; + this.finalizeMcpServerStatusRow(server.name, message, 'warning'); + return; + } + case 'disabled': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" disabled`, + 'textMuted', + ); + return; + case 'removed': + this.finalizeMcpServerStatusRow( + server.name, + `MCP server "${server.name}" removed`, + 'textMuted', + ); + return; + case 'pending': + this.showMcpServerStatusSpinner(server.name); + return; } - this.replaceMcpServerStatusRow(new ActivityLoader(this.host.state.ui, tint, label)); } - private renderMcpServerStatusRow(): void { - if (!this.mcpServerSnapshotReady) return; - const line = buildMcpStartupStatusLine([...this.mcpServers.values()]); - if (line === null) { - this.removeMcpServerStatusRow(); - return; - } - if (line.loading) { - this.showMcpServerStatusLoader(line.label, line.color); - return; - } - - const mark = line.color === 'success' - ? SUCCESS_MARK - : line.color === 'error' - ? FAILURE_MARK - : STATUS_BULLET; - const status = new StatusMessageComponent(`${mark}${line.label}`, line.color); - this.replaceMcpServerStatusRow(status); - if (!line.transient) return; - if (!mcpStatusAnimationEnabled()) { - this.removeMcpServerStatusRow(); + private showMcpServerStatusSpinner(name: string): void { + const { state } = this.host; + const label = `MCP server "${name}" connecting…`; + const existing = this.mcpServerStatusSpinners.get(name); + if (existing !== undefined) { + existing.setLabel(label); return; } - - const timer = setTimeout(() => { - if (this.mcpServerStatusRow !== status) return; - this.removeMcpServerStatusRow(); - }, MCP_STATUS_TRANSIENT_DURATION_MS); - timer.unref?.(); - this.mcpServerStatusTimer = timer; + const tint = (s: string): string => currentTheme.fg('textMuted', s); + const spinner = new MoonLoader(state.ui, 'braille', tint, label); + state.transcriptContainer.addChild(spinner); + this.mcpServerStatusSpinners.set(name, spinner); + state.ui.requestRender(); } - private replaceMcpServerStatusRow( - component: ActivityLoader | StatusMessageComponent, - ): void { - const previous = this.mcpServerStatusRow; - if (this.mcpServerStatusTimer !== undefined) { - clearTimeout(this.mcpServerStatusTimer); - this.mcpServerStatusTimer = undefined; + private finalizeMcpServerStatusRow(name: string, message: string, color: ColorToken): void { + const { state } = this.host; + const spinner = this.mcpServerStatusSpinners.get(name); + if (spinner === undefined) { + this.host.showStatus(message, color); + return; } - if (previous instanceof ActivityLoader) previous.stop(); - - const children = this.host.state.mcpStatusContainer.children; - const index = previous === undefined ? -1 : children.indexOf(previous); - if (index >= 0) { - children[index] = component; + spinner.stop(); + const status = new StatusMessageComponent(message, color); + const children = state.transcriptContainer.children; + const idx = children.indexOf(spinner); + if (idx >= 0) { + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = status; } else { - this.host.state.mcpStatusContainer.addChild(component); - } - this.mcpServerStatusRow = component; - this.host.state.ui.requestRender(); - } - - private removeMcpServerStatusRow(): void { - if (this.mcpServerStatusTimer !== undefined) { - clearTimeout(this.mcpServerStatusTimer); - this.mcpServerStatusTimer = undefined; + state.transcriptContainer.addChild(status); } - const row = this.mcpServerStatusRow; - if (row === undefined) return; - if (row instanceof ActivityLoader) row.stop(); - this.host.state.mcpStatusContainer.removeChild(row); - this.mcpServerStatusRow = undefined; - this.host.state.ui.requestRender(); - } - - private syncMcpServerSummary(): void { - const summary = formatMcpStartupStatusSummary([...this.mcpServers.values()]); - this.host.setAppState({ mcpServersSummary: summary || null }); + this.mcpServerStatusSpinners.delete(name); + state.ui.requestRender(); } private handleSkillActivated(event: SkillActivatedEvent): void { @@ -1157,7 +1064,6 @@ export class SessionEventHandler { this.host.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'skill_activation', - checkpointId: event.checkpointId, turnId: undefined, renderMode: 'plain', content: `Activated skill: ${event.skillName}`, @@ -1168,6 +1074,25 @@ export class SessionEventHandler { }); } + private handlePluginCommandActivated(event: PluginCommandActivatedEvent): void { + if (this.renderedPluginCommandActivationIds.has(event.activationId)) return; + this.renderedPluginCommandActivationIds.add(event.activationId); + this.host.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'plugin_command', + turnId: undefined, + renderMode: 'plain', + content: `/${event.pluginId}:${event.commandName}`, + pluginCommandData: { + activationId: event.activationId, + pluginId: event.pluginId, + commandName: event.commandName, + args: event.commandArgs, + trigger: event.trigger, + }, + }); + } + private handleCompactionBegin(event: CompactionStartedEvent): void { this.host.streamingUI.finalizeLiveTextBuffers('waiting'); this.host.setAppState({ @@ -1187,6 +1112,12 @@ export class SessionEventHandler { event.result.tokensAfter, event.result.summary, ); + // A completed compaction just refreshed and shrank the cached context — + // count it as activity so the next submit isn't judged against the + // pre-compaction timestamp, and reset the cache-break baseline (the drop + // is expected). Cancellations do neither: the context was not cut. + this.host.recordSessionActivity(); + this.host.noteCompactionFinished(); this.finishCompaction(sendQueued); } @@ -1201,14 +1132,18 @@ export class SessionEventHandler { private finishCompaction(sendQueued: (item: QueuedMessage) => void): void { const hasActiveTurn = this.host.streamingUI.hasActiveTurn(); if (!hasActiveTurn) { + const next = this.host.shiftQueuedMessage(); + if (next !== undefined) { + this.host.state.queuedMessageDispatchPending = true; + } this.host.setAppState({ isCompacting: false, streamingPhase: 'idle', }); this.host.resetLivePane(); - const next = this.host.shiftQueuedMessage(); if (next !== undefined) { setTimeout(() => { + this.host.state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); } @@ -1243,6 +1178,9 @@ export class SessionEventHandler { if (event.type === 'background.task.started') { if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; @@ -1264,6 +1202,21 @@ export class SessionEventHandler { description: info.description, status: info.status, }); + // Stopped / timed-out agents terminate without a `subagent.failed` + // event — mark the activity record here so the detail view does not + // stay "running" forever. `subagent.completed` carries the result + // summary and may land after this, so only fill still-running records. + const agentId = info.agentId; + if (agentId !== undefined) { + const record = this.subAgentEventHandler.activityStore.get(agentId); + if (record !== undefined && record.status === 'running') { + if (info.status === 'completed') { + this.subAgentEventHandler.activityStore.markCompleted(agentId); + } else { + this.subAgentEventHandler.activityStore.markFailed(agentId); + } + } + } } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { if (info.kind === 'process' || info.kind === 'question') { @@ -1316,10 +1269,7 @@ export class SessionEventHandler { bashTasks += 1; } } - this.host.dispatchFooter({ - type: 'background-counts.updated', - counts: { bashTasks, agentTasks }, - }); + state.footer.setBackgroundCounts({ bashTasks, agentTasks }); state.ui.requestRender(); } } diff --git a/apps/pythinker-code/src/tui/controllers/session-replay.ts b/apps/pythinker-code/src/tui/controllers/session-replay.ts index 0262362f..dd508140 100644 --- a/apps/pythinker-code/src/tui/controllers/session-replay.ts +++ b/apps/pythinker-code/src/tui/controllers/session-replay.ts @@ -3,26 +3,32 @@ import type { ContextMessage, GoalChange, PermissionMode, - PromptOrigin, ResumedAgentState, Session, ToolCall, } from '@pymodel/pythinker-code-sdk'; import { ToolCallComponent } from '../components/messages/tool-call'; +import { ReplayTurnBoundaryComponent } from '../components/messages/user-message'; +import { currentTheme } from '../theme'; +import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, BackgroundAgentMetadata, ToolResultBlockData, TranscriptEntry, } from '../types'; -import { formatErrorMessage, normalizeTodoList } from '../utils/event-payload'; +import { formatErrorMessage, isTodoItemShape } from '../utils/event-payload'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { buildGoalCompletionMessage } from '../utils/goal-completion'; +import { formatBashOutputForDisplay } from '../utils/shell-output'; +import { markTranscriptComponent } from '../utils/transcript-component-metadata'; import { appStateFromResumeAgent, backgroundOrigin, + bundledSkillsFromOrigin, collectReplayMessageContent, contentPartsToText, countActiveBackgroundTasks, @@ -34,15 +40,18 @@ import { replayBackgroundProjection, replayEntry, skillActivationFromOrigin, + stripBundledSkillParts, + pluginCommandFromOrigin, toolCallFromReplayMessage, toolResultOutput, + type BackgroundTaskNotificationOrigin, type ReplayRenderContext, type SkillActivationProjection, + type PluginCommandProjection, } from '../utils/message-replay'; import type { StreamingUIController } from './streaming-ui'; import type { SessionEventHandler } from './session-event-handler'; import type { TUIState } from '../tui-state'; -import type { FooterEvent } from '../runtime/footer/footer-model'; type GoalReplayRecord = Extract<AgentReplayRecord, { type: 'goal_updated' }>; type CompactionReplayRecord = Extract<AgentReplayRecord, { type: 'compaction' }>; @@ -53,9 +62,52 @@ export interface SessionReplayHost { readonly streamingUI: StreamingUIController; readonly sessionEventHandler: SessionEventHandler; setAppState(patch: Partial<AppState>): void; - dispatchFooter(event: FooterEvent): void; showError(msg: string): void; appendTranscriptEntry(entry: TranscriptEntry): void; + mergeAllTurnSteps(): void; +} + +function extractBashTag( + text: string, + tag: 'bash-input' | 'bash-stdout' | 'bash-stderr', +): string | undefined { + const match = new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`).exec(text); + return match?.[1] === undefined ? undefined : unescapeBashXml(match[1]); +} + +function unescapeBashXml(text: string): string { + return text + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll('&', '&'); +} + +/** + * Replay records within the turn limit, but never cut between a bundled + * prompt and the hook results recorded immediately before it: when the + * limiter's first retained record is a bundled prompt, the consecutive + * preceding hook results are pulled back into the window so the oldest + * visible bundle keeps its hook context. + */ +function preserveBundleHookResults( + replay: readonly AgentReplayRecord[], + maxTurns: number, +): readonly AgentReplayRecord[] { + const limited = limitReplayRecordsByTurn(replay, maxTurns); + const first = limited[0]; + if (first?.type !== 'message' || bundledSkillsFromOrigin(first.message.origin).length === 0) { + return limited; + } + const firstIndex = replay.indexOf(first); + if (firstIndex < 0) return limited; + let start = firstIndex; + for (;;) { + const candidate = replay[start - 1]; + if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') break; + start -= 1; + } + return start === firstIndex ? limited : [...replay.slice(start, firstIndex), ...limited]; } export class SessionReplayRenderer { @@ -73,6 +125,7 @@ export class SessionReplayRenderer { this.hydrateSnapshot(main); this.renderRecords(main); this.applyTerminalBackgroundAgentStatuses(main); + this.host.mergeAllTurnSteps(); return true; } catch (error) { const message = formatErrorMessage(error); @@ -100,7 +153,15 @@ export class SessionReplayRenderer { return; } - this.host.streamingUI.setTodoList(normalizeTodoList(rawTodos)); + const todos = rawTodos + .filter((todo): todo is TodoItem => isTodoItemShape(todo)) + .map((todo) => ({ title: todo.title, status: todo.status })); + if (todos.length > 0 && todos.every((todo) => todo.status === 'done')) { + this.host.streamingUI.setTodoList([]); + return; + } + + this.host.streamingUI.setTodoList(todos); } /** @@ -136,8 +197,8 @@ export class SessionReplayRenderer { private hydrateBackgroundState(agent: ResumedAgentState): void { const { state, sessionEventHandler } = this.host; - const projection = replayBackgroundProjection(agent.background); - sessionEventHandler.subAgentEventHandler.hydrateBackgroundAgentMetadata( + const projection = replayBackgroundProjection(agent.background, state.appState.availableModels); + sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata = new Map( projection.backgroundAgentMetadata, ); sessionEventHandler.backgroundTasks.clear(); @@ -150,10 +211,7 @@ export class SessionReplayRenderer { sessionEventHandler.backgroundTaskTranscriptedTerminal.add(info.taskId); } } - this.host.dispatchFooter({ - type: 'background-counts.updated', - counts: countActiveBackgroundTasks(sessionEventHandler.backgroundTasks), - }); + state.footer.setBackgroundCounts(countActiveBackgroundTasks(sessionEventHandler.backgroundTasks)); state.ui.requestRender(); } @@ -163,13 +221,48 @@ export class SessionReplayRenderer { private renderRecords(agent: ResumedAgentState): void { const context = createReplayRenderContext(); - for (const record of limitReplayRecordsByTurn(agent.replay, REPLAY_TURN_LIMIT)) { - this.renderRecord(context, record); + const records = [...preserveBundleHookResults(agent.replay, REPLAY_TURN_LIMIT)]; + for (let i = 0; i < records.length; i++) { + i = this.renderRecordWithBundleLookahead(context, records, i); } this.flushAssistant(context); this.cleanupRuntime(context); } + private renderRecordWithBundleLookahead( + context: ReplayRenderContext, + records: readonly AgentReplayRecord[], + index: number, + ): number { + const record = records[index]!; + // Hook results recorded ahead of a bundled prompt are projected inside + // the bundle's window — after its skill cards, before the prompt — + // matching the live event order instead of attaching them to the + // previous turn. + if (record.type === 'message' && record.message.origin?.kind === 'hook_result') { + let end = index; + for (;;) { + const candidate = records[end + 1]; + if (candidate?.type !== 'message' || candidate.message.origin?.kind !== 'hook_result') { + break; + } + end += 1; + } + const next = records[end + 1]; + if (next?.type === 'message' && bundledSkillsFromOrigin(next.message.origin).length > 0) { + const hookResults: ContextMessage[] = []; + for (let j = index; j <= end; j++) { + const hookRecord = records[j]!; + if (hookRecord.type === 'message') hookResults.push(hookRecord.message); + } + this.renderBundledPrompt(context, next.message, hookResults); + return end + 1; + } + } + this.renderRecord(context, record); + return index; + } + private renderRecord(context: ReplayRenderContext, record: AgentReplayRecord): void { switch (record.type) { case 'message': @@ -245,6 +338,28 @@ export class SessionReplayRenderer { if (message.origin?.kind === 'injection') { return; } + if (message.origin?.kind === 'shell_command') { + // A `!` command, replayed from records. Unwrap the XML tags back into the + // same `$ cmd` + output view the live editor produced. (Must NOT fall into + // the `injection` branch above — that returns without rendering.) + this.flushAssistant(context); + const text = contentPartsToText(message.content); + if (message.origin.phase === 'input') { + const cmd = (extractBashTag(text, 'bash-input') ?? text).trim(); + this.advanceTurn(context); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', currentTheme.fg('shellMode', `$ ${cmd}`), 'plain', { + bullet: '', + }), + ); + } else { + const stdout = (extractBashTag(text, 'bash-stdout') ?? '').trim(); + const stderr = (extractBashTag(text, 'bash-stderr') ?? '').trim(); + const out = formatBashOutputForDisplay(stdout, stderr, message.origin.isError); + this.host.appendTranscriptEntry(replayEntry(context, 'status', out, 'plain')); + } + return; + } if (message.origin?.kind === 'cron_job') { this.renderCronJob(context, message); return; @@ -253,12 +368,19 @@ export class SessionReplayRenderer { this.renderCronMissed(context, message); return; } - // System-trigger messages are model-facing only. The live event stream and - // markdown exporter hide them, so resume replay must do the same. + // System-trigger messages (goal continuation prompts, goal outcome + // reminders, stop-hook reasons, …) are model-facing only: the live event + // stream never renders them, so replay must not leak them either. if (message.origin?.kind === 'system_trigger') { if (message.origin.name === 'goal_continuation') { - this.flushAssistant(context); + // The goal driver's synthetic "continue" prompt starts a new replay + // turn even though nothing visible is mounted: advance the turn and + // mark an invisible boundary so each goal round groups under its own + // turn and step/assistant folding can find the turn edges. this.advanceTurn(context); + const boundary = new ReplayTurnBoundaryComponent(); + markTranscriptComponent(boundary, replayEntry(context, 'user', '', 'plain')); + this.host.state.transcriptContainer.addChild(boundary); } return; } @@ -272,12 +394,47 @@ export class SessionReplayRenderer { } return; } + const pluginCommand = pluginCommandFromOrigin(message.origin); + if (pluginCommand !== undefined) { + this.renderPluginCommand(context, pluginCommand); + if (message.origin?.kind === 'plugin_command' && message.origin.trigger === 'user-slash') { + this.advanceTurn(context); + } + return; + } + if (bundledSkillsFromOrigin(message.origin).length > 0) { + this.renderBundledPrompt(context, message); + return; + } this.advanceTurn(context); - this.host.appendTranscriptEntry({ - ...replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), - checkpointId: message.origin?.kind === 'user' ? message.origin.checkpointId : undefined, - }); + this.host.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(message.content), 'plain'), + ); + } + + private renderBundledPrompt( + context: ReplayRenderContext, + message: ContextMessage, + hookResults: readonly ContextMessage[] = [], + ): void { + // The bundle is one message: advance once, rebuild the per-skill cards + // from the prompt origin, then show the caller's own parts (the engine + // prepends one rendered text part per bundled skill to the content). + this.advanceTurn(context); + this.renderBundledSkillCards(context, message); + for (const hookResult of hookResults) { + this.renderHookResult(context, hookResult); + } + this.host.appendTranscriptEntry( + replayEntry(context, 'user', contentPartsToText(stripBundledSkillParts(message)), 'plain'), + ); + } + + private renderBundledSkillCards(context: ReplayRenderContext, message: ContextMessage): void { + for (const skill of bundledSkillsFromOrigin(message.origin)) { + this.renderSkillActivation(context, skill); + } } private renderToolCalls(context: ReplayRenderContext, toolCalls: readonly ToolCall[]): void { @@ -363,11 +520,37 @@ export class SessionReplayRenderer { sessionEventHandler.renderedSkillActivationIds.add(skill.activationId); this.host.appendTranscriptEntry({ ...replayEntry(context, 'skill_activation', `Activated skill: ${skill.skillName}`, 'plain'), - checkpointId: skill.checkpointId, skillActivationId: skill.activationId, skillName: skill.skillName, skillArgs: skill.skillArgs, skillTrigger: skill.trigger, + bundledWithPrompt: skill.bundled === true ? true : undefined, + }); + } + + private renderPluginCommand( + context: ReplayRenderContext, + command: PluginCommandProjection, + ): void { + const { sessionEventHandler } = this.host; + if (context.pluginCommandActivationIds.has(command.activationId)) return; + if (sessionEventHandler.renderedPluginCommandActivationIds.has(command.activationId)) return; + context.pluginCommandActivationIds.add(command.activationId); + sessionEventHandler.renderedPluginCommandActivationIds.add(command.activationId); + this.host.appendTranscriptEntry({ + ...replayEntry( + context, + 'plugin_command', + `/${command.pluginId}:${command.commandName}`, + 'plain', + ), + pluginCommandData: { + activationId: command.activationId, + pluginId: command.pluginId, + commandName: command.commandName, + args: command.commandArgs, + trigger: command.trigger, + }, }); } @@ -388,6 +571,7 @@ export class SessionReplayRenderer { this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', 'Compaction complete', 'plain'), compactionData: { + summary: record.result.summary, tokensBefore: record.result.tokensBefore, tokensAfter: record.result.tokensAfter, instruction: record.instruction, @@ -435,8 +619,8 @@ export class SessionReplayRenderer { private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { if (message.origin?.kind !== 'hook_result') return; this.flushAssistant(context); - this.host.appendTranscriptEntry( - replayEntry( + this.host.appendTranscriptEntry({ + ...replayEntry( context, 'assistant', formatHookResultMessageForTranscript( @@ -446,7 +630,8 @@ export class SessionReplayRenderer { ), 'markdown', ), - ); + hookResult: true, + }); } private renderCronJob(context: ReplayRenderContext, message: ContextMessage): void { @@ -569,13 +754,15 @@ export class SessionReplayRenderer { (child) => child instanceof ToolCallComponent && child.toolCallView.id === toolCallId, ); if (childIndex >= 0) { + // Structural removal only: the container's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(childIndex, 1); } } private renderBackgroundTaskNotification( context: ReplayRenderContext, - origin: Extract<PromptOrigin, { kind: 'background_task' }>, + origin: BackgroundTaskNotificationOrigin, ): void { const { sessionEventHandler } = this.host; const task = sessionEventHandler.backgroundTasks.get(origin.taskId); @@ -594,6 +781,19 @@ export class SessionReplayRenderer { agentId: origin.taskId, parentToolCallId: origin.taskId, description: task?.description, + model: + task?.model === undefined + ? undefined + : modelDisplayName( + task.model, + this.host.state.appState.availableModels[task.model], + ), + effort: + task?.thinkingEffort === undefined || + task.thinkingEffort === 'off' || + task.thinkingEffort === 'on' + ? undefined + : task.thinkingEffort, }; let status = formatBackgroundAgentTranscript( origin.status === 'completed' ? 'completed' : 'failed', diff --git a/apps/pythinker-code/src/tui/controllers/staging-leases.ts b/apps/pythinker-code/src/tui/controllers/staging-leases.ts new file mode 100644 index 00000000..c6967653 --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/staging-leases.ts @@ -0,0 +1,303 @@ +/** + * `StagingLeaseTracker` — owns the lifecycle of staged prompt media (daemon + * uploads + local cache copies) between submission and the session that + * consumes it. + * + * A paste/upload edge stages media before the prompt exists. The two staged + * forms age differently once the consuming turn ends: + * + * - Daemon uploads become garbage — the engine materialized its own session + * copy at intake — so the turn-end release deletes them. + * - Local cache copies may still be referenced by persisted history: a v1 + * video degrade writes its `<video path="…">` tag with the cache path, and + * skill/plugin args carry the path as plain text; neither form is rewritten + * to the session media dir. Turn-end release therefore retires cache copies + * to a session-lifetime bucket, deleted at session close / shutdown. + * + * Media that never gets consumed (validation/render failure, queue discard, + * a dispatch RPC that failed before any turn claimed the lease) is deleted + * immediately, whatever form it takes. + * + * A submission diverted before dispatch hands its lease back via `defer`: + * the media stays staged under raw (ids, paths) ownership — a queued message + * re-leases at dequeue dispatch, and the cache-hint stash's restore/resend + * exits release it through `releaseRecalled` / a fresh lease. + * + * The tracker holds one lease per submission, binds it to the consuming turn + * (explicitly at dispatch, by exact submission id when the turn echoes the + * client-chosen prompt id, or heuristically when a matching-origin turn + * starts), and releases it when that turn ends. The heuristic claims the + * earliest unclaimed lease of the same origin; that is only sound because the + * TUI serializes same-origin dispatches (one in-flight submission at a time, + * see `beginSessionRequest`) and `turn.started` arrives in dispatch order. + * + * Exact binding: a lease created with a `submissionId` is registered in + * `leasesBySubmissionId`, and the submission sends that id as the prompt id; + * the consuming turn's `turn.started` echoes it as `promptId`, so + * `handleTurnStarted` binds the exact lease instead of guessing. The + * heuristic below remains the fallback for submissions without an id echo. + * + * INVARIANT: at most one unclaimed lease per origin at any moment — with two + * or more, the heuristic cannot tell which submission the turn belongs to. + * `handleTurnStarted` reports a violation through the `warn` effect and still + * claims the earliest (a mis-claim only mis-times deletions, so it is not + * worth failing the turn over). An exact `promptId` hit bypasses the + * heuristic entirely, so it neither trips nor needs the invariant. + * + * Unclaimed leases are released at session close / shutdown, and every + * in-flight cleanup is drainable via {@link drain}. + * + * Self-contained state machine extracted from `PythinkerTUI`: the two side effects + * (resolving attachment ids to daemon file ids, deleting the staged files) + * are injected, so the tracker is unit-testable without a TUI. + */ + +import type { TurnEndedEvent, TurnStartedEvent } from '@pymodel/pythinker-code-sdk'; + +import type { QueuedMessage } from '../types'; + +export type StagingLeaseOrigin = 'user' | 'skill_activation' | 'plugin_command'; + +export interface StagingLease { + readonly imageAttachmentIds: readonly number[]; + readonly paths: readonly string[]; + readonly origin: StagingLeaseOrigin; + readonly submissionId?: string; + turnId: string | undefined; + released: boolean; +} + +export interface StagingLeaseEffects { + /** Resolve attachment ids to the staged daemon file ids, consuming the mapping. */ + readonly takeFileIds: (imageAttachmentIds: readonly number[]) => readonly string[]; + /** Consume retains without taking the staged files (queue recall keeps them). */ + readonly releaseRetains: (imageAttachmentIds: readonly number[]) => void; + /** Delete staged files (daemon uploads + local cache copies); never rejects. */ + readonly deleteFiles: (fileIds: readonly string[], paths: readonly string[]) => Promise<void>; + /** + * Optional sink for invariant violations (see the INVARIANT note above). + * The tracker keeps operating; the warning exists to make a broken + * same-origin ordering assumption visible instead of mis-binding silently. + */ + readonly warn?: (message: string) => void; +} + +export class StagingLeaseTracker { + private readonly cleanups = new Set<Promise<void>>(); + /** Staged media is owned by the turn that consumes it, not by the RPC call. */ + private readonly leases = new Set<StagingLease>(); + private readonly leasesByTurn = new Map<string, Set<StagingLease>>(); + /** Leases carrying a client-chosen submission id, for exact `promptId` binding. */ + private readonly leasesBySubmissionId = new Map<string, StagingLease>(); + /** + * Cache copies whose consuming turn already ended. Persisted history may + * still reference their paths (v1 video degrade tags, skill/plugin text + * references), so they survive until the session closes. + */ + private readonly retiredPaths = new Set<string>(); + + constructor(private readonly effects: StagingLeaseEffects) {} + + create( + imageAttachmentIds: readonly number[], + paths: readonly string[], + origin: StagingLeaseOrigin, + submissionId?: string, + ): StagingLease | undefined { + // `imageAttachmentIds` multiplicity is the retain count this lease must + // release: each extraction/rewrite retains once per unique id, so callers + // dedupe repeated placeholder occurrences per contribution before handing + // the ids over (one message referencing an image twice contributes it + // once; two batched messages sharing an image contribute it twice). + if (imageAttachmentIds.length === 0 && paths.length === 0) return undefined; + const lease: StagingLease = { + imageAttachmentIds: [...imageAttachmentIds], + paths: [...paths], + origin, + submissionId, + turnId: undefined, + released: false, + }; + this.leases.add(lease); + if (submissionId !== undefined) this.leasesBySubmissionId.set(submissionId, lease); + return lease; + } + + bindToTurn(lease: StagingLease | undefined, turnId: string): void { + if (lease === undefined || lease.released || lease.turnId !== undefined) return; + lease.turnId = turnId; + let leases = this.leasesByTurn.get(turnId); + if (leases === undefined) { + leases = new Set<StagingLease>(); + this.leasesByTurn.set(turnId, leases); + } + leases.add(lease); + } + + handleTurnStarted(event: TurnStartedEvent): void { + const kind = event.origin?.kind; + if (kind !== 'user' && kind !== 'skill_activation' && kind !== 'plugin_command') return; + if (event.promptId !== undefined) { + // Exact binding: the turn echoes the submission's client-chosen prompt + // id — bind that lease directly and skip the origin heuristic (and its + // ambiguity warning) entirely. + const exact = this.leasesBySubmissionId.get(event.promptId); + if (exact !== undefined && exact.turnId === undefined) { + this.bindToTurn(exact, String(event.turnId)); + return; + } + } + const candidates = [...this.leases].filter( + (candidate) => + !candidate.released && candidate.turnId === undefined && candidate.origin === kind, + ); + if (candidates.length > 1) { + // INVARIANT violation: the earliest-unclaimed pick cannot tell + // same-origin leases apart — same-origin dispatch serialization or the + // turn.started ordering assumption may be broken. + this.effects.warn?.( + `staging lease: ${candidates.length} unclaimed '${kind}' leases when turn ` + + `${String(event.turnId)} started; claiming the earliest`, + ); + } + this.bindToTurn(candidates[0], String(event.turnId)); + } + + handleTurnEnded(event: TurnEndedEvent): void { + const turnId = String(event.turnId); + const leases = this.leasesByTurn.get(turnId); + if (leases === undefined) return; + for (const lease of leases) this.releaseConsumed(lease); + this.leasesByTurn.delete(turnId); + } + + /** + * Track a dispatch RPC carrying staged media. When it rejects, run + * `onError` and release the lease — but only while no turn has claimed it: + * a bound lease is owned by the turn and released at turn end, whatever the + * RPC's later outcome. + */ + trackDispatch( + lease: StagingLease | undefined, + request: Promise<unknown>, + onError: (error: unknown) => void, + ): void { + this.track( + request + .catch((error: unknown) => { + onError(error); + if (lease?.turnId === undefined) this.release(lease); + }) + .then(() => undefined), + ); + } + + /** + * Release staged media that will never be consumed (dispatch failed before + * a turn claimed the lease): delete daemon uploads and cache copies now. + */ + release(lease: StagingLease | undefined): void { + if (lease === undefined || lease.released) return; + this.unbind(lease); + this.deleteStaged(this.takeFileIds(lease), lease.paths); + } + + /** Release every unclaimed lease and the retired cache copies (session close / shutdown). */ + releaseAll(): void { + for (const lease of this.leases) this.release(lease); + const retired = [...this.retiredPaths]; + this.retiredPaths.clear(); + this.deleteStaged([], retired); + } + + /** Release staged media that never got a lease (validation/render failures). */ + releaseMedia(imageAttachmentIds: readonly number[], paths: readonly string[]): void { + const fileIds = this.effects.takeFileIds(imageAttachmentIds); + this.deleteStaged(fileIds, paths); + } + + releaseQueued(items: readonly QueuedMessage[]): void { + const fileIds = items.flatMap((item) => + this.effects.takeFileIds(item.imageAttachmentIds ?? []), + ); + const paths = items.flatMap((item) => item.stagingPaths ?? []); + this.deleteStaged(fileIds, paths); + } + + /** + * Release a queued item (or a cache-hint stash's extraction) recalled into + * the editor: the restored draft still references its attachments, so this + * is not a discard — daemon uploads stay staged (only the retain is + * consumed; the next submit re-retains them) and cache copies retire to + * session lifetime instead of being deleted. + */ + releaseRecalled(item: { + imageAttachmentIds?: readonly number[]; + stagingPaths?: readonly string[]; + }): void { + this.effects.releaseRetains(item.imageAttachmentIds ?? []); + for (const path of item.stagingPaths ?? []) this.retiredPaths.add(path); + } + + /** + * Hand a lease's staged media back to raw (ids, paths) ownership without + * consuming retains or deleting files: the lease is simply unbound. Used + * when a submission is diverted before dispatch — queued behind a running + * turn or swallowed by the cache-hint stash; see the header note. + */ + defer(lease: StagingLease | undefined): void { + if (lease === undefined || lease.released) return; + this.unbind(lease); + } + + /** Track an in-flight staging-related promise so {@link drain} can await it. */ + track(cleanup: Promise<void>): void { + let tracked!: Promise<void>; + tracked = cleanup.catch(() => undefined).finally(() => { + this.cleanups.delete(tracked); + }); + this.cleanups.add(tracked); + } + + async drain(): Promise<void> { + while (this.cleanups.size > 0) { + await Promise.allSettled(this.cleanups); + } + } + + /** Schedule deletion of already-resolved staged files (e.g. a store clear). */ + deleteStaged(fileIds: readonly string[], paths: readonly string[] = []): void { + if (fileIds.length === 0 && paths.length === 0) return; + this.track(this.effects.deleteFiles(fileIds, paths)); + } + + /** + * Turn-end release: the daemon uploads are safe to delete — the engine + * materialized its own session copies at intake — while the cache copies + * retire to session lifetime (see {@link retiredPaths}). + */ + private releaseConsumed(lease: StagingLease): void { + if (lease.released) return; + this.unbind(lease); + for (const path of lease.paths) this.retiredPaths.add(path); + this.deleteStaged(this.takeFileIds(lease)); + } + + private unbind(lease: StagingLease): void { + lease.released = true; + this.leases.delete(lease); + if (lease.submissionId !== undefined) this.leasesBySubmissionId.delete(lease.submissionId); + if (lease.turnId !== undefined) { + const leases = this.leasesByTurn.get(lease.turnId); + leases?.delete(lease); + if (leases?.size === 0) this.leasesByTurn.delete(lease.turnId); + } + } + + private takeFileIds(lease: StagingLease): readonly string[] { + // Multiplicity in the lease's id list is the retain count (creation sites + // dedupe per extraction before contributing ids): consume one retain per + // occurrence. + return lease.imageAttachmentIds.flatMap((id) => this.effects.takeFileIds([id])); + } +} diff --git a/apps/pythinker-code/src/tui/controllers/streaming-ui.ts b/apps/pythinker-code/src/tui/controllers/streaming-ui.ts index 27c5ffb1..c8fead2a 100644 --- a/apps/pythinker-code/src/tui/controllers/streaming-ui.ts +++ b/apps/pythinker-code/src/tui/controllers/streaming-ui.ts @@ -1,8 +1,8 @@ -import type { Component } from '@earendil-works/pi-tui'; import type { Session } from '@pymodel/pythinker-code-sdk'; import { AgentGroupComponent } from '../components/messages/agent-group'; import { AssistantMessageComponent } from '../components/messages/assistant-message'; +import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; @@ -12,8 +12,6 @@ import { hasDispose } from '../utils/component-capabilities'; import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload'; import { notifyTerminalOnce } from '../utils/terminal-notification'; import { nextTranscriptId } from '../utils/transcript-id'; -import { ScrollbackBridge } from '../runtime/scrollback/scrollback-bridge'; -import { markTranscriptComponent } from '../utils/transcript-component-metadata'; import type { TodoItem } from '../components/chrome/todo-panel'; import type { AppState, @@ -37,16 +35,11 @@ export interface StreamingUIHost { deferUserMessages: boolean; shiftQueuedMessage(): QueuedMessage | undefined; pushTranscriptEntry(entry: TranscriptEntry): void; + mergeCurrentTurnSteps(): void; + mergeCompletedTurnAssistants(): void; } export class StreamingUIController { - /** - * Mirrors assistant text into terminal scrollback when the OpenTUI path is - * active. Unset on the legacy pi path, where the transcript container owns - * rendering, so leaving it undefined changes nothing. - */ - private scrollback: ScrollbackBridge | undefined = undefined; - private flushTimer: ReturnType<typeof setTimeout> | undefined; private lastFlushAt: number | undefined; private pendingAssistantFlush = false; @@ -63,8 +56,6 @@ export class StreamingUIController { private _thinkingDraft = ''; private _streamingBlock: { component: AssistantMessageComponent; entry: TranscriptEntry } | null = null; private _activeThinkingComponent: ThinkingComponent | undefined = undefined; - /** Scrollback identity for the live thinking block; the component itself has none. */ - private _thinkingEntryId: string | undefined = undefined; private _activeCompactionBlock: CompactionComponent | undefined = undefined; private _activeToolCalls = new Map<string, ToolCallBlockData>(); private _streamingToolCallArguments = new Map< @@ -84,14 +75,9 @@ export class StreamingUIController { solo?: ToolCallComponent; group?: ReadGroupComponent; } | null = null; + constructor(private readonly host: StreamingUIHost) {} - private addLiveTranscriptChild(child: Component): void { - this.host.state.transcriptContainer.addTranscriptChild(child, { - role: 'live-durable', - edgeBlankPolicy: 'trim-plain', - }); - } // --------------------------------------------------------------------------- // Turn context — read/write accessors // --------------------------------------------------------------------------- @@ -282,6 +268,41 @@ export class StreamingUIController { return true; } + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + /** Registers a tool call that arrived via tool.call.started. * Clears any pending streaming state for this id, updates or creates the * component, and returns whether the call was new (no previous entry). */ @@ -295,7 +316,7 @@ export class StreamingUIController { existingComponent.updateToolCall(toolCall); } else if (existing === undefined) { this.finalizeLiveTextBuffers('tool'); - if (toolCall.name !== 'Agent' && toolCall.name !== 'DynamicWorkflow') { + if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentDynamicWorkflow') { this.onToolCallStart(toolCall); } } @@ -381,11 +402,10 @@ export class StreamingUIController { // --------------------------------------------------------------------------- disposeActiveThinkingComponent(): void { - const component = this._activeThinkingComponent; - if (component === undefined) return; - this.host.state.activityContainer.removeChild(component); - component.dispose(); - this._activeThinkingComponent = undefined; + if (this._activeThinkingComponent !== undefined) { + this._activeThinkingComponent.dispose(); + this._activeThinkingComponent = undefined; + } } disposeAndClearPendingToolComponents(): void { @@ -500,20 +520,13 @@ export class StreamingUIController { this.host.state.ui.requestRender(); } - /** Enables scrollback mirroring; the OpenTUI presentation calls this on start. */ - setScrollbackBridge(bridge: ScrollbackBridge | undefined): void { - this.scrollback = bridge; - } - resetLiveText(): void { - this.scrollback?.reset(); this.pendingAssistantFlush = false; this.pendingThinkingFlush = false; this.clearFlushTimerIfIdle(); this._assistantDraft = ''; this._streamingBlock = null; this._thinkingDraft = ''; - this._thinkingEntryId = undefined; this.disposeActiveThinkingComponent(); } @@ -524,6 +537,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { @@ -542,14 +556,23 @@ export class StreamingUIController { const completedTurnKey = this._currentTurnId ?? `local:${String(state.appState.streamingStartTime)}`; this.finalizeLiveTextBuffers('idle'); + // The finished turn keeps only its conclusion-bearing tail; intermediate + // chatter folds into the step summary. + this.host.mergeCompletedTurnAssistants(); this.resetToolCallState(); this._currentTurnId = undefined; const next = this.host.shiftQueuedMessage(); if (next !== undefined) { + // The message is out of the queue but not yet sent. Mark the dispatch + // pending *before* setAppState — that call synchronously retries + // queued-goal promotion, which would otherwise see an empty queue and an + // idle phase and start a goal ahead of this message. + state.queuedMessageDispatchPending = true; this.host.setAppState({ streamingPhase: 'idle' }); this.host.resetLivePane(); setTimeout(() => { + state.queuedMessageDispatchPending = false; sendQueued(next); }, 0); return; @@ -577,13 +600,12 @@ export class StreamingUIController { turnId: this._currentTurnId, renderMode: 'markdown' as const, content: '', + modelText: true, }; const component = new AssistantMessageComponent(); - markTranscriptComponent(component, entry); this._streamingBlock = { component, entry }; - this.scrollback?.begin(entry.id, this._currentTurnId); this.host.pushTranscriptEntry(entry); - this.addLiveTranscriptChild(component); + state.transcriptContainer.addChild(component); state.ui.requestRender(); } @@ -591,21 +613,24 @@ export class StreamingUIController { const block = this._streamingBlock; if (block !== null) { block.entry.content = fullText; - block.component.updateContent(fullText); - this.scrollback?.update(block.entry.id, fullText); + block.component.updateContent(fullText, { transient: true }); this.host.state.ui.requestRender(); } } onStreamingTextEnd(): void { const block = this._streamingBlock; - if (block !== null) this.scrollback?.complete(block.entry.id); + if (block !== null) { + block.component.updateContent(block.entry.content, { transient: false }); + } this._streamingBlock = null; } onThinkingUpdate(fullText: string): void { - // Replay also funnels stored thinking through this method, so filter at the - // component boundary as well as at the live delta handler. + // Skip thinking that carries nothing visible — empty (e.g. encrypted + // reasoning) or whitespace-only (a model occasionally streams a single + // space as thinking). Session replay funnels through here as well, so a + // stored whitespace-only think part never becomes a bare bullet line. if (fullText.trim().length === 0 && this._activeThinkingComponent === undefined) return; const { state } = this.host; if (this._activeThinkingComponent === undefined) { @@ -618,30 +643,19 @@ export class StreamingUIController { state.ui, ); if (state.toolOutputExpanded) this._activeThinkingComponent.setExpanded(true); - state.activityContainer.addChild(this._activeThinkingComponent); - this._thinkingEntryId = nextTranscriptId(); - this.scrollback?.begin(this._thinkingEntryId, this._currentTurnId); + state.transcriptContainer.addChild(this._activeThinkingComponent); } else { this._activeThinkingComponent.setText(fullText); } - if (this._thinkingEntryId !== undefined) { - this.scrollback?.update(this._thinkingEntryId, fullText); - } state.ui.requestRender(); } onThinkingEnd(): void { - const component = this._activeThinkingComponent; - if (component === undefined) return; - component.finalize(); - this.host.state.activityContainer.removeChild(component); - this.addLiveTranscriptChild(component); + if (this._activeThinkingComponent === undefined) return; + this._activeThinkingComponent.finalize(); this._activeThinkingComponent = undefined; - if (this._thinkingEntryId !== undefined) { - this.scrollback?.complete(this._thinkingEntryId); - this._thinkingEntryId = undefined; - } this.host.state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); } onToolCallStart(toolCall: ToolCallBlockData): void { @@ -663,7 +677,7 @@ export class StreamingUIController { let handled = this.tryAttachAgentToolCall(toolCall, tc); if (!handled) handled = this.tryAttachReadToolCall(toolCall, tc); if (!handled) { - this.addLiveTranscriptChild(tc); + state.transcriptContainer.addChild(tc); state.ui.requestRender(); } @@ -688,6 +702,7 @@ export class StreamingUIController { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); state.ui.requestRender(); + this.host.mergeCurrentTurnSteps(); return; } @@ -699,9 +714,10 @@ export class StreamingUIController { state.appState.workDir, ); if (state.toolOutputExpanded) completed.setExpanded(true); - this.addLiveTranscriptChild(completed); + state.transcriptContainer.addChild(completed); state.ui.requestRender(); } + this.host.mergeCurrentTurnSteps(); } setTodoList(todos: readonly TodoItem[]): void { @@ -720,9 +736,12 @@ export class StreamingUIController { this._activeCompactionBlock.markDone(); this._activeCompactionBlock = undefined; } - const block = new CompactionComponent(state.ui, instruction); + const block = new CompactionComponent(state.ui, instruction, currentWorkingTip()?.text); this._activeCompactionBlock = block; - this.addLiveTranscriptChild(block); + state.transcriptContainer.addChild(block); + if (state.toolOutputExpanded) { + block.setExpanded(true); + } state.ui.requestRender(); } @@ -749,12 +768,10 @@ export class StreamingUIController { private flushToolCallPreview(id: string): void { const streaming = this._streamingToolCallArguments.get(id); if (streaming === undefined) return; - const args = parseStreamingArgs(streaming.argumentsText); - if (typeof args['i'] === 'string') delete args['i']; const toolCall: ToolCallBlockData = { id, name: streaming.name ?? this._activeToolCalls.get(id)?.name ?? 'Tool', - args, + args: parseStreamingArgs(streaming.argumentsText), streamingArguments: streaming.argumentsText, streamingStartedAtMs: streaming.startedAtMs, step: this._currentStep, @@ -769,7 +786,7 @@ export class StreamingUIController { const existingComponent = this._pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); - } else if (toolCall.name !== 'Agent' && toolCall.name !== 'DynamicWorkflow') { + } else if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentDynamicWorkflow') { this.onToolCallStart(toolCall); } } @@ -792,7 +809,7 @@ export class StreamingUIController { const cur = this._pendingAgentGroup; if (cur === null) { this._pendingAgentGroup = { step, turnId, solo: tc }; - this.addLiveTranscriptChild(tc); + state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } @@ -805,7 +822,7 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { this._pendingAgentGroup = { step, turnId, solo: tc }; - this.addLiveTranscriptChild(tc); + state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } @@ -822,12 +839,11 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { - state.transcriptContainer.replaceTranscriptChild(solo, group, { - role: 'live-durable', - edgeBlankPolicy: 'trim-plain', - }); + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = group; } else { - this.addLiveTranscriptChild(group); + state.transcriptContainer.addChild(group); } group.attach(solo.toolCallView.id, solo); return group; @@ -851,7 +867,7 @@ export class StreamingUIController { const cur = this._pendingReadGroup; if (cur === null) { this._pendingReadGroup = { step, turnId, solo: tc }; - this.addLiveTranscriptChild(tc); + state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } @@ -864,7 +880,7 @@ export class StreamingUIController { const solo = cur.solo; if (solo === undefined) { this._pendingReadGroup = { step, turnId, solo: tc }; - this.addLiveTranscriptChild(tc); + state.transcriptContainer.addChild(tc); state.ui.requestRender(); return true; } @@ -881,12 +897,11 @@ export class StreamingUIController { const children = state.transcriptContainer.children; const idx = children.indexOf(solo); if (idx >= 0) { - state.transcriptContainer.replaceTranscriptChild(solo, group, { - role: 'live-durable', - edgeBlankPolicy: 'trim-plain', - }); + // In-place replacement is picked up by the container's ref-checked + // render cache; a tree-wide invalidate is unnecessary (and costly). + children[idx] = group; } else { - this.addLiveTranscriptChild(group); + state.transcriptContainer.addChild(group); } group.attach(solo.toolCallView.id, solo); return group; diff --git a/apps/pythinker-code/src/tui/controllers/subagent-activity-store.ts b/apps/pythinker-code/src/tui/controllers/subagent-activity-store.ts new file mode 100644 index 00000000..350cc80b --- /dev/null +++ b/apps/pythinker-code/src/tui/controllers/subagent-activity-store.ts @@ -0,0 +1,347 @@ +/** + * SubagentActivityStore — per-agent activity records feeding the background + * agent detail view (AgentActivityViewer). + * + * Child-agent events arrive at `SubAgentEventHandler.routeChildAgentEvent` + * regardless of foreground/background state, but are dropped there when the + * parent tool card is gone (Ctrl+B) or never existed (run_in_background). + * This store tees those events into a bounded per-agent fold so the tasks + * browser can show what a background agent is actually doing. + * + * Retention: only the most recent `MAX_SUBAGENT_ACTIVITY_STEPS` steps are + * kept (older steps are discarded whole — a step is the core loop's natural + * "one model response + tool execution" unit, bounded by the core's own + * `turn.step.started` events). Per-step assistant text keeps a trailing + * window; per-call result output is capped. Everything lives in memory and + * is released on session switch (`clear`). + * + * Pure logic — no TUI state, no components — so it is unit-testable. + */ + +import type { Event } from '@pymodel/pythinker-code-sdk'; + +import { + MAX_SUBAGENT_ACTIVITY_STEPS, + SUBAGENT_ARG_STRING_MAX_CHARS, + SUBAGENT_STEP_TEXT_TAIL_CHARS, + SUBAGENT_TOOL_OUTPUT_MAX_CHARS, +} from '#/tui/constant/rendering'; +import type { ToolResultBlockData } from '../types'; +import { + argsRecord, + appendStreamingArgsPreview, + parseStreamingArgs, + serializeToolResultOutput, +} from '../utils/event-payload'; + +/** A single tool call inside a step, shaped so the viewer can feed the + * main-flow renderers (`ToolCallBlockData` / `ToolResultBlockData`). */ +export interface SubToolCallActivity { + readonly id: string; + name: string; + args: Record<string, unknown>; + status: 'running' | 'done' | 'error'; + readonly startedAt: number; + durationMs?: number; + result?: ToolResultBlockData; + /** Last line of stdout/stderr live progress, while the call is running. */ + liveOutputTail?: string; +} + +/** One step = one core loop iteration (`turn.step.started` … next start). */ +export interface SubagentStepActivity { + readonly step: number; + /** Assistant text of this step, trailing window only. */ + textTail: string; + readonly toolCalls: SubToolCallActivity[]; + retrying?: string; +} + +export interface SubagentActivityRecord { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + model?: string; + effort?: string; + readonly steps: SubagentStepActivity[]; + /** Count of real `turn.step.started` events seen (monotonic). */ + totalSteps: number; + status: 'running' | 'completed' | 'failed'; + resultSummary?: string; + error?: string; + /** Bumped on every mutation; the viewer caches its render against this. */ + version: number; +} + +export interface SubagentActivitySpawn { + readonly agentId: string; + readonly agentName: string; + readonly description?: string; + readonly parentToolCallId: string; + readonly model?: string; + readonly effort?: string; +} + +const LIVE_OUTPUT_TAIL_CHARS = 200; + +function tail(text: string, maxChars: number): string { + return text.length <= maxChars ? text : text.slice(text.length - maxChars); +} + +/** Truncate long string argument values before they are retained — Write and + * Edit carry whole-file contents in args, which would otherwise dwarf every + * other retention cap. Only header summaries (`extractKeyArgument`) and the + * Edit/Write line chips read args, so truncation is display-safe; those + * chips simply become approximate beyond the cap. Shallow on purpose: the + * tools that matter have flat argument records. */ +function capArgStrings(args: Record<string, unknown>): Record<string, unknown> { + let capped: Record<string, unknown> | undefined; + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || value.length <= SUBAGENT_ARG_STRING_MAX_CHARS) continue; + capped ??= { ...args }; + capped[key] = `${value.slice(0, SUBAGENT_ARG_STRING_MAX_CHARS)}…`; + } + return capped ?? args; +} + +export class SubagentActivityStore { + private readonly records = new Map<string, SubagentActivityRecord>(); + /** Raw streaming-arguments buffer per in-flight tool call (from deltas). */ + private readonly streamingArgs = new Map<string, string>(); + + ensureRecord(spawn: SubagentActivitySpawn): SubagentActivityRecord { + const existing = this.records.get(spawn.agentId); + if (existing !== undefined) { + // A resumed subagent re-spawns under the same id: keep the accumulated + // steps and flip the record back to running. + existing.status = 'running'; + existing.resultSummary = undefined; + existing.error = undefined; + return existing; + } + const record: SubagentActivityRecord = { + agentId: spawn.agentId, + agentName: spawn.agentName, + description: spawn.description, + parentToolCallId: spawn.parentToolCallId, + model: spawn.model, + effort: spawn.effort, + steps: [], + totalSteps: 0, + status: 'running', + version: 0, + }; + this.records.set(spawn.agentId, record); + return record; + } + + get(agentId: string): SubagentActivityRecord | undefined { + return this.records.get(agentId); + } + + agentIds(): readonly string[] { + return [...this.records.keys()]; + } + + applyEvent(event: Event): void { + switch (event.type) { + case 'turn.step.started': { + const record = this.recordFor(event.agentId); + record.steps.push({ step: event.step, textTail: '', toolCalls: [] }); + record.totalSteps += 1; + while (record.steps.length > MAX_SUBAGENT_ACTIVITY_STEPS) { + const evicted = record.steps.shift(); + if (evicted === undefined) break; + // A call truncated before started/result only ever produced deltas; + // its arg buffer is keyed by id, so evicting the only step that + // referenced it must drop the buffer entry too. + for (const call of evicted.toolCalls) { + this.streamingArgs.delete(this.streamKey(record.agentId, call.id)); + } + } + this.bump(record); + return; + } + case 'assistant.delta': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.textTail = tail(step.textTail + event.delta, SUBAGENT_STEP_TEXT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.call.started': { + const record = this.recordFor(event.agentId); + const existing = this.findToolCall(record, event.toolCallId); + const args = capArgStrings(argsRecord(event.args)); + if (existing === undefined) { + this.currentStep(record).toolCalls.push({ + id: event.toolCallId, + name: event.name, + args, + status: 'running', + startedAt: Date.now(), + }); + } else { + // Authoritative full args arrive with the start; replace the + // best-effort record assembled from streaming deltas. + existing.name = event.name; + existing.args = args; + } + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'tool.call.delta': { + const record = this.recordFor(event.agentId); + const key = this.streamKey(event.agentId, event.toolCallId); + // parseStreamingArgs only reads the preview window, so keep the raw + // buffer capped at the same size — an uncapped buffer would outgrow + // the store's retention caps on large Write/Edit argument streams. + const buffered = appendStreamingArgsPreview( + this.streamingArgs.get(key), + event.argumentsPart, + ); + this.streamingArgs.set(key, buffered); + let call = this.findToolCall(record, event.toolCallId); + if (call === undefined) { + call = { + id: event.toolCallId, + name: event.name ?? '', + args: {}, + status: 'running', + startedAt: Date.now(), + }; + this.currentStep(record).toolCalls.push(call); + } + if (call.name.length === 0 && event.name !== undefined) call.name = event.name; + call.args = capArgStrings(parseStreamingArgs(buffered)); + this.bump(record); + return; + } + case 'tool.progress': { + if (event.update.kind !== 'stdout' && event.update.kind !== 'stderr') return; + const text = event.update.text; + if (text === undefined || text.trim().length === 0) return; + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + const lines = text.trimEnd().split('\n'); + call.liveOutputTail = tail(lines.at(-1) ?? '', LIVE_OUTPUT_TAIL_CHARS); + this.bump(record); + return; + } + case 'tool.result': { + const record = this.records.get(event.agentId); + const call = record === undefined ? undefined : this.findToolCall(record, event.toolCallId); + if (record === undefined || call === undefined) return; + let output = serializeToolResultOutput(event.output); + if (output.length > SUBAGENT_TOOL_OUTPUT_MAX_CHARS) { + output = `${output.slice(0, SUBAGENT_TOOL_OUTPUT_MAX_CHARS)}\n… [output truncated to ${String(SUBAGENT_TOOL_OUTPUT_MAX_CHARS)} chars]`; + } + call.result = { + tool_call_id: call.id, + output, + is_error: event.isError, + synthetic: event.synthetic, + }; + call.status = event.isError === true ? 'error' : 'done'; + call.durationMs = Date.now() - call.startedAt; + call.liveOutputTail = undefined; + this.streamingArgs.delete(this.streamKey(event.agentId, event.toolCallId)); + this.bump(record); + return; + } + case 'turn.step.retrying': { + const record = this.recordFor(event.agentId); + const step = this.currentStep(record); + step.retrying = `retrying · attempt ${String(event.nextAttempt)}/${String(event.maxAttempts)} (${event.errorName})`; + this.bump(record); + return; + } + default: + return; + } + } + + markCompleted(agentId: string, resultSummary?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'completed'; + record.resultSummary = resultSummary; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + markFailed(agentId: string, error?: string): void { + const record = this.records.get(agentId); + if (record === undefined) return; + record.status = 'failed'; + record.error = error; + this.dropStreamingBuffers(agentId); + this.bump(record); + } + + clear(): void { + this.records.clear(); + this.streamingArgs.clear(); + } + + /** Drop one agent's record and its in-flight arg buffers. Used when a + * foreground-only subagent (never backgrounded, so it can never appear in + * /tasks) reaches a terminal state — its record would otherwise stay + * resident until the session reset. */ + drop(agentId: string): void { + this.records.delete(agentId); + this.dropStreamingBuffers(agentId); + } + + /** No more deltas arrive once the record is terminal, so any buffer left + * by a call truncated before started/result can be released here. */ + private dropStreamingBuffers(agentId: string): void { + const prefix = `${agentId}:`; + for (const key of this.streamingArgs.keys()) { + if (key.startsWith(prefix)) this.streamingArgs.delete(key); + } + } + + /** Get-or-create: events can arrive for agents this process never saw a + * spawn for (e.g. switching back to a session whose background agents are + * still running) — keep their activity rather than dropping it. */ + private recordFor(agentId: string): SubagentActivityRecord { + return ( + this.records.get(agentId) ?? + this.ensureRecord({ agentId, agentName: agentId, parentToolCallId: '' }) + ); + } + + /** Latest step, creating a synthetic one when content arrives ahead of any + * `turn.step.started` (same mid-flight case as `recordFor`). */ + private currentStep(record: SubagentActivityRecord): SubagentStepActivity { + let step = record.steps.at(-1); + if (step === undefined) { + step = { step: 0, textTail: '', toolCalls: [] }; + record.steps.push(step); + } + return step; + } + + private findToolCall( + record: SubagentActivityRecord, + toolCallId: string, + ): SubToolCallActivity | undefined { + for (let i = record.steps.length - 1; i >= 0; i--) { + const call = record.steps[i]!.toolCalls.find((c) => c.id === toolCallId); + if (call !== undefined) return call; + } + return undefined; + } + + private streamKey(agentId: string, toolCallId: string): string { + return `${agentId}:${toolCallId}`; + } + + private bump(record: SubagentActivityRecord): void { + record.version += 1; + } +} diff --git a/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts b/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts index 605a6d5b..b3993730 100644 --- a/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/subagent-event-handler.ts @@ -2,12 +2,14 @@ import type { BackgroundTaskInfo, Event, } from '@pymodel/pythinker-code-sdk'; -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import { - DynamicWorkflowMissionControlComponent, - dynamicWorkflowDescriptionFromArgs, -} from '../components/messages/dynamic-workflow-mission-control'; + AgentDynamicWorkflowProgressComponent, + agentDynamicWorkflowDescriptionFromArgs, + agentDynamicWorkflowGridHeightForTerminalRows, +} from '../components/messages/agent-dynamic-workflow-progress'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { MAIN_AGENT_ID } from '../constant/pythinker-tui'; import type { BackgroundAgentMetadata, @@ -20,6 +22,7 @@ import { argsRecord, serializeToolResultOutput } from '../utils/event-payload'; import { formatHookResultPlain } from '../utils/hook-result-format'; import { nextTranscriptId } from '../utils/transcript-id'; import type { SessionEventHost } from './session-event-handler'; +import { SubagentActivityStore } from './subagent-activity-store'; export interface SubagentInfo { readonly parentToolCallId: string; @@ -52,22 +55,10 @@ function renderedRowsAfterChild( export class SubAgentEventHandler { readonly subagentInfo: Map<string, SubagentInfo> = new Map(); - private readonly dynamicWorkflowMissionControls: Map< - string, - DynamicWorkflowMissionControlComponent - > = new Map(); - // Lifecycle events that arrive before their `subagent.spawned` are buffered - // per parent tool call, so they replay only into the generation of that - // exact workflow. `parentToolCallIdsByAgentId` guards against ambiguous - // parentless terminal events when an agent id is reused, and retired tool - // calls keep late events (after undo / turn cleanup) from resurrecting UI. - private readonly pendingLifecycleByParentToolCallId = new Map< - string, - Map<string, SubagentLifecycleEvent[]> - >(); - private readonly parentToolCallIdsByAgentId = new Map<string, Set<string>>(); - private readonly retiredDynamicWorkflowToolCallIds = new Set<string>(); + private readonly agentDynamicWorkflowProgress: Map<string, AgentDynamicWorkflowProgressComponent> = new Map(); backgroundAgentMetadata: Map<string, BackgroundAgentMetadata> = new Map(); + /** Bounded per-agent activity fold feeding the background-agent detail view. */ + readonly activityStore = new SubagentActivityStore(); constructor( private readonly host: SessionEventHost, @@ -77,45 +68,29 @@ export class SubAgentEventHandler { resetRuntimeState(): void { this.subagentInfo.clear(); this.backgroundAgentMetadata.clear(); - this.clearDynamicWorkflowMissionControls(); - this.pendingLifecycleByParentToolCallId.clear(); - this.parentToolCallIdsByAgentId.clear(); - this.retiredDynamicWorkflowToolCallIds.clear(); - } - - /** Rebuilds replay state from a resumed session's background agents. */ - hydrateBackgroundAgentMetadata( - backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>, - ): void { - this.backgroundAgentMetadata = new Map(backgroundAgentMetadata); - for (const [agentId, meta] of backgroundAgentMetadata) { - const knownParentToolCallIds = this.parentToolCallIdsByAgentId.get(agentId) ?? new Set(); - knownParentToolCallIds.add(meta.parentToolCallId); - this.parentToolCallIdsByAgentId.set(agentId, knownParentToolCallIds); - } + this.activityStore.clear(); + this.clearAgentDynamicWorkflowProgress(); } routeChildAgentEvent(event: Event): boolean { if (isSubagentLifecycleEvent(event)) return false; const childAgentId = event.agentId; - if (childAgentId === MAIN_AGENT_ID) { - // Swallow a late result for a Dynamic Workflow tool call that was already - // retired (undo / turn cleanup), so it cannot restart streaming UI. - return ( - event.type === 'tool.result' && - this.retiredDynamicWorkflowToolCallIds.has(event.toolCallId) - ); - } + if (childAgentId === MAIN_AGENT_ID) return false; if (this.host.btwPanelController.routeEvent(event)) return true; + // Tee every child-agent event into the activity store before the routing + // below swallows events whose parent card is gone (Ctrl+B) or never + // existed (run_in_background) — that data is the background detail view. + this.activityStore.applyEvent(event); + const info = this.subagentInfo.get(childAgentId); if (info === undefined || info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; - const missionControl = this.dynamicWorkflowMissionControls.get(parentToolCallId); - if (missionControl !== undefined) { - this.applySubagentEventToDynamicWorkflow(missionControl, event, childAgentId); + const dynamicWorkflowProgress = this.agentDynamicWorkflowProgress.get(parentToolCallId); + if (dynamicWorkflowProgress !== undefined) { + this.applySubagentEventToDynamicWorkflowProgress(dynamicWorkflowProgress, event, childAgentId); this.requestRender(); return true; } @@ -160,39 +135,20 @@ export class SubAgentEventHandler { toolCall.updateSubagentMetrics({ contextTokens: event.contextTokens, usage: totalUsage, + // The bound model alias rides every child status update (emitted right + // after spawn); surface it on the subagent card. `modelDisplayName` + // falls back to the alias itself when the entry is unknown. + modelDisplay: + event.model === undefined + ? undefined + : modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + effortDisplay: this.subagentEffortDisplay(event.thinkingEffort), }); } return true; } handleLifecycleEvent(event: SubagentLifecycleEvent): void { - if (event.type !== 'subagent.spawned') { - const parentToolCallId = event.parentToolCallId; - const info = this.subagentInfo.get(event.subagentId); - const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); - const activeParentToolCallId = info?.parentToolCallId ?? backgroundMeta?.parentToolCallId; - - if (parentToolCallId !== undefined) { - if (this.retiredDynamicWorkflowToolCallIds.has(parentToolCallId)) { - this.deletePendingLifecycle(parentToolCallId, event.subagentId); - return; - } - if (activeParentToolCallId === undefined) { - this.bufferPendingLifecycle(parentToolCallId, event); - return; - } - if (activeParentToolCallId !== parentToolCallId) return; - } else { - if (activeParentToolCallId === undefined) return; - const knownParentToolCallIds = this.parentToolCallIdsByAgentId.get(event.subagentId); - if ( - knownParentToolCallIds === undefined || - knownParentToolCallIds.size !== 1 || - !knownParentToolCallIds.has(activeParentToolCallId) - ) return; - } - } - switch (event.type) { case 'subagent.spawned': this.handleSubagentSpawned(event); @@ -212,126 +168,89 @@ export class SubAgentEventHandler { } } - handleWorkflowWarning(event: Extract<Event, { type: 'workflow.warning' }>): void { - const missionControl = this.dynamicWorkflowMissionControls.get(event.parentToolCallId); - if (missionControl !== undefined) { - missionControl.markWarning(event.message); - this.requestRender(); - return; - } - // The tool call was retired or the warning arrived without a card: never - // drop it silently. - this.host.showStatus(event.message, 'warning'); - } - - // Retires every live mission control: the tool call ids are recorded so - // any late events for them are dropped, and their subagents are forgotten. - clearDynamicWorkflowMissionControls(): void { - const toolCallIds = new Set(this.dynamicWorkflowMissionControls.keys()); - for (const toolCallId of toolCallIds) { - this.retiredDynamicWorkflowToolCallIds.add(toolCallId); - } - for (const missionControl of this.dynamicWorkflowMissionControls.values()) { - missionControl.markToolCallEnded(); - missionControl.markActiveCancelled(); - } - this.dynamicWorkflowMissionControls.clear(); - for (const toolCallId of toolCallIds) { - this.forgetDynamicWorkflowSubagents(toolCallId); - this.pendingLifecycleByParentToolCallId.delete(toolCallId); + clearAgentDynamicWorkflowProgress(): void { + for (const progress of this.agentDynamicWorkflowProgress.values()) { + progress.dispose(); } + this.agentDynamicWorkflowProgress.clear(); this.host.updateActivityPane(); } - hasDynamicWorkflowMissionControl(toolCallId: string): boolean { - return this.dynamicWorkflowMissionControls.has(toolCallId); + hasAgentDynamicWorkflowProgress(toolCallId: string): boolean { + return this.agentDynamicWorkflowProgress.has(toolCallId); } - hasActiveDynamicWorkflowToolCall(): boolean { - return Array.from(this.dynamicWorkflowMissionControls.values()).some((missionControl) => - missionControl.isToolCallActive() + hasActiveAgentDynamicWorkflowToolCall(): boolean { + return Array.from(this.agentDynamicWorkflowProgress.values()).some((progress) => + progress.isToolCallActive() ); } - syncDynamicWorkflowActivitySpinner( + syncAgentDynamicWorkflowActivitySpinner( spinner: { renderInline(): string } | undefined, ): void { - for (const missionControl of this.dynamicWorkflowMissionControls.values()) { - missionControl.setActivitySpinnerText( + for (const progress of this.agentDynamicWorkflowProgress.values()) { + progress.setActivitySpinnerText( spinner === undefined ? undefined : () => spinner.renderInline(), ); } } - /** True for a Dynamic Workflow tool call already removed from the UI. */ - isRetiredDynamicWorkflowToolCall(toolCallId: string): boolean { - return this.retiredDynamicWorkflowToolCallIds.has(toolCallId); - } - - handleDynamicWorkflowToolCallStarted( + handleAgentDynamicWorkflowToolCallStarted( toolCallId: string, args: Record<string, unknown>, ): void { - if (this.isRetiredDynamicWorkflowToolCall(toolCallId)) return; - const missionControl = this.ensureDynamicWorkflowMissionControl(toolCallId, args); - missionControl.markInputComplete(); - // Captured here rather than in `ensure…`, which the delta path also calls: - // mid-stream arguments are half-parsed, and saving those would write a - // workflow missing most of its items. - this.host.state.lastDynamicWorkflowArgs = args; + const progress = this.ensureAgentDynamicWorkflowProgress(toolCallId, args); + progress.markInputComplete(); this.requestRender(); } - handleDynamicWorkflowToolCallDelta( + handleAgentDynamicWorkflowToolCallDelta( toolCallId: string, args: Record<string, unknown>, - options: { readonly streamingArguments?: string }, + options: { readonly streamingArguments?: string | undefined }, ): void { - if (this.isRetiredDynamicWorkflowToolCall(toolCallId)) return; - this.ensureDynamicWorkflowMissionControl(toolCallId, args, options); + this.ensureAgentDynamicWorkflowProgress(toolCallId, args, options); this.requestRender(); } - handleDynamicWorkflowToolResult( + handleAgentDynamicWorkflowToolResult( toolCallId: string, resultData: ToolResultBlockData, isError: boolean, ): void { - const missionControl = this.dynamicWorkflowMissionControls.get(toolCallId); - if (missionControl === undefined) { - return; - } + const progress = this.agentDynamicWorkflowProgress.get(toolCallId); + if (progress === undefined) return; if (isError && isUserCancelledSubagentError(resultData.output)) { - if (missionControl.isRequestStreaming()) { - this.removeDynamicWorkflowMissionControl(toolCallId, missionControl); + if (progress.isRequestStreaming()) { + this.removeAgentDynamicWorkflowProgress(toolCallId, progress); } else { - missionControl.markToolCallEnded(); - missionControl.markActiveCancelled(); + progress.markToolCallEnded(); + progress.markActiveCancelled(); } } else if (isError) { - missionControl.markToolCallEnded(); - missionControl.applyResult(resultData.output); - missionControl.markRequestFailed(resultData.output); - } else { - missionControl.markToolCallEnded(); - if (!missionControl.applyResult(resultData.output)) { - missionControl.markRequestFailed('Unsupported Dynamic Workflow result'); + progress.markToolCallEnded(); + if (!progress.applyResult(resultData.output)) { + progress.markDynamicWorkflowFailed(resultData.output); } + } else { + progress.markToolCallEnded(); + progress.applyResult(resultData.output); } this.host.updateActivityPane(); this.requestRender(); } - markActiveDynamicWorkflowsCancelled(): void { + markActiveAgentDynamicWorkflowsCancelled(): void { let updated = false; - for (const [toolCallId, missionControl] of this.dynamicWorkflowMissionControls) { - if (missionControl.isRequestStreaming()) { - this.removeDynamicWorkflowMissionControl(toolCallId, missionControl); + for (const [toolCallId, progress] of this.agentDynamicWorkflowProgress) { + if (progress.isRequestStreaming()) { + this.removeAgentDynamicWorkflowProgress(toolCallId, progress); updated = true; continue; } - missionControl.markActiveCancelled(); + progress.markActiveCancelled(); updated = true; } if (updated) this.requestRender(); @@ -340,17 +259,6 @@ export class SubAgentEventHandler { private handleSubagentSpawned( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): void { - if (this.retiredDynamicWorkflowToolCallIds.has(event.parentToolCallId)) { - this.deletePendingLifecycle(event.parentToolCallId, event.subagentId); - return; - } - - const knownParentToolCallIds = this.parentToolCallIdsByAgentId.get(event.subagentId) ?? new Set(); - knownParentToolCallIds.add(event.parentToolCallId); - this.parentToolCallIdsByAgentId.set(event.subagentId, knownParentToolCallIds); - if (this.backgroundAgentMetadata.delete(event.subagentId)) { - this.deps.syncBackgroundAgentBadge(); - } this.rememberSubagent(event); if (event.runInBackground) { @@ -358,12 +266,10 @@ export class SubAgentEventHandler { this.backgroundAgentMetadata.set(event.subagentId, meta); this.appendBackgroundAgentEntry('started', meta); this.deps.syncBackgroundAgentBadge(); - this.drainPendingLifecycle(event.parentToolCallId, event.subagentId); return; } this.handleForegroundSubagentSpawned(event); - this.drainPendingLifecycle(event.parentToolCallId, event.subagentId); } private handleSubagentStarted( @@ -385,6 +291,8 @@ export class SubAgentEventHandler { private handleSubagentCompleted( event: SubagentLifecycleEventOf<'subagent.completed'>, ): void { + this.activityStore.markCompleted(event.subagentId, event.resultSummary); + this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { const taskId = this.findAgentTaskId( @@ -414,6 +322,8 @@ export class SubAgentEventHandler { private handleSubagentFailed( event: SubagentLifecycleEventOf<'subagent.failed'>, ): void { + this.activityStore.markFailed(event.subagentId, event.error); + this.pruneForegroundOnlyRecord(event.subagentId); const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { const taskId = this.findAgentTaskId( @@ -469,6 +379,28 @@ export class SubAgentEventHandler { return match; } + /** A subagent that never became a background task (foreground-only) can + * never appear in /tasks, so its activity record is dropped at terminal + * state — otherwise records would pile up for the rest of the session. */ + private pruneForegroundOnlyRecord(subagentId: string): void { + // A spawn-time background agent keeps its record even when the + // background.task.started sync has not landed yet (short-lived agents). + if (this.backgroundAgentMetadata.has(subagentId)) return; + for (const info of this.deps.backgroundTasks.values()) { + if (info.kind === 'agent' && info.agentId === subagentId) return; + } + this.activityStore.drop(subagentId); + } + + /** Drop every foreground-only record. Called when the main turn ends: any + * foreground subagent of the turn is over at that point, and an aborted + * one emits no `subagent.completed`/`subagent.failed` to prune it. */ + dropForegroundOnlyActivityRecords(): void { + for (const agentId of this.activityStore.agentIds()) { + this.pruneForegroundOnlyRecord(agentId); + } + } + private buildBackgroundAgentMetadata( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): BackgroundAgentMetadata { @@ -479,13 +411,15 @@ export class SubAgentEventHandler { parentToolCallId: event.parentToolCallId, agentName: event.subagentName, description: typeof description === 'string' ? description : undefined, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), }; } private appendBackgroundAgentEntry( phase: 'started' | 'completed' | 'failed', meta: BackgroundAgentMetadata, - extras?: { resultSummary?: string; error?: string }, + extras: { resultSummary?: string; error?: string } | undefined = undefined, ): void { const status = formatBackgroundAgentTranscript(phase, meta, extras); const entry: TranscriptEntry = { @@ -509,17 +443,32 @@ export class SubAgentEventHandler { runInBackground: event.runInBackground, dynamicWorkflowIndex: event.dynamicWorkflowIndex, }); + this.activityStore.ensureRecord({ + agentId: event.subagentId, + agentName: event.subagentName, + description: event.description, + parentToolCallId: event.parentToolCallId, + model: this.spawnedModelDisplay(event), + effort: this.subagentEffortDisplay(event.thinkingEffort), + }); } private handleForegroundSubagentSpawned( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): void { - if (this.updateDynamicWorkflowMissionControl(event.parentToolCallId, (missionControl) => { - missionControl.registerSubagent({ + // The spawned event carries the display-normalized bound alias (newer + // cores) — show it at spawn instead of waiting for the child's first + // status frame. The `agent.status.updated` channel below stays as the + // in-run update/fallback path. + const modelDisplay = this.spawnedModelDisplay(event); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); + if (this.updateAgentDynamicWorkflowProgress(event.parentToolCallId, (progress) => { + progress.registerSubagent({ agentId: event.subagentId, dynamicWorkflowIndex: event.dynamicWorkflowIndex, - description: event.description, }); + if (modelDisplay !== undefined) progress.setModelDisplay(modelDisplay); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); })) { return; } @@ -532,14 +481,34 @@ export class SubAgentEventHandler { agentName: event.subagentName, runInBackground: event.runInBackground, }); + if (modelDisplay !== undefined || effortDisplay !== undefined) { + tc.updateSubagentMetrics({ modelDisplay, effortDisplay }); + } + } + + /** Map the spawned event's bound alias to a display name via the loaded + * model catalog; falls back to the alias itself for unknown entries. */ + private spawnedModelDisplay( + event: SubagentLifecycleEventOf<'subagent.spawned'>, + ): string | undefined { + if (event.model === undefined) return undefined; + return modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]); + } + + /** Concrete effort levels are always shown; the boolean states carry no + * level information — 'off' (no thinking) and 'on' (generic thinking) are + * both hidden. */ + private subagentEffortDisplay(effort: string | undefined): string | undefined { + if (effort === undefined || effort === 'off' || effort === 'on') return undefined; + return effort; } private handleForegroundSubagentStarted( event: SubagentLifecycleEventOf<'subagent.started'>, info: SubagentInfo, ): void { - if (this.updateDynamicWorkflowMissionControl(info.parentToolCallId, (missionControl) => { - missionControl.markStarted(event.subagentId); + if (this.updateAgentDynamicWorkflowProgress(info.parentToolCallId, (progress) => { + progress.markStarted(event.subagentId); })) { return; } @@ -557,8 +526,8 @@ export class SubAgentEventHandler { event: SubagentLifecycleEventOf<'subagent.suspended'>, info: SubagentInfo, ): void { - this.updateDynamicWorkflowMissionControl(info.parentToolCallId, (missionControl) => { - missionControl.markSuspended({ + this.updateAgentDynamicWorkflowProgress(info.parentToolCallId, (progress) => { + progress.markSuspended({ agentId: event.subagentId, reason: event.reason, dynamicWorkflowIndex: info.dynamicWorkflowIndex, @@ -571,8 +540,8 @@ export class SubAgentEventHandler { info: SubagentInfo, ): void { const { parentToolCallId } = info; - if (this.updateDynamicWorkflowMissionControl(parentToolCallId, (missionControl) => { - missionControl.markCompleted(event.subagentId, event.resultSummary); + if (this.updateAgentDynamicWorkflowProgress(parentToolCallId, (progress) => { + progress.markCompleted(event.subagentId, event.resultSummary); })) { this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); return; @@ -593,8 +562,8 @@ export class SubAgentEventHandler { info: SubagentInfo, ): void { const { parentToolCallId } = info; - if (this.updateDynamicWorkflowMissionControl(parentToolCallId, (missionControl) => { - this.markDynamicWorkflowFailedOrCancelled(missionControl, event.subagentId, event.error); + if (this.updateAgentDynamicWorkflowProgress(parentToolCallId, (progress) => { + this.markAgentDynamicWorkflowFailedOrCancelled(progress, event.subagentId, event.error); })) { this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); return; @@ -606,148 +575,114 @@ export class SubAgentEventHandler { this.host.streamingUI.removeToolComponentIfInactive(parentToolCallId); } - private applySubagentEventToDynamicWorkflow( - missionControl: DynamicWorkflowMissionControlComponent, + private applySubagentEventToDynamicWorkflowProgress( + progress: AgentDynamicWorkflowProgressComponent, event: Event, subagentId: string, ): void { if (event.type === 'assistant.delta' || event.type === 'thinking.delta') { - missionControl.appendModelDelta({ agentId: subagentId, delta: event.delta }); + progress.appendModelDelta({ agentId: subagentId, delta: event.delta }); } else if (event.type === 'tool.call.started') { - missionControl.recordToolCall({ - agentId: subagentId, - name: event.name, - }); + progress.recordToolCall({ agentId: subagentId, toolCallId: event.toolCallId }); + } else if (event.type === 'agent.status.updated' && event.model !== undefined) { + // The bound model alias rides every child status update (emitted right + // after spawn). DynamicWorkflow members share one binding, so the panel shows it + // once in the header instead of per cell. `modelDisplayName` falls back + // to the alias itself when the entry is unknown. + progress.setModelDisplay( + modelDisplayName(event.model, this.host.state.appState.availableModels[event.model]), + ); + const effortDisplay = this.subagentEffortDisplay(event.thinkingEffort); + if (effortDisplay !== undefined) progress.setEffortDisplay(effortDisplay); } } - private updateDynamicWorkflowMissionControl( + private updateAgentDynamicWorkflowProgress( parentToolCallId: string, - update: (missionControl: DynamicWorkflowMissionControlComponent) => void, + update: (progress: AgentDynamicWorkflowProgressComponent) => void, ): boolean { - const missionControl = this.dynamicWorkflowMissionControls.get(parentToolCallId); - if (missionControl === undefined) return false; - update(missionControl); + const progress = this.agentDynamicWorkflowProgress.get(parentToolCallId); + if (progress === undefined) return false; + update(progress); this.requestRender(); return true; } - private ensureDynamicWorkflowMissionControl( + private ensureAgentDynamicWorkflowProgress( toolCallId: string, args: Record<string, unknown>, - options: { readonly streamingArguments?: string } = {}, - ): DynamicWorkflowMissionControlComponent { - const existing = this.dynamicWorkflowMissionControls.get(toolCallId); + options: { readonly streamingArguments?: string | undefined } = {}, + ): AgentDynamicWorkflowProgressComponent { + const existing = this.agentDynamicWorkflowProgress.get(toolCallId); if (existing !== undefined) { existing.updateArgs(args, options); return existing; } - let missionControl: DynamicWorkflowMissionControlComponent | undefined; - missionControl = new DynamicWorkflowMissionControlComponent({ - description: dynamicWorkflowDescriptionFromArgs(args), - availableRows: () => this.dynamicWorkflowAvailableRows(missionControl), + const progress = new AgentDynamicWorkflowProgressComponent({ + description: agentDynamicWorkflowDescriptionFromArgs(args), + availableGridHeight: () => this.agentDynamicWorkflowGridHeight(), + requestRender: () => { + this.requestRender(); + }, }); - missionControl.updateArgs(args, options); - this.dynamicWorkflowMissionControls.set(toolCallId, missionControl); + progress.updateArgs(args, options); + this.agentDynamicWorkflowProgress.set(toolCallId, progress); this.host.streamingUI.finalizeLiveTextBuffers('tool'); - this.host.state.transcriptContainer.addTranscriptChild(missionControl, { - role: 'live-durable', - edgeBlankPolicy: 'trim-plain', - }); + this.host.state.transcriptContainer.addChild(progress); this.host.updateActivityPane(); this.requestRender(); - return missionControl; + return progress; } - private removeDynamicWorkflowMissionControl( + private removeAgentDynamicWorkflowProgress( toolCallId: string, - missionControl: DynamicWorkflowMissionControlComponent, + progress: AgentDynamicWorkflowProgressComponent, ): void { - this.dynamicWorkflowMissionControls.delete(toolCallId); - this.retiredDynamicWorkflowToolCallIds.add(toolCallId); - this.forgetDynamicWorkflowSubagents(toolCallId); - this.pendingLifecycleByParentToolCallId.delete(toolCallId); + this.agentDynamicWorkflowProgress.delete(toolCallId); + progress.dispose(); const children = this.host.state.transcriptContainer.children; - const index = children.indexOf(missionControl); + const index = children.indexOf(progress); if (index >= 0) { + // Structural removal only: GutterContainer's ref-checked render cache + // detects the child-list change; no tree-wide invalidate needed. children.splice(index, 1); } this.host.updateActivityPane(); } - private forgetDynamicWorkflowSubagents(toolCallId: string): void { - for (const [agentId, info] of this.subagentInfo) { - if (info.parentToolCallId !== toolCallId) continue; - this.subagentInfo.delete(agentId); - } - } - - private dynamicWorkflowAvailableRows(missionControl: Component | undefined): number | undefined { + private agentDynamicWorkflowGridHeight(): number | undefined { const { state } = this.host; const terminalRows = state.ui.terminal.rows; const terminalColumns = state.ui.terminal.columns; - if (!Number.isFinite(terminalRows)) return undefined; if (!Number.isFinite(terminalColumns) || terminalColumns <= 0) { - return Math.max(0, Math.floor(terminalRows)); + return agentDynamicWorkflowGridHeightForTerminalRows(terminalRows); } const width = Math.floor(terminalColumns); - const followingTranscriptRows = missionControl === undefined - ? 0 - : state.transcriptContainer.renderedRowsAfterChild(width, missionControl); - // Under the fixed layout the transcript lives inside the layout root, so - // the rows below it include the root's chrome + footer measurement and - // later transcript siblings. - const followingRows = - state.layout === 'fixed' - ? state.layoutRoot.followingRows(width) + followingTranscriptRows - : renderedRowsAfterChild(state.ui.children, state.transcriptContainer, width); - return Math.max(0, Math.floor(terminalRows) - Math.max(0, Math.floor(followingRows))); - } - - private markDynamicWorkflowFailedOrCancelled( - missionControl: DynamicWorkflowMissionControlComponent, + const dock = state.dockContainer; + // Fullscreen: the root children are empty (layout root holds a ScrollView + + // dock); the chrome below the transcript is the dock's children instead. + const rowsAfterDynamicWorkflow = renderedRowsAfterChild( + dock !== undefined ? [state.transcriptContainer, ...dock.children] : state.ui.children, + state.transcriptContainer, + width, + ); + return agentDynamicWorkflowGridHeightForTerminalRows(terminalRows, rowsAfterDynamicWorkflow); + } + + private markAgentDynamicWorkflowFailedOrCancelled( + progress: AgentDynamicWorkflowProgressComponent, subagentId: string, error: string, ): void { if (isUserCancelledSubagentError(error)) { - missionControl.markCancelled(subagentId); + progress.markCancelled(subagentId); } else { - missionControl.markFailed(subagentId, error); - } - } - - private bufferPendingLifecycle( - parentToolCallId: string, - event: SubagentLifecycleEvent, - ): void { - // Keyed by parent tool call so a later workflow reusing the same agent id - // cannot drain events left over from an earlier generation. - const pendingByAgentId = - this.pendingLifecycleByParentToolCallId.get(parentToolCallId) ?? new Map(); - const pending = pendingByAgentId.get(event.subagentId) ?? []; - pending.push(event); - pendingByAgentId.set(event.subagentId, pending); - this.pendingLifecycleByParentToolCallId.set(parentToolCallId, pendingByAgentId); - } - - private deletePendingLifecycle(parentToolCallId: string, agentId: string): void { - const pendingByAgentId = this.pendingLifecycleByParentToolCallId.get(parentToolCallId); - if (pendingByAgentId === undefined) return; - pendingByAgentId.delete(agentId); - if (pendingByAgentId.size === 0) { - this.pendingLifecycleByParentToolCallId.delete(parentToolCallId); + progress.markFailed(subagentId, error); } } - private drainPendingLifecycle(parentToolCallId: string, agentId: string): void { - const pending = this.pendingLifecycleByParentToolCallId.get(parentToolCallId)?.get(agentId); - if (pending === undefined) return; - this.deletePendingLifecycle(parentToolCallId, agentId); - for (const event of pending) this.handleLifecycleEvent(event); - } - private getOrActivateToolComponent(parentToolCallId: string) { let component = this.host.streamingUI.getToolComponent(parentToolCallId); if (component !== undefined) return component; @@ -793,7 +728,7 @@ function isSubagentLifecycleEvent(event: Event): event is SubagentLifecycleEvent } function isUserCancelledSubagentError(error: string): boolean { - // Structured Dynamic Workflow results use outcome="aborted" and are parsed separately. + // Structured AgentDynamicWorkflow results use outcome="aborted" and are parsed separately. switch (error.trim()) { case 'Aborted by the user': case 'The user manually interrupted this subagent batch.': diff --git a/apps/pythinker-code/src/tui/controllers/tasks-browser.ts b/apps/pythinker-code/src/tui/controllers/tasks-browser.ts index 5fdb4c84..22522e02 100644 --- a/apps/pythinker-code/src/tui/controllers/tasks-browser.ts +++ b/apps/pythinker-code/src/tui/controllers/tasks-browser.ts @@ -1,10 +1,18 @@ import type { BackgroundTaskInfo, Session } from '@pymodel/pythinker-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@earendil-works/pi-tui'; +import type { ProcessTerminal, TUI } from '@pymodel/pi-tui'; +import { AgentActivityViewer, formatSubagentActivityPreview } from '../components/dialogs/agent-activity-viewer'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; +import { + beginScreenTakeover, + endScreenTakeover, + type ScreenTakeover, +} from '../utils/screen-takeover'; +import type { SessionEventHandler } from './session-event-handler'; +import type { SubagentActivityRecord } from './subagent-activity-store'; export interface TasksBrowserHost { readonly state: { @@ -15,6 +23,7 @@ export interface TasksBrowserHost { readonly editor: CustomEditor; }; readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>; + readonly sessionEventHandler: SessionEventHandler; readonly session: Session | undefined; showError(msg: string): void; setTasksBrowser(value: TasksBrowserState | undefined): void; @@ -22,7 +31,7 @@ export interface TasksBrowserHost { export type TasksBrowserState = { component: TasksBrowserApp; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; filter: TasksFilter; selectedTaskId: string | undefined; tailOutput: string | undefined; @@ -33,8 +42,8 @@ export type TasksBrowserState = { pollTimer: NodeJS.Timeout | undefined; viewer: | { - component: TaskOutputViewer; - savedChildren: readonly Component[]; + component: TaskOutputViewer | AgentActivityViewer; + takeover: ScreenTakeover; taskId: string; output: string; refreshId: number; @@ -82,9 +91,7 @@ export class TasksBrowserController { state.terminal, ); - const savedChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(component); + const takeover = beginScreenTakeover(state.ui, component); state.ui.setFocus(component); state.ui.requestRender(true); @@ -94,7 +101,7 @@ export class TasksBrowserController { this.host.setTasksBrowser({ component, - savedChildren, + takeover, filter, selectedTaskId, tailOutput: undefined, @@ -119,10 +126,7 @@ export class TasksBrowserController { if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); - state.ui.clear(); - for (const child of browser.savedChildren) { - state.ui.addChild(child); - } + endScreenTakeover(state.ui, browser.takeover); this.host.setTasksBrowser(undefined); state.ui.setFocus(state.editor); state.ui.requestRender(true); @@ -140,6 +144,8 @@ export class TasksBrowserController { const browser = state.tasksBrowser; const viewer = browser?.viewer; if (browser === undefined || viewer === undefined) return; + // The agent activity viewer refreshes from the local store, not the RPC. + if (viewer.component instanceof AgentActivityViewer) return; const session = this.host.session; if (session === undefined) return; @@ -214,9 +220,26 @@ export class TasksBrowserController { return; } if (state.tasksBrowser !== browser) return; + this.syncAgentPreview(); this.pushProps(tasks); } + /** Agent tasks capture output only on completion, so while one is selected + * the Preview frame is fed from the in-memory activity store instead. */ + private syncAgentPreview(): void { + const browser = this.host.state.tasksBrowser; + const selectedTaskId = browser?.selectedTaskId; + if (browser === undefined || selectedTaskId === undefined) return; + const info = this.host.backgroundTasks.get(selectedTaskId); + if (info?.kind !== 'agent' || info.agentId === undefined) return; + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record === undefined) return; + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + } + private pushProps(tasks: readonly BackgroundTaskInfo[]): void { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; @@ -233,7 +256,7 @@ export class TasksBrowserController { } private buildCallbacks(): { - onSelect: (taskId: string | undefined) => void; + onSelect: (taskId: string) => void; onToggleFilter: () => void; onRefresh: () => void; onCancel: () => void; @@ -268,18 +291,15 @@ export class TasksBrowserController { }; } - private handleSelect(taskId: string | undefined): void { + private handleSelect(taskId: string): void { const browser = this.host.state.tasksBrowser; if (browser === undefined) return; if (browser.selectedTaskId === taskId) return; browser.selectedTaskId = taskId; browser.tailOutput = undefined; - browser.tailLoading = taskId !== undefined; - // Deselection (no visible task under the active filter) invalidates any - // in-flight tail request by bumping its id. - if (taskId === undefined) browser.tailRequestId += 1; + browser.tailLoading = true; this.repaint(); - if (taskId !== undefined) this.loadTail(taskId); + this.loadTail(taskId); } private handleToggleFilter(): void { @@ -320,6 +340,20 @@ export class TasksBrowserController { if (browser === undefined) return; if (browser.viewer !== undefined) return; + // Agent tasks get the activity detail view when this process holds a + // record for the agent; otherwise (e.g. a `lost` task after resume) fall + // through to the captured-output viewer. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + this.openAgentActivityViewer(taskId, info, record); + return; + } + } + const session = this.host.session; if (session === undefined) { this.flash('No active session.'); @@ -337,7 +371,6 @@ export class TasksBrowserController { const current = state.tasksBrowser; if (current === undefined || current !== browser) return; - const info = this.host.backgroundTasks.get(taskId); const viewer = new TaskOutputViewer( { taskId, @@ -350,9 +383,7 @@ export class TasksBrowserController { state.terminal, ); - const savedBrowserChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(viewer); + const takeover = beginScreenTakeover(state.ui, viewer); state.ui.setFocus(viewer); state.ui.requestRender(true); @@ -362,7 +393,7 @@ export class TasksBrowserController { browser.viewer = { component: viewer, - savedChildren: savedBrowserChildren, + takeover, taskId, output, refreshId: 0, @@ -370,11 +401,88 @@ export class TasksBrowserController { }; } + private openAgentActivityViewer( + taskId: string, + info: BackgroundTaskInfo, + record: SubagentActivityRecord, + ): void { + const { state } = this.host; + const browser = state.tasksBrowser; + if (browser === undefined || browser.viewer !== undefined) return; + + const viewer = new AgentActivityViewer( + { + taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }, + state.terminal, + ); + + const takeover = beginScreenTakeover(state.ui, viewer); + state.ui.setFocus(viewer); + state.ui.requestRender(true); + + // The activity store is in-memory — refreshing is a local read, no RPC. + const pollTimer = setInterval(() => { + this.refreshAgentActivityViewer(); + }, 1000); + + browser.viewer = { + component: viewer, + takeover, + taskId, + output: '', + refreshId: 0, + pollTimer, + }; + } + + private refreshAgentActivityViewer(): void { + const { state } = this.host; + const viewer = state.tasksBrowser?.viewer; + if (viewer === undefined || !(viewer.component instanceof AgentActivityViewer)) return; + + const info = this.host.backgroundTasks.get(viewer.taskId); + const agentId = info?.kind === 'agent' ? info.agentId : undefined; + const record = + agentId === undefined + ? undefined + : this.host.sessionEventHandler.subAgentEventHandler.activityStore.get(agentId); + viewer.component.setProps({ + taskId: viewer.taskId, + info, + record, + onClose: () => { + this.closeOutputViewer(); + }, + }); + state.ui.requestRender(); + } + private loadTail(taskId: string): void { const { state } = this.host; const browser = state.tasksBrowser; if (browser === undefined) return; + // Agent tasks capture output only on completion — serve the preview from + // the in-memory activity store instead of the RPC when a record exists. + const info = this.host.backgroundTasks.get(taskId); + if (info !== undefined && info.kind === 'agent' && info.agentId !== undefined) { + const record = this.host.sessionEventHandler.subAgentEventHandler.activityStore.get( + info.agentId, + ); + if (record !== undefined) { + browser.tailOutput = formatSubagentActivityPreview(record); + browser.tailLoading = false; + this.repaint(); + return; + } + } + const session = this.host.session; if (session === undefined) { browser.tailLoading = false; @@ -426,10 +534,7 @@ export class TasksBrowserController { const viewer = browser.viewer; clearInterval(viewer.pollTimer); browser.viewer = undefined; - this.host.state.ui.clear(); - for (const child of viewer.savedChildren) { - this.host.state.ui.addChild(child); - } + endScreenTakeover(this.host.state.ui, viewer.takeover); this.host.state.ui.setFocus(browser.component); this.host.state.ui.requestRender(true); } diff --git a/apps/pythinker-code/src/tui/easter-eggs/dance.ts b/apps/pythinker-code/src/tui/easter-eggs/dance.ts new file mode 100644 index 00000000..3b7beee2 --- /dev/null +++ b/apps/pythinker-code/src/tui/easter-eggs/dance.ts @@ -0,0 +1,248 @@ +/** + * `/dance` easter egg — everything it needs lives in this one file: the + * rainbow text coloring, the animation state machine, and the command handler. + * Removing the feature is "delete this file + its import sites". + * + * It is deliberately NOT registered in BUILTIN_SLASH_COMMANDS, so it stays out + * of `/help` and autocomplete; `executeSlashCommand` calls the handler as a + * fallback after builtin/skill resolution, so a real command or a same-named + * skill always wins. + */ + +import chalk from 'chalk'; +import { truncateToWidth, visibleWidth } from '@pymodel/pi-tui'; + +import type { SlashCommandHost } from '../commands/dispatch'; +import type { ParsedSlashInput } from '../commands/types'; +import { currentTheme } from '../theme'; + +/** Frame interval for the rainbow flow animation. */ +export const DANCE_FRAME_MS = 110; +/** How long the rainbow flows before settling (fading out, or freezing). */ +export const DANCE_FLOW_MS = 3000; + +const DARK_RAINBOW = [ + '#4FA8FF', + '#5BC0BE', + '#4EC87E', + '#E8A838', + '#FFCB6B', + '#C678B8', + '#A274D9', + '#7C8DFF', +] as const; + +const LIGHT_RAINBOW = [ + '#1565C0', + '#00838F', + '#0E7A38', + '#92660A', + '#9A4A00', + '#B91C1C', + '#8A3A75', + '#6B3A9A', + '#354CB5', +] as const; + +function getDanceRainbowPalette(): readonly [string, ...string[]] { + return currentTheme.palette.text === '#1A1A1A' ? LIGHT_RAINBOW : DARK_RAINBOW; +} + +/** Paint a string character-by-character through a palette, skipping spaces. */ +export function rainbowText( + text: string, + colors: readonly [string, ...string[]], + offset = 0, + bold = false, +): string { + let colorIndex = offset; + return Array.from(text) + .map((char) => { + if (char === ' ') return char; + const color = colors[colorIndex % colors.length] ?? colors[0]; + colorIndex++; + const style = chalk.hex(color); + return bold ? style.bold(char) : style(char); + }) + .join(''); +} + +/** Read-only view of the dance state for components that only render it. */ +export interface RainbowDanceView { + /** Whether consumers should paint themselves in rainbow at all. */ + readonly colored: boolean; + /** Palette offset, advancing while the rainbow flows. */ + readonly phase: number; +} + +export interface RainbowDanceController extends RainbowDanceView { + start(opts: { hold: boolean }): void; + stop(): void; + dispose(): void; +} + +let currentDanceController: RainbowDanceController | undefined; +let currentDanceView: RainbowDanceView | undefined; + +export function setRainbowDance(dance: RainbowDanceController | undefined): void { + currentDanceController = dance; + currentDanceView = dance; +} + +export function installRainbowDance(requestRender: () => void): () => void { + currentDanceController?.dispose(); + const dance = new RainbowDance(requestRender); + setRainbowDance(dance); + return () => { + dance.dispose(); + if (currentDanceController === dance) { + setRainbowDance(undefined); + } + }; +} + +export function getRainbowDanceView(): RainbowDanceView | undefined { + return currentDanceView; +} + +export function isRainbowDancing(): boolean { + return currentDanceView?.colored === true; +} + +export function renderDanceWelcomeHeader( + logo: readonly [string, string], + textWidth: number, + rightRow1: string, +): string[] { + const phase = currentDanceView?.phase ?? 0; + const palette = getDanceRainbowPalette(); + const logoWidth = Math.max(...logo.map((row) => visibleWidth(row))); + const gap = ' '; + const rightRow0 = truncateToWidth( + rainbowText('Welcome to Pythinker Code!', palette, phase + 2, true), + textWidth, + '…', + ); + + return [ + rainbowText(logo[0].padEnd(logoWidth), palette, phase) + gap + rightRow0, + rainbowText(logo[1].padEnd(logoWidth), palette, phase + 3) + gap + rightRow1, + ]; +} + +export function renderDanceFooterModel(modelLabel: string): string { + return rainbowText(modelLabel, getDanceRainbowPalette(), currentDanceView?.phase ?? 0); +} + +/** + * Drives the rainbow: a single timer advances a shared `phase` and asks the UI + * to repaint. Lives independently of any component, so the welcome banner + * scrolling away or being rebuilt never disturbs the animation. Three states: + * off (default), flowing, and a frozen static rainbow. + */ +export class RainbowDance implements RainbowDanceController { + private currentPhase = 0; + private isColored = false; + private frameTimer: ReturnType<typeof setInterval> | null = null; + private flowStopTimer: ReturnType<typeof setTimeout> | null = null; + private readonly requestRender: () => void; + + constructor(requestRender: () => void) { + this.requestRender = requestRender; + } + + get colored(): boolean { + return this.isColored; + } + + get phase(): number { + return this.currentPhase; + } + + /** + * Flow the rainbow for `DANCE_FLOW_MS`, then settle: + * - `hold: false` → fade back to the default (uncolored) banner. + * - `hold: true` → freeze into a static rainbow that stays on. + */ + start(opts: { hold: boolean }): void { + this.clearTimers(); + this.isColored = true; + this.frameTimer = setInterval(() => { + // Phase just increments; rainbowText() takes it modulo the *current* + // palette length, so the dance never needs to know the palette size. + this.currentPhase += 1; + this.requestRender(); + }, DANCE_FRAME_MS); + this.flowStopTimer = setTimeout(() => { + this.settle(opts.hold); + }, DANCE_FLOW_MS); + this.requestRender(); + } + + /** Turn the rainbow off — back to the default colors. */ + stop(): void { + this.clearTimers(); + this.isColored = false; + this.currentPhase = 0; + this.requestRender(); + } + + /** + * Clear timers without repainting — for shutdown, where the UI is going + * away and a final render would be wasted or write to a stopped terminal. + */ + dispose(): void { + this.clearTimers(); + } + + /** End the flow: freeze the rainbow (hold) or fade back to default. */ + private settle(hold: boolean): void { + this.clearTimers(); + if (!hold) { + this.isColored = false; + this.currentPhase = 0; + } + this.requestRender(); + } + + private clearTimers(): void { + if (this.frameTimer !== null) { + clearInterval(this.frameTimer); + this.frameTimer = null; + } + if (this.flowStopTimer !== null) { + clearTimeout(this.flowStopTimer); + this.flowStopTimer = null; + } + } +} + +/** + * Handle `/dance`: + * /dance flow for a few seconds, then fade back to the default colors + * /dance on flow, then freeze into a static rainbow that stays on + * /dance off turn the rainbow off + * + * Returns true when it claimed the input. + */ +export function tryHandleDanceCommand(host: SlashCommandHost, parsed: ParsedSlashInput): boolean { + if (parsed.name !== 'dance') return false; + if (currentDanceController === undefined) return false; + + // The status line dims the whole message, which buried the command in the + // hint. Paint just the command in the brand color (bold) so it reads as a + // command; chalk nesting resumes the dim run right after it. + const cmd = (text: string): string => currentTheme.boldFg('primary', text); + + const sub = parsed.args.trim().toLowerCase(); + if (sub === 'off') { + currentDanceController.stop(); + } else if (sub === 'on') { + currentDanceController.start({ hold: true }); + host.showStatus(`Dancing — use ${cmd('/dance off')} to turn it off.`); + } else { + currentDanceController.start({ hold: false }); + host.showStatus(`Use ${cmd('/dance on')} to keep the rainbow on.`); + } + return true; +} diff --git a/apps/pythinker-code/src/tui/easter-eggs/rainbow-colors.ts b/apps/pythinker-code/src/tui/easter-eggs/rainbow-colors.ts deleted file mode 100644 index 9a0c2395..00000000 --- a/apps/pythinker-code/src/tui/easter-eggs/rainbow-colors.ts +++ /dev/null @@ -1,222 +0,0 @@ -import chalk from 'chalk'; - -import type { SlashCommandHost } from '../commands/dispatch'; -import { - buildWelcomeCopy, - type WelcomeBannerCopy, -} from '../components/chrome/welcome-banner'; -import { PYTHINKER_LOGO_LINES } from '../components/chrome/pythinker-logo'; -import { currentTheme } from '../theme'; - -/** Frame interval for the rainbow flow animation. */ -export const RAINBOW_FRAME_MS = 110; -/** How long the rainbow flows before fading out or freezing. */ -export const RAINBOW_FLOW_MS = 3000; - -function themedRainbowPalette(): readonly [string, ...string[]] { - const colors = currentTheme.palette; - return [ - colors.rainbowRed, - colors.rainbowOrange, - colors.rainbowYellow, - colors.rainbowGreen, - colors.rainbowBlue, - colors.rainbowIndigo, - colors.rainbowViolet, - ]; -} - -function makeRainbowPainter( - colors: readonly [string, ...string[]], - offset: number, - bold: boolean, -): (text: string) => string { - let colorIndex = offset; - return (text) => - Array.from(text) - .map((char) => { - if (char === ' ') return char; - const color = colors[colorIndex % colors.length] ?? colors[0]; - colorIndex++; - const style = chalk.hex(color); - return bold ? style.bold(char) : style(char); - }) - .join(''); -} - -/** Paint a string character-by-character through a palette, skipping spaces. */ -export function rainbowText( - text: string, - colors: readonly [string, ...string[]], - offset = 0, - bold = false, -): string { - return makeRainbowPainter(colors, offset, bold)(text); -} - -/** Read-only rainbow state for components that only render it. */ -export interface RainbowColorView { - /** Whether consumers should paint themselves in rainbow colors. */ - readonly colored: boolean; - /** Palette offset, advancing while the rainbow flows. */ - readonly phase: number; -} - -export interface RainbowColorController extends RainbowColorView { - start(options: { freeze: boolean }): void; - stop(): void; - dispose(): void; -} - -let currentRainbowController: RainbowColorController | undefined; -let currentRainbowView: RainbowColorView | undefined; - -export function setRainbowColors(controller: RainbowColorController | undefined): void { - currentRainbowController = controller; - currentRainbowView = controller; -} - -export function installRainbowColors(requestRender: () => void): () => void { - currentRainbowController?.dispose(); - const controller = new RainbowColorMode(requestRender); - setRainbowColors(controller); - return () => { - controller.dispose(); - if (currentRainbowController === controller) { - setRainbowColors(undefined); - } - }; -} - -export function getRainbowColorView(): RainbowColorView | undefined { - return currentRainbowView; -} - -export function isRainbowColorActive(): boolean { - return currentRainbowView?.colored === true; -} - -/** Create one stateful painter so consecutive render fragments share an offset. */ -export function createRainbowPainter(offset = currentRainbowView?.phase ?? 0): (text: string) => string { - return makeRainbowPainter(themedRainbowPalette(), offset, false); -} - -export function renderRainbowWelcomeCopy(isLoggedOut: boolean): WelcomeBannerCopy { - const phase = currentRainbowView?.phase ?? 0; - const palette = themedRainbowPalette(); - const base = buildWelcomeCopy(isLoggedOut); - return { - head: rainbowText('Welcome to Pythinker — think first, then code.', palette, phase, true), - strapline: rainbowText( - 'Review · Secure · Diagnose · Build with confidence.', - palette, - phase + 2, - ), - prompt: base.prompt, - }; -} - -export function renderRainbowWelcomeLogo(): string[] { - const phase = currentRainbowView?.phase ?? 0; - const palette = themedRainbowPalette(); - return PYTHINKER_LOGO_LINES.map((plain, index) => rainbowText(plain, palette, phase + index)); -} - -export function renderRainbowFooterModel(modelLabel: string): string { - return rainbowText(modelLabel, themedRainbowPalette(), currentRainbowView?.phase ?? 0); -} - -/** - * Drives a shared rainbow phase independently of any component. The mode can - * be off, flowing temporarily, or frozen after one flow. - */ -export class RainbowColorMode implements RainbowColorController { - private currentPhase = 0; - private isColored = false; - private frameTimer: ReturnType<typeof setInterval> | null = null; - private flowStopTimer: ReturnType<typeof setTimeout> | null = null; - private readonly requestRender: () => void; - - constructor(requestRender: () => void) { - this.requestRender = requestRender; - } - - get colored(): boolean { - return this.isColored; - } - - get phase(): number { - return this.currentPhase; - } - - /** Flow for RAINBOW_FLOW_MS, then freeze when requested or return to normal. */ - start(options: { freeze: boolean }): void { - this.clearTimers(); - this.isColored = true; - this.frameTimer = setInterval(() => { - this.currentPhase += 1; - this.requestRender(); - }, RAINBOW_FRAME_MS); - this.flowStopTimer = setTimeout(() => { - this.settle(options.freeze); - }, RAINBOW_FLOW_MS); - this.requestRender(); - } - - /** Turn rainbow colors off and return to the default theme treatment. */ - stop(): void { - this.clearTimers(); - this.isColored = false; - this.currentPhase = 0; - this.requestRender(); - } - - /** Clear timers silently during shutdown. */ - dispose(): void { - this.clearTimers(); - } - - private settle(freeze: boolean): void { - this.clearTimers(); - if (!freeze) { - this.isColored = false; - this.currentPhase = 0; - } - this.requestRender(); - } - - private clearTimers(): void { - if (this.frameTimer !== null) { - clearInterval(this.frameTimer); - this.frameTimer = null; - } - if (this.flowStopTimer !== null) { - clearTimeout(this.flowStopTimer); - this.flowStopTimer = null; - } - } -} - -/** Handle the built-in `/colors [on|off]` command. */ -export function handleColorsCommand(host: SlashCommandHost, args: string): void { - const mode = args.trim().toLowerCase(); - if (mode !== '' && mode !== 'on' && mode !== 'off') { - host.showError('Usage: /colors [on|off]'); - return; - } - if (currentRainbowController === undefined) return; - - const command = (text: string): string => currentTheme.boldFg('primary', text); - if (mode === 'off') { - currentRainbowController.stop(); - host.showStatus('Rainbow colors are off.'); - return; - } - if (mode === 'on') { - currentRainbowController.start({ freeze: true }); - host.showStatus(`Rainbow colors will stay on. Use ${command('/colors off')} to turn them off.`); - return; - } - currentRainbowController.start({ freeze: false }); - host.showStatus(`Use ${command('/colors on')} to keep the rainbow on.`); -} diff --git a/apps/pythinker-code/src/tui/editor/vim/editor-bridge.ts b/apps/pythinker-code/src/tui/editor/vim/editor-bridge.ts deleted file mode 100644 index 3a805c24..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/editor-bridge.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Quarantined bridge to private pi-tui 0.81.1 editor state. - * - * pi-tui has no public cursor setter, so vim integration must use these - * internals. Re-verify this file first before upgrading pi-tui. - */ - -import type { Editor } from '@earendil-works/pi-tui'; - -import { - graphemeColumnAtUtf16Offset, - graphemeLength, - utf16OffsetAtGraphemeColumn, -} from './graphemes'; -import type { VimBuffer } from './types'; - -const PI_TUI_VERSION = '0.81.1'; -const BRIDGE_FILE = 'apps/pythinker-code/src/tui/editor/vim/editor-bridge.ts'; -const verifiedEditors = new WeakSet<object>(); - -/** The private surface this bridge depends on. Pinned to pi-tui 0.81.1. */ -interface EditorInternals { - state: { cursorLine: number; cursorCol: number }; - pastes: Map<number, string>; - pasteCounter: number; -} - -function incompatibleInternals(): Error { - return new Error( - `Unsupported pi-tui editor internals. Version ${PI_TUI_VERSION} is required; re-verify ${BRIDGE_FILE}.`, - ); -} - -function getInternals(editor: Editor): EditorInternals { - const internals = editor as unknown as EditorInternals; - if (!verifiedEditors.has(editor)) { - if ( - typeof internals.state !== 'object' - || internals.state === null - || typeof internals.state.cursorLine !== 'number' - || typeof internals.state.cursorCol !== 'number' - || !(internals.pastes instanceof Map) - || typeof internals.pasteCounter !== 'number' - ) { - throw incompatibleInternals(); - } - for (const [id, content] of internals.pastes) { - if (typeof id !== 'number' || typeof content !== 'string') { - throw incompatibleInternals(); - } - } - verifiedEditors.add(editor); - } - return internals; -} - -function clamp(value: number, maximum: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(Math.trunc(value), maximum)); -} - -function hasPasteMarker(text: string, pasteId: number): boolean { - const marker = new RegExp( - `\\[paste #${String(pasteId)}(?: (?:\\+\\d+ lines|\\d+ chars))?\\]`, - 'u', - ); - return marker.test(text); -} - -/** Read the editor into a VimBuffer. */ -export function readBuffer(editor: Editor): VimBuffer { - getInternals(editor); - const cursor = editor.getCursor(); - const lines = editor.getLines(); - const line = clamp(cursor.line, lines.length - 1); - return { - lines, - line, - // pi-tui cursors are UTF-16 offsets; the engine works in grapheme columns. - column: graphemeColumnAtUtf16Offset(lines[line] ?? '', cursor.col), - }; -} - -/** Apply a vim result back. Preserves paste payloads and only writes when needed. */ -export function applyBuffer(editor: Editor, next: VimBuffer): void { - const internals = getInternals(editor); - const nextText = next.lines.join('\n'); - - if (nextText !== editor.getText()) { - const savedPastes = new Map(internals.pastes); - const savedPasteCounter = internals.pasteCounter; - editor.setText(nextText); - internals.pastes = savedPastes; - internals.pasteCounter = savedPasteCounter; - - for (const pasteId of internals.pastes.keys()) { - if (!hasPasteMarker(nextText, pasteId)) { - internals.pastes.delete(pasteId); - } - } - } - - const lines = editor.getLines(); - const cursorLine = clamp(next.line, lines.length - 1); - const cursorText = lines[cursorLine] ?? ''; - const cursorColumn = clamp(next.column, graphemeLength(cursorText)); - internals.state.cursorLine = cursorLine; - // Convert back to the UTF-16 offset pi-tui stores, so surrogate pairs and - // combining sequences are never split at the cursor. - internals.state.cursorCol = utf16OffsetAtGraphemeColumn(cursorText, cursorColumn); - editor.invalidate(); -} diff --git a/apps/pythinker-code/src/tui/editor/vim/graphemes.ts b/apps/pythinker-code/src/tui/editor/vim/graphemes.ts deleted file mode 100644 index 0f0ff81a..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/graphemes.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Grapheme-cluster text helpers for the Vim engine. - * - * The engine tracks cursor columns and edit ranges in grapheme units (a user - * perceives a combining sequence or emoji ZWJ cluster as one character), - * while pi-tui and raw strings work in UTF-16 offsets. `Intl.Segmenter` is - * used instead of `Array.from` so that combining marks, flags, and ZWJ emoji - * are never split. - */ -const graphemeSegmenter = new Intl.Segmenter(undefined, { - granularity: 'grapheme', -}); - -function clamp(value: number, maximum: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(Math.trunc(value), maximum)); -} - -/** Splits a string into its grapheme clusters. */ -export function graphemes(value: string): readonly string[] { - return Array.from( - graphemeSegmenter.segment(value), - ({ segment }) => segment, - ); -} - -/** Counts grapheme clusters without materializing the split array. */ -export function graphemeLength(value: string): number { - let length = 0; - for (const _segment of graphemeSegmenter.segment(value)) { - length += 1; - } - return length; -} - -/** True when `value` is exactly one grapheme cluster (possibly multi-codepoint). */ -export function isSingleGrapheme(value: string): boolean { - if (value.length === 0) return false; - const segments = graphemeSegmenter.segment(value)[Symbol.iterator](); - return !segments.next().done && segments.next().done === true; -} - -/** Converts a UTF-16 offset into a grapheme column, clamped to the string. */ -export function graphemeColumnAtUtf16Offset( - value: string, - offset: number, -): number { - const target = clamp(offset, value.length); - let column = 0; - for (const { index, segment } of graphemeSegmenter.segment(value)) { - if (index + segment.length > target) break; - column += 1; - } - return column; -} - -/** Converts a grapheme column into the UTF-16 offset of that cluster's start. */ -export function utf16OffsetAtGraphemeColumn( - value: string, - column: number, -): number { - const target = Math.max(0, Math.trunc(Number.isFinite(column) ? column : 0)); - let currentColumn = 0; - for (const { index } of graphemeSegmenter.segment(value)) { - if (currentColumn >= target) return index; - currentColumn += 1; - } - return value.length; -} diff --git a/apps/pythinker-code/src/tui/editor/vim/index.ts b/apps/pythinker-code/src/tui/editor/vim/index.ts deleted file mode 100644 index c21e7858..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './motions'; -export * from './operators'; -export * from './state-machine'; -export * from './text-objects'; -export * from './types'; -export * from './visual'; diff --git a/apps/pythinker-code/src/tui/editor/vim/motions.ts b/apps/pythinker-code/src/tui/editor/vim/motions.ts deleted file mode 100644 index b1b91d32..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/motions.ts +++ /dev/null @@ -1,530 +0,0 @@ -import { graphemes } from './graphemes'; -import type { FindType, VimBuffer } from './types'; - -type CharacterKind = 'blank' | 'keyword' | 'punctuation'; - -interface FlatBuffer { - readonly characters: readonly string[]; - readonly positions: readonly ({ readonly line: number; readonly column: number } | null)[]; - readonly cursorOffset: number; -} - -const KEYWORD_CHARACTER = /[\p{Letter}\p{Number}_]/u; -const BLANK_CHARACTER = /\s/u; - -function nonNegativeInteger(value: number): number { - if (!Number.isFinite(value)) { - return Number.MAX_SAFE_INTEGER; - } - return Math.max(0, Math.floor(value)); -} - -function lineIndex(buffer: VimBuffer): number { - if (buffer.lines.length === 0) { - return 0; - } - return Math.min(Math.max(0, Math.floor(buffer.line)), buffer.lines.length - 1); -} - -function lineCharacters(buffer: VimBuffer, line: number): readonly string[] { - return graphemes(buffer.lines[line] ?? ''); -} - -function normalColumn(characters: readonly string[], column: number): number { - if (characters.length === 0) { - return 0; - } - return Math.min(Math.max(0, Math.floor(column)), characters.length - 1); -} - -function at(buffer: VimBuffer, line: number, column: number): VimBuffer { - return { lines: buffer.lines, line, column }; -} - -function clampNormal(buffer: VimBuffer): VimBuffer { - const line = lineIndex(buffer); - return at(buffer, line, normalColumn(lineCharacters(buffer, line), buffer.column)); -} - -function firstNonBlankColumn(characters: readonly string[]): number { - const column = characters.findIndex((character) => !BLANK_CHARACTER.test(character)); - return Math.max(0, column); -} - -function characterKind(character: string, bigWord: boolean): CharacterKind { - if (BLANK_CHARACTER.test(character)) { - return 'blank'; - } - if (bigWord || KEYWORD_CHARACTER.test(character)) { - return 'keyword'; - } - return 'punctuation'; -} - -function flatten(buffer: VimBuffer): FlatBuffer { - const current = clampNormal(buffer); - const characters: string[] = []; - const positions: ({ readonly line: number; readonly column: number } | null)[] = []; - let cursorOffset = 0; - - for (let line = 0; line < buffer.lines.length; line += 1) { - const linePoints = lineCharacters(buffer, line); - if (line === current.line) { - cursorOffset = characters.length + current.column; - } - for (let column = 0; column < linePoints.length; column += 1) { - const character = linePoints[column]; - if (character !== undefined) { - characters.push(character); - positions.push({ line, column }); - } - } - if (line < buffer.lines.length - 1) { - characters.push('\n'); - positions.push(null); - } - } - - return { characters, positions, cursorOffset }; -} - -function fromOffset(buffer: VimBuffer, flat: FlatBuffer, offset: number): VimBuffer { - const position = flat.positions[offset]; - return position === undefined || position === null - ? clampNormal(buffer) - : at(buffer, position.line, position.column); -} - -function repeatOffset( - initialOffset: number, - count: number, - step: (offset: number) => number | null, -): number { - let offset = initialOffset; - const repetitions = nonNegativeInteger(count); - for (let index = 0; index < repetitions; index += 1) { - const next = step(offset); - if (next === null || next === offset) { - break; - } - offset = next; - } - return offset; -} - -function nextWordOffset( - characters: readonly string[], - offset: number, - bigWord: boolean, -): number | null { - if (offset >= characters.length) { - return null; - } - - let index = offset; - const initialKind = characterKind(characters[index] ?? '', bigWord); - if (initialKind === 'blank') { - while ( - index < characters.length - && characterKind(characters[index] ?? '', bigWord) === 'blank' - ) { - index += 1; - } - } else { - while ( - index < characters.length - && characterKind(characters[index] ?? '', bigWord) === initialKind - ) { - index += 1; - } - while ( - index < characters.length - && characterKind(characters[index] ?? '', bigWord) === 'blank' - ) { - index += 1; - } - } - - return index < characters.length ? index : null; -} - -function previousWordOffset( - characters: readonly string[], - offset: number, - bigWord: boolean, -): number | null { - let index = offset - 1; - while ( - index >= 0 - && characterKind(characters[index] ?? '', bigWord) === 'blank' - ) { - index -= 1; - } - if (index < 0) { - return null; - } - - const kind = characterKind(characters[index] ?? '', bigWord); - while ( - index > 0 - && characterKind(characters[index - 1] ?? '', bigWord) === kind - ) { - index -= 1; - } - return index; -} - -function wordEndOffset( - characters: readonly string[], - offset: number, - bigWord: boolean, -): number | null { - if (offset >= characters.length) { - return null; - } - - let index = offset; - let kind = characterKind(characters[index] ?? '', bigWord); - if (kind === 'blank') { - while ( - index < characters.length - && characterKind(characters[index] ?? '', bigWord) === 'blank' - ) { - index += 1; - } - if (index >= characters.length) { - return null; - } - kind = characterKind(characters[index] ?? '', bigWord); - } else if ( - index + 1 >= characters.length - || characterKind(characters[index + 1] ?? '', bigWord) !== kind - ) { - index += 1; - while ( - index < characters.length - && characterKind(characters[index] ?? '', bigWord) === 'blank' - ) { - index += 1; - } - if (index >= characters.length) { - return null; - } - kind = characterKind(characters[index] ?? '', bigWord); - } - - while ( - index + 1 < characters.length - && characterKind(characters[index + 1] ?? '', bigWord) === kind - ) { - index += 1; - } - return index; -} - -function moveWord( - buffer: VimBuffer, - count: number, - bigWord: boolean, - step: ( - characters: readonly string[], - offset: number, - bigWord: boolean, - ) => number | null, -): VimBuffer { - const flat = flatten(buffer); - const offset = repeatOffset(flat.cursorOffset, count, (current) => - step(flat.characters, current, bigWord), - ); - return fromOffset(buffer, flat, offset); -} - -function targetCharacter(character: string): string | null { - const characters = graphemes(character); - return characters.length === 1 ? characters[0] ?? null : null; -} - -function findColumn( - buffer: VimBuffer, - count: number, - character: string, - direction: 1 | -1, - skipAdjacentTarget = false, -): number | null { - const current = clampNormal(buffer); - const target = targetCharacter(character); - if (target === null) { - return null; - } - - const characters = lineCharacters(current, current.line); - let remaining = Math.max(1, nonNegativeInteger(count)); - let firstColumn = current.column + direction; - if (skipAdjacentTarget && characters[firstColumn] === target) { - firstColumn += direction; - } - for ( - let column = firstColumn; - column >= 0 && column < characters.length; - column += direction - ) { - if (characters[column] === target) { - remaining -= 1; - if (remaining === 0) { - return column; - } - } - } - return null; -} - -/** - * Moves left by grapheme without wrapping to another line. - */ -export function moveLeft(buffer: VimBuffer, count: number): VimBuffer { - const current = clampNormal(buffer); - return at( - buffer, - current.line, - Math.max(0, current.column - nonNegativeInteger(count)), - ); -} - -/** - * Moves right by grapheme without wrapping to another line. - */ -export function moveRight(buffer: VimBuffer, count: number): VimBuffer { - const current = clampNormal(buffer); - const characters = lineCharacters(current, current.line); - return at( - buffer, - current.line, - normalColumn(characters, current.column + nonNegativeInteger(count)), - ); -} - -/** - * Moves down while preserving an explicit desired column through short lines. - * - * The desired column is supplied by the caller so this pure module does not - * retain hidden mutable cursor state. - */ -export function moveDown( - buffer: VimBuffer, - count: number, - desiredColumn: number, -): VimBuffer { - const current = clampNormal(buffer); - const lastLine = Math.max(0, buffer.lines.length - 1); - const targetLine = Math.min(lastLine, current.line + nonNegativeInteger(count)); - return at( - buffer, - targetLine, - normalColumn( - lineCharacters(buffer, targetLine), - nonNegativeInteger(desiredColumn), - ), - ); -} - -/** - * Moves up while preserving an explicit desired column through short lines. - * - * The desired column is supplied by the caller so this pure module does not - * retain hidden mutable cursor state. - */ -export function moveUp( - buffer: VimBuffer, - count: number, - desiredColumn: number, -): VimBuffer { - const current = clampNormal(buffer); - const targetLine = Math.max(0, current.line - nonNegativeInteger(count)); - return at( - buffer, - targetLine, - normalColumn( - lineCharacters(buffer, targetLine), - nonNegativeInteger(desiredColumn), - ), - ); -} - -/** Moves to the start of the next small word. */ -export function moveWordForward(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, false, nextWordOffset); -} - -/** Moves to the start of the next whitespace-delimited WORD. */ -export function moveBigWordForward(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, true, nextWordOffset); -} - -/** Moves to the start of the previous small word. */ -export function moveWordBackward(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, false, previousWordOffset); -} - -/** Moves to the start of the previous whitespace-delimited WORD. */ -export function moveBigWordBackward(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, true, previousWordOffset); -} - -/** Moves to the end of a small word. */ -export function moveWordEnd(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, false, wordEndOffset); -} - -/** Moves to the end of a whitespace-delimited WORD. */ -export function moveBigWordEnd(buffer: VimBuffer, count: number): VimBuffer { - return moveWord(buffer, count, true, wordEndOffset); -} - -/** Moves to column zero of the current line. */ -export function moveLineStart(buffer: VimBuffer, _count: number): VimBuffer { - const current = clampNormal(buffer); - return at(buffer, current.line, 0); -} - -/** Moves to the first non-blank character of the current line. */ -export function moveFirstNonBlank(buffer: VimBuffer, _count: number): VimBuffer { - const current = clampNormal(buffer); - return at( - buffer, - current.line, - firstNonBlankColumn(lineCharacters(current, current.line)), - ); -} - -/** Moves to the final character of the current line. */ -export function moveLineEnd(buffer: VimBuffer, _count: number): VimBuffer { - const current = clampNormal(buffer); - const characters = lineCharacters(current, current.line); - return at(buffer, current.line, characters.length === 0 ? 0 : characters.length - 1); -} - -/** Moves to a one-based counted line, defaulting to the first line. */ -export function moveToFirstLine(buffer: VimBuffer, count: number): VimBuffer { - const lastLine = Math.max(0, buffer.lines.length - 1); - const targetLine = Math.min(lastLine, Math.max(0, nonNegativeInteger(count) - 1)); - return at( - buffer, - targetLine, - firstNonBlankColumn(lineCharacters(buffer, targetLine)), - ); -} - -/** - * Moves to the final line, or to a one-based line number when a count is - * supplied. - */ -export function moveToLastLine(buffer: VimBuffer, count?: number): VimBuffer { - const lastLine = Math.max(0, buffer.lines.length - 1); - const targetLine = - count === undefined - ? lastLine - : Math.min(lastLine, Math.max(0, nonNegativeInteger(count) - 1)); - return at( - buffer, - targetLine, - firstNonBlankColumn(lineCharacters(buffer, targetLine)), - ); -} - -/** Finds the counted next occurrence of a character and lands on it. */ -export function findForward( - buffer: VimBuffer, - count: number, - character: string, -): VimBuffer { - const current = clampNormal(buffer); - const column = findColumn(current, count, character, 1); - return column === null ? current : at(buffer, current.line, column); -} - -/** Finds the counted previous occurrence of a character and lands on it. */ -export function findBackward( - buffer: VimBuffer, - count: number, - character: string, -): VimBuffer { - const current = clampNormal(buffer); - const column = findColumn(current, count, character, -1); - return column === null ? current : at(buffer, current.line, column); -} - -/** Finds forward and lands immediately before the target character. */ -export function tillForward( - buffer: VimBuffer, - count: number, - character: string, -): VimBuffer { - const current = clampNormal(buffer); - const column = findColumn(current, count, character, 1); - return column === null - ? current - : at(buffer, current.line, Math.max(current.column, column - 1)); -} - -/** Finds backward and lands immediately after the target character. */ -export function tillBackward( - buffer: VimBuffer, - count: number, - character: string, -): VimBuffer { - const current = clampNormal(buffer); - const column = findColumn(current, count, character, -1); - return column === null - ? current - : at(buffer, current.line, Math.min(current.column, column + 1)); -} - -/** - * Repeats a find, skipping the adjacent target for till motions so another - * repetition can advance instead of rediscovering the same character. - */ -export function repeatFind( - buffer: VimBuffer, - count: number, - find: FindType, - character: string, -): VimBuffer { - const current = clampNormal(buffer); - switch (find) { - case 'f': - return findForward(current, count, character); - case 'F': - return findBackward(current, count, character); - case 't': { - const column = findColumn(current, count, character, 1, true); - return column === null - ? current - : at(buffer, current.line, Math.max(current.column, column - 1)); - } - case 'T': { - const column = findColumn(current, count, character, -1, true); - return column === null - ? current - : at(buffer, current.line, Math.min(current.column, column + 1)); - } - } -} - -/** Applies a find command by its discriminant. */ -export function applyFind( - buffer: VimBuffer, - count: number, - find: FindType, - character: string, -): VimBuffer { - switch (find) { - case 'f': - return findForward(buffer, count, character); - case 'F': - return findBackward(buffer, count, character); - case 't': - return tillForward(buffer, count, character); - case 'T': - return tillBackward(buffer, count, character); - } -} diff --git a/apps/pythinker-code/src/tui/editor/vim/operators.ts b/apps/pythinker-code/src/tui/editor/vim/operators.ts deleted file mode 100644 index 6d9f456d..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/operators.ts +++ /dev/null @@ -1,729 +0,0 @@ -import { graphemes } from './graphemes'; -import { - applyFind, - moveBigWordBackward, - moveBigWordEnd, - moveBigWordForward, - moveDown, - moveFirstNonBlank, - moveLeft, - moveLineEnd, - moveLineStart, - moveRight, - moveToFirstLine, - moveToLastLine, - moveUp, - moveWordBackward, - moveWordEnd, - moveWordForward, -} from './motions'; -import type { FindType, Operator, Position, VimBuffer } from './types'; - -export type RangeKind = - | 'charwise-exclusive' - | 'charwise-inclusive' - | 'linewise'; - -export interface OperatorRange { - readonly kind: RangeKind; - readonly startLine: number; - readonly startColumn: number; - readonly endLine: number; - readonly endColumn: number; -} - -export interface OperatorResult { - readonly buffer: VimBuffer; - readonly register: string; - readonly registerIsLinewise: boolean; - readonly enterInsert: boolean; - readonly applied: boolean; -} - -interface OrderedOffsets { - readonly start: number; - readonly end: number; -} - -interface WordForwardResult { - readonly target: VimBuffer; - readonly exhausted: boolean; - readonly finalStepStart: VimBuffer | null; -} - -const BLANK_CHARACTER = /\s/u; - -function linePoints(buffer: VimBuffer, line: number): readonly string[] { - return graphemes(buffer.lines[line] ?? ''); -} - -function clampedLine(buffer: VimBuffer, line: number): number { - return Math.min( - Math.max(0, Math.floor(line)), - Math.max(0, buffer.lines.length - 1), - ); -} - -function clampedNormalColumn(points: readonly string[], column: number): number { - if (points.length === 0) { - return 0; - } - return Math.min(Math.max(0, Math.floor(column)), points.length - 1); -} - -function normalBuffer(buffer: VimBuffer): VimBuffer { - const line = clampedLine(buffer, buffer.line); - return { - lines: buffer.lines.length === 0 ? [''] : buffer.lines, - line, - column: clampedNormalColumn(linePoints(buffer, line), buffer.column), - }; -} - -function firstNonBlank(points: readonly string[]): number { - const column = points.findIndex((point) => !BLANK_CHARACTER.test(point)); - return Math.max(0, column); -} - -function onlyBlank(points: readonly string[]): boolean { - return points.every((point) => BLANK_CHARACTER.test(point)); -} - -function leadingIndent(value: string): string { - const points = graphemes(value); - let end = 0; - while (end < points.length && BLANK_CHARACTER.test(points[end] ?? '')) { - end += 1; - } - return points.slice(0, end).join(''); -} - -function lineStartOffset(lines: readonly string[], targetLine: number): number { - const line = Math.min( - Math.max(0, Math.floor(targetLine)), - Math.max(0, lines.length - 1), - ); - let offset = 0; - for (let index = 0; index < line; index += 1) { - offset += graphemes(lines[index] ?? '').length + 1; - } - return offset; -} - -function positionOffset( - lines: readonly string[], - line: number, - column: number, -): number { - const targetLine = Math.min( - Math.max(0, Math.floor(line)), - Math.max(0, lines.length - 1), - ); - const points = graphemes(lines[targetLine] ?? ''); - const targetColumn = Math.min(Math.max(0, Math.floor(column)), points.length); - return lineStartOffset(lines, targetLine) + targetColumn; -} - -function positionFromOffset( - lines: readonly string[], - sourceOffset: number, -): { readonly line: number; readonly column: number } { - const safeLines = lines.length === 0 ? [''] : lines; - let remaining = Math.max(0, Math.floor(sourceOffset)); - for (let line = 0; line < safeLines.length; line += 1) { - const length = graphemes(safeLines[line] ?? '').length; - if (remaining <= length || line === safeLines.length - 1) { - return { line, column: Math.min(remaining, length) }; - } - remaining -= length + 1; - } - return { line: 0, column: 0 }; -} - -export function graphemeOffset( - lines: readonly string[], - position: Position, -): number { - return positionOffset(lines, position.line, position.column); -} - -export function positionAtGraphemeOffset( - lines: readonly string[], - offset: number, -): Position { - return positionFromOffset(lines, offset); -} - -function orderedOffsets( - lines: readonly string[], - range: OperatorRange, -): OrderedOffsets { - const first = positionOffset( - lines, - range.startLine, - range.startColumn, - ); - const second = positionOffset( - lines, - range.endLine, - range.endColumn, - ); - const start = Math.min(first, second); - const high = Math.max(first, second); - return { - start, - end: range.kind === 'charwise-inclusive' ? high + 1 : high, - }; -} - -function emptyResult(buffer: VimBuffer): OperatorResult { - return { - buffer: normalBuffer(buffer), - register: '', - registerIsLinewise: false, - enterInsert: false, - applied: false, - }; -} - -function linewiseRange( - buffer: VimBuffer, - startLine: number, - endLine: number, -): OperatorRange { - const start = clampedLine(buffer, Math.min(startLine, endLine)); - const end = clampedLine(buffer, Math.max(startLine, endLine)); - return { - kind: 'linewise', - startLine: start, - startColumn: 0, - endLine: end, - endColumn: 0, - }; -} - -export function doubledOperatorRange( - buffer: VimBuffer, - count: number, -): OperatorRange { - const current = normalBuffer(buffer); - const repetitions = Math.max(1, Math.floor(count)); - return linewiseRange( - current, - current.line, - current.line + repetitions - 1, - ); -} - -function charwiseRange( - kind: Exclude<RangeKind, 'linewise'>, - start: VimBuffer, - end: VimBuffer, -): OperatorRange { - return { - kind, - startLine: start.line, - startColumn: start.column, - endLine: end.line, - endColumn: end.column, - }; -} - -function sameCursor(first: VimBuffer, second: VimBuffer): boolean { - return first.line === second.line && first.column === second.column; -} - -// One `w`/`W` step: move with the plain motion, but treat an empty line -// between the start and the landed position as a hop target of its own. -// Returns undefined when the motion cannot advance at all. -function nextWordForwardTarget( - buffer: VimBuffer, - current: VimBuffer, - bigWord: boolean, -): VimBuffer | undefined { - const move = bigWord ? moveBigWordForward : moveWordForward; - const moved = move(current, 1); - const searchEnd = sameCursor(current, moved) - ? buffer.lines.length - 1 - : moved.line; - for (let line = current.line + 1; line <= searchEnd; line += 1) { - if ((buffer.lines[line] ?? '') === '') { - return { lines: buffer.lines, line, column: 0 }; - } - } - return sameCursor(current, moved) ? undefined : moved; -} - -// Applies `count` forward word steps, reporting whether the motion was -// exhausted early and where the final step started (used by operator ranges -// to decide linewise vs charwise deletion). -function wordForwardTarget( - buffer: VimBuffer, - count: number, - bigWord: boolean, -): WordForwardResult { - let target = buffer; - let finalStepStart: VimBuffer | null = null; - const repetitions = Math.max(1, Math.floor(count)); - for (let index = 0; index < repetitions; index += 1) { - const stepStart = target; - const next = nextWordForwardTarget(buffer, stepStart, bigWord); - if (next === undefined) { - return { target, exhausted: true, finalStepStart }; - } - finalStepStart = stepStart; - target = next; - } - return { target, exhausted: false, finalStepStart }; -} - -export function operatorRangeForFind( - buffer: VimBuffer, - count: number, - find: FindType, - character: string, -): OperatorRange | null { - const current = normalBuffer(buffer); - const target = applyFind(current, count, find, character); - if (sameCursor(current, target)) { - return null; - } - return charwiseRange('charwise-inclusive', current, target); -} - -export function operatorRangeForMotion( - buffer: VimBuffer, - motion: string, - count: number, - hasExplicitCount = false, -): OperatorRange | null { - const current = normalBuffer(buffer); - let target: VimBuffer; - let kind: RangeKind; - - switch (motion) { - case 'h': - target = moveLeft(current, count); - kind = 'charwise-exclusive'; - break; - case 'l': - target = moveRight(current, count); - kind = 'charwise-exclusive'; - break; - case 'j': - target = moveDown(current, count, current.column); - return sameCursor(current, target) - ? null - : linewiseRange(current, current.line, target.line); - case 'k': - target = moveUp(current, count, current.column); - return sameCursor(current, target) - ? null - : linewiseRange(current, current.line, target.line); - case 'w': - case 'W': { - const motionResult = wordForwardTarget(current, count, motion === 'W'); - target = motionResult.target; - if (motionResult.exhausted) { - return { - kind: 'charwise-exclusive', - startLine: current.line, - startColumn: current.column, - endLine: target.line, - endColumn: linePoints(current, target.line).length, - }; - } - - const finalStepStart = motionResult.finalStepStart; - if (finalStepStart !== null && finalStepStart.line < target.line) { - if ((current.lines[finalStepStart.line] ?? '') === '') { - const endLine = target.line - 1; - if ( - current.column - <= firstNonBlank(linePoints(current, current.line)) - ) { - return linewiseRange(current, current.line, endLine); - } - return { - kind: 'charwise-exclusive', - startLine: current.line, - startColumn: current.column, - endLine, - endColumn: linePoints(current, endLine).length, - }; - } - return { - kind: 'charwise-exclusive', - startLine: current.line, - startColumn: current.column, - endLine: finalStepStart.line, - endColumn: linePoints(current, finalStepStart.line).length, - }; - } - return charwiseRange('charwise-exclusive', current, target); - } - case 'b': - target = moveWordBackward(current, count); - kind = 'charwise-exclusive'; - break; - case 'B': - target = moveBigWordBackward(current, count); - kind = 'charwise-exclusive'; - break; - case 'e': - target = moveWordEnd(current, count); - kind = 'charwise-inclusive'; - break; - case 'E': - target = moveBigWordEnd(current, count); - kind = 'charwise-inclusive'; - break; - case '0': - target = moveLineStart(current, count); - kind = 'charwise-exclusive'; - break; - case '^': - target = moveFirstNonBlank(current, count); - kind = 'charwise-exclusive'; - break; - case '$': - target = moveLineEnd(current, count); - kind = 'charwise-inclusive'; - break; - case 'G': - target = moveToLastLine( - current, - hasExplicitCount ? count : undefined, - ); - return linewiseRange(current, current.line, target.line); - case 'gg': - target = moveToFirstLine(current, count); - return linewiseRange(current, current.line, target.line); - default: - return null; - } - - if ( - sameCursor(current, target) - && kind === 'charwise-exclusive' - ) { - return null; - } - return charwiseRange(kind, current, target); -} - -function applyLinewise( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorResult { - const current = normalBuffer(buffer); - const startLine = Math.min(range.startLine, range.endLine); - const endLine = Math.max(range.startLine, range.endLine); - const register = current.lines.slice(startLine, endLine + 1).join('\n'); - if (op === 'yank') { - return { - buffer: current, - register, - registerIsLinewise: true, - enterInsert: false, - applied: true, - }; - } - - if (op === 'change') { - const indent = leadingIndent(current.lines[startLine] ?? ''); - const lines = [ - ...current.lines.slice(0, startLine), - indent, - ...current.lines.slice(endLine + 1), - ]; - return { - buffer: { lines, line: startLine, column: graphemes(indent).length }, - register, - registerIsLinewise: true, - enterInsert: true, - applied: true, - }; - } - - const remaining = [ - ...current.lines.slice(0, startLine), - ...current.lines.slice(endLine + 1), - ]; - const lines = remaining.length === 0 ? [''] : remaining; - const line = Math.min(startLine, lines.length - 1); - return { - buffer: { - lines, - line, - column: firstNonBlank(graphemes(lines[line] ?? '')), - }, - register, - registerIsLinewise: true, - enterInsert: false, - applied: true, - }; -} - -function applyCharwise( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorResult { - const current = normalBuffer(buffer); - const source = graphemes(current.lines.join('\n')); - const offsets = orderedOffsets(current.lines, range); - const start = Math.min(offsets.start, source.length); - const end = Math.min(Math.max(start, offsets.end), source.length); - if (start === end) { - return emptyResult(current); - } - - const register = source.slice(start, end).join(''); - if (op === 'yank') { - return { - buffer: current, - register, - registerIsLinewise: false, - enterInsert: false, - applied: true, - }; - } - - const nextSource = [...source.slice(0, start), ...source.slice(end)]; - const lines = nextSource.join('').split('\n'); - const insertion = positionFromOffset(lines, start); - if (op === 'change') { - return { - buffer: { lines, line: insertion.line, column: insertion.column }, - register, - registerIsLinewise: false, - enterInsert: true, - applied: true, - }; - } - - const points = graphemes(lines[insertion.line] ?? ''); - return { - buffer: { - lines, - line: insertion.line, - column: clampedNormalColumn(points, insertion.column), - }, - register, - registerIsLinewise: false, - enterInsert: false, - applied: true, - }; -} - -// `d` followed by a word motion over blank text (e.g. `dw` on an empty or -// whitespace-only line) deletes whole lines, matching Vim: the range is -// promoted to linewise when everything before the cursor on the start line -// and everything after the target on the end line is blank. -function deleteSpecialRange( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorRange { - if ( - op !== 'delete' - || range.kind === 'linewise' - || range.startLine >= range.endLine - ) { - return range; - } - - const current = normalBuffer(buffer); - const startPoints = linePoints(current, range.startLine); - const endPoints = linePoints(current, range.endLine); - const trailingStart = - range.kind === 'charwise-inclusive' - ? range.endColumn + 1 - : range.endColumn; - if ( - !onlyBlank(startPoints.slice(0, range.startColumn)) - || !onlyBlank(endPoints.slice(trailingStart)) - ) { - return range; - } - return linewiseRange(current, range.startLine, range.endLine); -} - -function applyRange( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorResult { - return range.kind === 'linewise' - ? applyLinewise(buffer, op, range) - : applyCharwise(buffer, op, range); -} - -// Operator-motion path: applies the d-special blank-line promotion above. -export function applyOperator( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorResult { - return applyRange(buffer, op, deleteSpecialRange(buffer, op, range)); -} - -// Visual-mode path: the user's explicit selection is honored verbatim and -// never widened by the operator-motion special case. -export function applyVisualOperator( - buffer: VimBuffer, - op: Operator, - range: OperatorRange, -): OperatorResult { - return applyRange(buffer, op, range); -} - -export function replaceRangeWithRegister( - buffer: VimBuffer, - range: OperatorRange, - register: string, - registerIsLinewise: boolean, -): OperatorResult { - const current = normalBuffer(buffer); - if (register.length === 0 && !registerIsLinewise) { - return emptyResult(current); - } - const deleted = applyVisualOperator(current, 'delete', range); - if (!deleted.applied) { - return deleted; - } - - if (range.kind === 'linewise') { - const startLine = clampedLine( - current, - Math.min(range.startLine, range.endLine), - ); - const endLine = clampedLine( - current, - Math.max(range.startLine, range.endLine), - ); - const replacementLines = register.split('\n'); - const lines = [ - ...current.lines.slice(0, startLine), - ...replacementLines, - ...current.lines.slice(endLine + 1), - ]; - return { - buffer: { - lines, - line: startLine, - column: firstNonBlank(graphemes(replacementLines[0] ?? '')), - }, - register: deleted.register, - registerIsLinewise: deleted.registerIsLinewise, - enterInsert: false, - applied: true, - }; - } - - const source = graphemes(current.lines.join('\n')); - const offsets = orderedOffsets(current.lines, range); - const start = Math.min(offsets.start, source.length); - const end = Math.min(Math.max(start, offsets.end), source.length); - const replacement = graphemes(register); - if (registerIsLinewise) { - const nextSource = [ - ...source.slice(0, start), - '\n', - ...replacement, - '\n', - ...source.slice(end), - ]; - const lines = nextSource.join('').split('\n'); - const cursor = positionFromOffset(lines, start + 1); - return { - buffer: { - lines, - line: cursor.line, - column: firstNonBlank(graphemes(lines[cursor.line] ?? '')), - }, - register: deleted.register, - registerIsLinewise: deleted.registerIsLinewise, - enterInsert: false, - applied: true, - }; - } - - const nextSource = [ - ...source.slice(0, start), - ...replacement, - ...source.slice(end), - ]; - const lines = nextSource.join('').split('\n'); - const cursor = positionFromOffset( - lines, - start + replacement.length - 1, - ); - return { - buffer: { lines, line: cursor.line, column: cursor.column }, - register: deleted.register, - registerIsLinewise: deleted.registerIsLinewise, - enterInsert: false, - applied: true, - }; -} - -export function pasteRegister( - buffer: VimBuffer, - register: string, - registerIsLinewise: boolean, - after: boolean, - count: number, -): VimBuffer { - const current = normalBuffer(buffer); - // An empty linewise register still pastes one empty line; only an empty - // charwise register is a no-op. - if (register.length === 0 && !registerIsLinewise) { - return current; - } - const repetitions = Math.max(1, Math.floor(count)); - - if (registerIsLinewise) { - const registerLines = register.split('\n'); - const repeatedLines: string[] = []; - for (let index = 0; index < repetitions; index += 1) { - repeatedLines.push(...registerLines); - } - const insertLine = after ? current.line + 1 : current.line; - const lines = [ - ...current.lines.slice(0, insertLine), - ...repeatedLines, - ...current.lines.slice(insertLine), - ]; - return { - lines, - line: insertLine, - column: firstNonBlank(graphemes(lines[insertLine] ?? '')), - }; - } - - const source = graphemes(current.lines.join('\n')); - const content = graphemes(register.repeat(repetitions)); - const currentOffset = positionOffset( - current.lines, - current.line, - current.column, - ); - const insertOffset = - after && linePoints(current, current.line).length > 0 - ? currentOffset + 1 - : currentOffset; - const nextSource = [ - ...source.slice(0, insertOffset), - ...content, - ...source.slice(insertOffset), - ]; - const lines = nextSource.join('').split('\n'); - const cursor = positionFromOffset( - lines, - insertOffset + Math.max(0, content.length - 1), - ); - return { lines, line: cursor.line, column: cursor.column }; -} diff --git a/apps/pythinker-code/src/tui/editor/vim/state-machine.ts b/apps/pythinker-code/src/tui/editor/vim/state-machine.ts deleted file mode 100644 index 90bf4086..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/state-machine.ts +++ /dev/null @@ -1,1902 +0,0 @@ -import { VIM_OPEN_LINE_COUNT_CAP } from '../../constant/vim'; -import { - graphemeLength, - graphemes, - isSingleGrapheme, - utf16OffsetAtGraphemeColumn, -} from './graphemes'; -import { - applyFind, - moveBigWordBackward, - moveBigWordEnd, - moveBigWordForward, - moveDown, - moveFirstNonBlank, - moveLeft, - moveLineEnd, - moveLineStart, - moveRight, - moveToFirstLine, - moveToLastLine, - moveUp, - moveWordBackward, - moveWordEnd, - moveWordForward, - repeatFind, -} from './motions'; -import { - applyOperator, - applyVisualOperator, - doubledOperatorRange, - graphemeOffset, - operatorRangeForFind, - operatorRangeForMotion, - pasteRegister, - positionAtGraphemeOffset, - replaceRangeWithRegister, - type OperatorRange, -} from './operators'; -import { findTextObject } from './text-objects'; -import type { - CommandState, - FindType, - InsertEntry, - Operator, - PersistentState, - Position, - RepeatSpec, - RepeatTarget, - TextObjScope, - VimBuffer, - VimState, - VisualKind, -} from './types'; -import { selectionRange } from './visual'; - -interface CommandResult { - readonly state: VimState; - readonly persistent: PersistentState; - readonly buffer: VimBuffer; -} - -function normalState(command?: CommandState): VimState { - return { mode: 'NORMAL', command: command ?? { type: 'idle' } }; -} - -function visualState( - kind: VisualKind, - anchor: Position, - command?: CommandState, -): VimState { - return { - mode: 'VISUAL', - kind, - anchor: { line: anchor.line, column: anchor.column }, - command: command ?? { type: 'idle' }, - }; -} - -function copyRepeatTarget(target: RepeatTarget): RepeatTarget { - switch (target.kind) { - case 'motion': - return { kind: 'motion', key: target.key, char: target.char }; - case 'textObject': - return { - kind: 'textObject', - scope: target.scope, - object: target.object, - }; - case 'line': - return { kind: 'line' }; - } -} - -function copyRepeatSpec(repeat: RepeatSpec | null): RepeatSpec | null { - if (repeat === null) { - return null; - } - switch (repeat.kind) { - case 'operator': - return { - kind: 'operator', - op: repeat.op, - count: repeat.count, - target: copyRepeatTarget(repeat.target), - insertedText: repeat.insertedText, - }; - case 'simple': - return { kind: 'simple', key: repeat.key, count: repeat.count }; - case 'visual': - return { - kind: 'visual', - op: repeat.op, - visual: repeat.visual, - lineSpan: repeat.lineSpan, - columnSpan: repeat.columnSpan, - insertedText: repeat.insertedText, - }; - case 'insert': - return { - kind: 'insert', - key: repeat.key, - count: repeat.count, - insertedText: repeat.insertedText, - }; - } -} - -export function createInitialState(): VimState { - return normalState(); -} - -export function createInitialPersistent(): PersistentState { - return { - lastFind: null, - desiredColumn: null, - register: '', - registerIsLinewise: false, - lastChange: null, - }; -} - -function copyLastFind( - persistent: PersistentState, -): PersistentState['lastFind'] { - return persistent.lastFind === null - ? null - : { type: persistent.lastFind.type, char: persistent.lastFind.char }; -} - -function copyPersistent( - persistent: PersistentState, - desiredColumn = persistent.desiredColumn, -): PersistentState { - return { - lastFind: copyLastFind(persistent), - desiredColumn, - register: persistent.register, - registerIsLinewise: persistent.registerIsLinewise, - lastChange: copyRepeatSpec(persistent.lastChange), - }; -} - -function persistentWithRegister( - persistent: PersistentState, - register: string, - registerIsLinewise: boolean, - lastFind = copyLastFind(persistent), - lastChange = copyRepeatSpec(persistent.lastChange), -): PersistentState { - return { - lastFind, - desiredColumn: null, - register, - registerIsLinewise, - lastChange, - }; -} - -function persistentWithLastChange( - persistent: PersistentState, - lastChange: RepeatSpec, -): PersistentState { - return { - ...copyPersistent(persistent, null), - lastChange: copyRepeatSpec(lastChange), - }; -} - -function copyBuffer(buffer: VimBuffer): VimBuffer { - return { lines: buffer.lines, line: buffer.line, column: buffer.column }; -} - -function normalBuffer(buffer: VimBuffer): VimBuffer { - return moveLeft(buffer, 0); -} - -function countValue(digits: string): number { - const value = Number.parseInt(digits, 10); - return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER; -} - -function multiplyCounts(first: number, second: number): number { - const value = first * second; - return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER; -} - -// `o`/`O` counts translate into whole opened lines; cap them so a huge count -// cannot allocate an unbounded buffer. -function boundedOpenLineCount(count: number): number { - return Math.min( - VIM_OPEN_LINE_COUNT_CAP, - Math.max(1, Math.floor(count)), - ); -} - -function isDigit(key: string): boolean { - return key.length === 1 && key >= '0' && key <= '9'; -} - -function isNonZeroDigit(key: string): boolean { - return isDigit(key) && key !== '0'; -} - -function findType(key: string): FindType | null { - switch (key) { - case 'f': - case 'F': - case 't': - case 'T': - return key; - default: - return null; - } -} - -function operatorType(key: string): Operator | null { - switch (key) { - case 'd': - return 'delete'; - case 'c': - return 'change'; - case 'y': - return 'yank'; - default: - return null; - } -} - -function isCharacterKey(key: string): boolean { - return isSingleGrapheme(key); -} - -function reverseFind(find: FindType): FindType { - switch (find) { - case 'f': - return 'F'; - case 'F': - return 'f'; - case 't': - return 'T'; - case 'T': - return 't'; - } -} - -function lineLength(buffer: VimBuffer): number { - return graphemeLength(buffer.lines[buffer.line] ?? ''); -} - -function currentCharacter(buffer: VimBuffer): string { - return graphemes(buffer.lines[buffer.line] ?? '')[buffer.column] ?? ''; -} - -function leadingIndent(line: string): string { - return /^[\t ]*/u.exec(line)?.[0] ?? ''; -} - -// Opens `count` blank lines above or below the cursor, carrying the leading -// indent of the current line; returns the buffer positioned on the last opened line. -function openLine(buffer: VimBuffer, above: boolean, count = 1): VimBuffer { - const current = normalBuffer(buffer); - const indent = leadingIndent(current.lines[current.line] ?? ''); - const firstLine = above ? current.line : current.line + 1; - const repetitions = boundedOpenLineCount(count); - const openedLines = Array.from({ length: repetitions }, () => indent); - return { - lines: [ - ...current.lines.slice(0, firstLine), - ...openedLines, - ...current.lines.slice(firstLine), - ], - line: firstLine + repetitions - 1, - column: graphemeLength(indent), - }; -} - -function insertEntry( - buffer: VimBuffer, - pendingRepeat: RepeatSpec | null, -): InsertEntry { - return { - pendingRepeat: copyRepeatSpec(pendingRepeat), - snapshotLines: [...buffer.lines], - snapshotCursor: { line: buffer.line, column: buffer.column }, - }; -} - -function enterInsert( - persistent: PersistentState, - buffer: VimBuffer, - column: number, - pendingRepeat: RepeatSpec | null, -): CommandResult { - const insertBuffer = { - lines: buffer.lines, - line: buffer.line, - column, - }; - return { - state: { - mode: 'INSERT', - entry: insertEntry(insertBuffer, pendingRepeat), - }, - persistent: copyPersistent(persistent, null), - buffer: insertBuffer, - }; -} - -function idleResult( - persistent: PersistentState, - buffer: VimBuffer, - desiredColumn = persistent.desiredColumn, -): CommandResult { - return { - state: normalState(), - persistent: copyPersistent(persistent, desiredColumn), - buffer: normalBuffer(buffer), - }; -} - -function pendingOperator( - persistent: PersistentState, - buffer: VimBuffer, - op: Operator, - count: number, -): CommandResult { - return { - state: normalState({ type: 'operator', op, count }), - persistent: copyPersistent(persistent), - buffer: normalBuffer(buffer), - }; -} - -function finishOperator( - persistent: PersistentState, - buffer: VimBuffer, - op: Operator, - range: OperatorRange | null, - repeat: RepeatSpec | null, - lastFind = copyLastFind(persistent), -): CommandResult { - if (range === null) { - return idleResult(persistent, buffer, null); - } - const result = applyOperator(buffer, op, range); - if (!result.applied) { - return idleResult(persistent, buffer, null); - } - const changesBuffer = op !== 'yank'; - const lastChange = - changesBuffer && repeat !== null - ? copyRepeatSpec(repeat) - : copyRepeatSpec(persistent.lastChange); - const nextPersistent = persistentWithRegister( - persistent, - result.register, - result.registerIsLinewise, - lastFind, - result.enterInsert ? copyRepeatSpec(persistent.lastChange) : lastChange, - ); - return { - state: result.enterInsert - ? { - mode: 'INSERT', - entry: insertEntry(result.buffer, repeat), - } - : normalState(), - persistent: nextPersistent, - buffer: result.buffer, - }; -} - -function shortcutRange( - buffer: VimBuffer, - before: boolean, - count: number, -): OperatorRange { - const current = normalBuffer(buffer); - const length = lineLength(current); - const repetitions = Math.max(1, Math.floor(count)); - return { - kind: 'charwise-exclusive', - startLine: current.line, - startColumn: before - ? Math.max(0, current.column - repetitions) - : current.column, - endLine: current.line, - endColumn: before - ? current.column - : Math.min(length, current.column + repetitions), - }; -} - -function operatorRepeat( - op: Operator, - count: number, - target: RepeatTarget, -): RepeatSpec { - return { - kind: 'operator', - op, - count, - target, - insertedText: null, - }; -} - -function insertRepeat(key: string, count: number): RepeatSpec { - const repeatCount = - key === 'o' || key === 'O' ? boundedOpenLineCount(count) : count; - return { kind: 'insert', key, count: repeatCount, insertedText: null }; -} - -function sameLines( - first: readonly string[], - second: readonly string[], -): boolean { - return first.length === second.length - && first.every((line, index) => line === second[index]); -} - -// Converts a grapheme-based position into a UTF-16 offset into the joined -// buffer text, so recovered insertions can be located by raw string slicing. -function utf16PositionOffset( - lines: readonly string[], - position: Position, -): number { - const safeLines = lines.length === 0 ? [''] : lines; - const line = Math.min( - Math.max(0, Math.floor(position.line)), - safeLines.length - 1, - ); - let offset = 0; - for (let index = 0; index < line; index += 1) { - offset += (safeLines[index] ?? '').length + 1; - } - const text = safeLines[line] ?? ''; - return offset + utf16OffsetAtGraphemeColumn(text, position.column); -} - -function insertTextAt( - lines: readonly string[], - position: Position, - text: string, -): VimBuffer { - const source = graphemes(lines.join('\n')); - const inserted = graphemes(text); - const offset = graphemeOffset(lines, position); - const nextLines = [ - ...source.slice(0, offset), - ...inserted, - ...source.slice(offset), - ].join('').split('\n'); - const cursorOffset = - inserted.length === 0 ? offset : offset + inserted.length - 1; - const cursor = positionAtGraphemeOffset(nextLines, cursorOffset); - return { - lines: nextLines, - line: cursor.line, - column: cursor.column, - }; -} - -// Rebuilds a counted `o`/`O` change: the insertion is applied once to a -// single-line template, then that result (possibly multi-line) is repeated -// `count` times in place of the original line. -function insertTextIntoOpenedLines( - lines: readonly string[], - cursor: Position, - count: number, - text: string, -): VimBuffer { - const repetitions = boundedOpenLineCount(count); - const firstLine = Math.max(0, cursor.line - repetitions + 1); - const template = insertTextAt( - [lines[cursor.line] ?? ''], - { line: 0, column: cursor.column }, - text, - ); - const repeatedLines: string[] = []; - for (let index = 0; index < repetitions; index += 1) { - repeatedLines.push(...template.lines); - } - return { - lines: [ - ...lines.slice(0, firstLine), - ...repeatedLines, - ...lines.slice(cursor.line + 1), - ], - line: firstLine + (repetitions - 1) * template.lines.length + template.line, - column: template.column, - }; -} - -function recoverInsertedText( - entry: InsertEntry, - buffer: VimBuffer, -): string | null { - const before = entry.snapshotLines.join('\n'); - const after = buffer.lines.join('\n'); - const insertionOffset = utf16PositionOffset( - entry.snapshotLines, - entry.snapshotCursor, - ); - const insertedLength = after.length - before.length; - if (insertedLength < 0) { - return null; - } - - const prefixMatches = - before.slice(0, insertionOffset) === after.slice(0, insertionOffset); - const suffixMatches = - before.slice(insertionOffset) - === after.slice(insertionOffset + insertedLength); - const cursorOffset = utf16PositionOffset(buffer.lines, buffer); - if ( - !prefixMatches - || !suffixMatches - || cursorOffset !== insertionOffset + insertedLength - ) { - return null; - } - return after.slice(insertionOffset, insertionOffset + insertedLength); -} - -function repeatWithInsertedText( - repeat: RepeatSpec, - insertedText: string | null, -): RepeatSpec | null { - switch (repeat.kind) { - case 'operator': - return { ...repeat, insertedText }; - case 'simple': - return null; - case 'visual': - return { ...repeat, insertedText }; - case 'insert': - return { ...repeat, insertedText }; - } -} - -function repeatWithCount( - repeat: RepeatSpec, - count: number, -): RepeatSpec { - switch (repeat.kind) { - case 'operator': - return { ...repeat, count }; - case 'simple': - return { ...repeat, count }; - case 'visual': - return { ...repeat }; - case 'insert': - return { - ...repeat, - count: - repeat.key === 'o' || repeat.key === 'O' - ? boundedOpenLineCount(count) - : count, - }; - } -} - -function finishInsert( - entry: InsertEntry, - persistent: PersistentState, - buffer: VimBuffer, -): CommandResult { - if (entry.pendingRepeat === null) { - return { - state: normalState(), - persistent: copyPersistent(persistent), - buffer: moveLeft(normalBuffer(buffer), 1), - }; - } - - const insertedText = recoverInsertedText(entry, buffer); - const recorded = repeatWithInsertedText( - entry.pendingRepeat, - insertedText, - ); - if (recorded === null) { - return idleResult(persistent, buffer, null); - } - - if (insertedText === null) { - // Replaying the wrong edit is worse than declining to replay it. - return { - state: normalState(), - persistent: persistentWithLastChange(persistent, recorded), - buffer: moveLeft(normalBuffer(buffer), 1), - }; - } - - // Open-line changes stay replayable even when nothing was typed, and their - // inserted text is applied per line instead of being repeated on one line. - const isOpenLine = - recorded.kind === 'insert' && (recorded.key === 'o' || recorded.key === 'O'); - if ( - recorded.kind === 'insert' - && insertedText.length === 0 - && !isOpenLine - ) { - return idleResult(persistent, buffer, null); - } - - const text = - recorded.kind === 'insert' && !isOpenLine - ? insertedText.repeat(recorded.count) - : insertedText; - const reconstructed = isOpenLine - ? insertTextIntoOpenedLines( - entry.snapshotLines, - entry.snapshotCursor, - recorded.count, - text, - ) - : insertTextAt( - entry.snapshotLines, - entry.snapshotCursor, - text, - ); - return { - state: normalState(), - persistent: persistentWithLastChange(persistent, recorded), - buffer: normalBuffer(reconstructed), - }; -} - -function movementResult( - persistent: PersistentState, - buffer: VimBuffer, - key: string, - count: number, - hasExplicitCount: boolean, -): CommandResult | null { - const current = normalBuffer(buffer); - switch (key) { - case 'h': - return idleResult(persistent, moveLeft(current, count), null); - case 'l': - return idleResult(persistent, moveRight(current, count), null); - case 'j': { - const desiredColumn = persistent.desiredColumn ?? current.column; - return idleResult( - persistent, - moveDown(current, count, desiredColumn), - desiredColumn, - ); - } - case 'k': { - const desiredColumn = persistent.desiredColumn ?? current.column; - return idleResult( - persistent, - moveUp(current, count, desiredColumn), - desiredColumn, - ); - } - case 'w': - return idleResult(persistent, moveWordForward(current, count), null); - case 'W': - return idleResult(persistent, moveBigWordForward(current, count), null); - case 'b': - return idleResult(persistent, moveWordBackward(current, count), null); - case 'B': - return idleResult(persistent, moveBigWordBackward(current, count), null); - case 'e': - return idleResult(persistent, moveWordEnd(current, count), null); - case 'E': - return idleResult(persistent, moveBigWordEnd(current, count), null); - case '0': - return idleResult(persistent, moveLineStart(current, count), null); - case '^': - return idleResult(persistent, moveFirstNonBlank(current, count), null); - case '$': - return idleResult( - persistent, - moveLineEnd(current, count), - Number.POSITIVE_INFINITY, - ); - case 'G': - return idleResult( - persistent, - moveToLastLine(current, hasExplicitCount ? count : undefined), - null, - ); - case ';': - case ',': { - if (persistent.lastFind === null) { - return idleResult(persistent, current, null); - } - const repeatedFind = - key === ',' - ? reverseFind(persistent.lastFind.type) - : persistent.lastFind.type; - return idleResult( - persistent, - repeatFind( - current, - count, - repeatedFind, - persistent.lastFind.char, - ), - null, - ); - } - default: - return null; - } -} - -function applyShortcut( - persistent: PersistentState, - buffer: VimBuffer, - key: string, - count: number, -): CommandResult | null { - const current = normalBuffer(buffer); - switch (key) { - case 'x': - return finishOperator( - persistent, - current, - 'delete', - shortcutRange(current, false, count), - { kind: 'simple', key, count }, - ); - case 'X': - return finishOperator( - persistent, - current, - 'delete', - shortcutRange(current, true, count), - { kind: 'simple', key, count }, - ); - case 's': { - const repeat = operatorRepeat( - 'change', - count, - { kind: 'motion', key: 's' }, - ); - const result = finishOperator( - persistent, - current, - 'change', - shortcutRange(current, false, count), - repeat, - ); - return result.state.mode === 'INSERT' - ? result - : enterInsert(persistent, current, current.column, repeat); - } - case 'S': - return finishOperator( - persistent, - current, - 'change', - doubledOperatorRange(current, count), - operatorRepeat('change', count, { kind: 'line' }), - ); - case 'D': - return finishOperator( - persistent, - current, - 'delete', - operatorRangeForMotion(current, '$', count), - { kind: 'simple', key, count }, - ); - case 'C': - return finishOperator( - persistent, - current, - 'change', - operatorRangeForMotion(current, '$', count), - operatorRepeat( - 'change', - count, - { kind: 'motion', key: '$' }, - ), - ); - case 'Y': - return finishOperator( - persistent, - current, - 'yank', - doubledOperatorRange(current, count), - null, - ); - case 'p': - case 'P': { - const pasted = pasteRegister( - current, - persistent.register, - persistent.registerIsLinewise, - key === 'p', - count, - ); - const result = idleResult(persistent, pasted, null); - return sameLines(current.lines, pasted.lines) - ? result - : { - ...result, - persistent: persistentWithLastChange( - result.persistent, - { kind: 'simple', key, count }, - ), - }; - } - default: - return null; - } -} - -function applyNormalCommand( - persistent: PersistentState, - buffer: VimBuffer, - key: string, - count: number, - hasExplicitCount: boolean, -): CommandResult | null { - const current = normalBuffer(buffer); - const op = operatorType(key); - if (op !== null) { - return pendingOperator(persistent, current, op, count); - } - const shortcut = applyShortcut(persistent, current, key, count); - if (shortcut !== null) { - return shortcut; - } - const movement = movementResult( - persistent, - current, - key, - count, - hasExplicitCount, - ); - if (movement !== null) { - return movement; - } - const find = findType(key); - if (find !== null) { - return { - state: normalState({ type: 'find', find, count }), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - - switch (key) { - case 'g': - return { - state: normalState({ type: 'g', count }), - persistent: copyPersistent(persistent), - buffer: current, - }; - case 'v': - return { - state: visualState('char', current), - persistent: copyPersistent(persistent, null), - buffer: current, - }; - case 'V': - return { - state: visualState('line', current), - persistent: copyPersistent(persistent, null), - buffer: current, - }; - case '.': - return replayLastChange( - persistent, - current, - hasExplicitCount ? count : null, - ); - case 'i': - return enterInsert( - persistent, - current, - current.column, - insertRepeat(key, count), - ); - case 'I': { - const target = moveFirstNonBlank(current, 1); - return enterInsert( - persistent, - target, - target.column, - insertRepeat(key, count), - ); - } - case 'a': - return enterInsert( - persistent, - current, - Math.min(lineLength(current), current.column + 1), - insertRepeat(key, count), - ); - case 'A': - return enterInsert( - persistent, - current, - lineLength(current), - insertRepeat(key, count), - ); - case 'o': - case 'O': { - const openLineCount = boundedOpenLineCount(count); - const opened = openLine(current, key === 'O', openLineCount); - return enterInsert( - persistent, - opened, - opened.column, - insertRepeat(key, openLineCount), - ); - } - default: - return null; - } -} - -function applyPendingOperator( - persistent: PersistentState, - buffer: VimBuffer, - op: Operator, - count: number, - key: string, - hasMotionCount: boolean, -): CommandResult { - const current = normalBuffer(buffer); - const nextOperator = operatorType(key); - if (nextOperator !== null) { - return nextOperator === op - ? finishOperator( - persistent, - current, - op, - doubledOperatorRange(current, count), - operatorRepeat(op, count, { kind: 'line' }), - ) - : idleResult(persistent, current, null); - } - if (key === 'i' || key === 'a') { - const scope: TextObjScope = key === 'i' ? 'inner' : 'around'; - return { - state: normalState({ - type: 'operatorTextObj', - op, - count, - scope, - }), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - const find = findType(key); - if (find !== null) { - return { - state: normalState({ type: 'operatorFind', op, count, find }), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - if (key === 'g') { - return { - state: normalState({ type: 'operatorG', op, count }), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - if ((key === ';' || key === ',') && persistent.lastFind !== null) { - const findToApply = - key === ',' - ? reverseFind(persistent.lastFind.type) - : persistent.lastFind.type; - return finishOperator( - persistent, - current, - op, - operatorRangeForFind( - current, - count, - findToApply, - persistent.lastFind.char, - ), - operatorRepeat( - op, - count, - { - kind: 'motion', - key: findToApply, - char: persistent.lastFind.char, - }, - ), - ); - } - const motion = - op === 'change' - && (key === 'w' || key === 'W') - && !/\s/u.test(currentCharacter(current)) - ? key === 'w' ? 'e' : 'E' - : key; - return finishOperator( - persistent, - current, - op, - operatorRangeForMotion( - current, - motion, - count, - hasMotionCount || count !== 1, - ), - operatorRepeat(op, count, { kind: 'motion', key }), - ); -} - -function repeatOperatorRange( - buffer: VimBuffer, - repeat: Extract<RepeatSpec, { readonly kind: 'operator' }>, -): OperatorRange | null { - switch (repeat.target.kind) { - case 'line': - return doubledOperatorRange(buffer, repeat.count); - case 'textObject': - return findTextObject( - buffer, - repeat.target.scope, - repeat.target.object, - ); - case 'motion': { - if (repeat.target.key === 's') { - return shortcutRange(buffer, false, repeat.count); - } - if (repeat.target.char !== undefined) { - const find = findType(repeat.target.key); - return find === null - ? null - : operatorRangeForFind( - buffer, - repeat.count, - find, - repeat.target.char, - ); - } - const motion = - repeat.op === 'change' - && (repeat.target.key === 'w' || repeat.target.key === 'W') - && !/\s/u.test(currentCharacter(buffer)) - ? repeat.target.key === 'w' ? 'e' : 'E' - : repeat.target.key; - return operatorRangeForMotion( - buffer, - motion, - repeat.count, - repeat.count !== 1, - ); - } - } -} - -function replayOperator( - persistent: PersistentState, - buffer: VimBuffer, - repeat: Extract<RepeatSpec, { readonly kind: 'operator' }>, -): CommandResult { - if ( - repeat.op === 'yank' - || (repeat.op === 'change' && repeat.insertedText === null) - ) { - return idleResult(persistent, buffer, null); - } - const range = repeatOperatorRange(buffer, repeat); - if (range === null) { - return idleResult(persistent, buffer, null); - } - const result = applyOperator(buffer, repeat.op, range); - if (!result.applied) { - if ( - repeat.op === 'change' - && repeat.target.kind === 'motion' - && repeat.target.key === 's' - && repeat.insertedText !== null - ) { - return { - state: normalState(), - persistent: persistentWithLastChange(persistent, repeat), - buffer: normalBuffer( - insertTextAt(buffer.lines, buffer, repeat.insertedText), - ), - }; - } - return idleResult(persistent, buffer, null); - } - const nextBuffer = - repeat.op === 'change' - ? normalBuffer( - insertTextAt( - result.buffer.lines, - result.buffer, - repeat.insertedText ?? '', - ), - ) - : result.buffer; - return { - state: normalState(), - persistent: persistentWithRegister( - persistent, - result.register, - result.registerIsLinewise, - copyLastFind(persistent), - repeat, - ), - buffer: nextBuffer, - }; -} - -function visualRepeatRange( - buffer: VimBuffer, - repeat: Extract<RepeatSpec, { readonly kind: 'visual' }>, -): OperatorRange { - const endLine = buffer.line + repeat.lineSpan; - const endColumn = buffer.column + repeat.columnSpan; - return selectionRange( - buffer, - { line: endLine, column: endColumn }, - repeat.visual, - ); -} - -function replayVisual( - persistent: PersistentState, - buffer: VimBuffer, - repeat: Extract<RepeatSpec, { readonly kind: 'visual' }>, -): CommandResult { - if ( - repeat.op === 'yank' - || (repeat.op === 'change' && repeat.insertedText === null) - ) { - return idleResult(persistent, buffer, null); - } - const result = applyOperator( - buffer, - repeat.op, - visualRepeatRange(buffer, repeat), - ); - if (!result.applied) { - return idleResult(persistent, buffer, null); - } - const insertsText = repeat.insertedText !== null; - const nextBuffer = insertsText - ? normalBuffer( - insertTextAt( - result.buffer.lines, - result.buffer, - repeat.insertedText ?? '', - ), - ) - : result.buffer; - return { - state: normalState(), - persistent: persistentWithRegister( - persistent, - result.register, - result.registerIsLinewise, - copyLastFind(persistent), - repeat, - ), - buffer: nextBuffer, - }; -} - -function insertPositionForKey( - buffer: VimBuffer, - key: string, -): Position | null { - const current = normalBuffer(buffer); - switch (key) { - case 'i': - return { line: current.line, column: current.column }; - case 'I': { - const target = moveFirstNonBlank(current, 1); - return { line: target.line, column: target.column }; - } - case 'a': - return { - line: current.line, - column: Math.min(lineLength(current), current.column + 1), - }; - case 'A': - return { line: current.line, column: lineLength(current) }; - default: - return null; - } -} - -function replayInsert( - persistent: PersistentState, - buffer: VimBuffer, - repeat: Extract<RepeatSpec, { readonly kind: 'insert' }>, -): CommandResult { - // `o`/`O` re-opens fresh lines on replay; other insert keys paste in place. - const opensLine = repeat.key === 'o' || repeat.key === 'O'; - if ( - repeat.insertedText === null - || (repeat.insertedText.length === 0 && !opensLine) - ) { - return idleResult(persistent, buffer, null); - } - const repeatCount = opensLine - ? boundedOpenLineCount(repeat.count) - : repeat.count; - const target = opensLine - ? openLine(buffer, repeat.key === 'O', repeatCount) - : buffer; - const position = opensLine - ? { line: target.line, column: target.column } - : insertPositionForKey(target, repeat.key); - if (position === null) { - return idleResult(persistent, buffer, null); - } - return { - state: normalState(), - persistent: persistentWithLastChange(persistent, repeat), - buffer: normalBuffer( - opensLine - ? insertTextIntoOpenedLines( - target.lines, - position, - repeatCount, - repeat.insertedText, - ) - : insertTextAt( - target.lines, - position, - repeat.insertedText.repeat(repeatCount), - ), - ), - }; -} - -function replayLastChange( - persistent: PersistentState, - buffer: VimBuffer, - replacementCount: number | null, -): CommandResult { - if (persistent.lastChange === null) { - return idleResult(persistent, buffer, null); - } - const repeat = - replacementCount === null - ? copyRepeatSpec(persistent.lastChange) - : repeatWithCount(persistent.lastChange, replacementCount); - if (repeat === null) { - return idleResult(persistent, buffer, null); - } - switch (repeat.kind) { - case 'operator': - return replayOperator(persistent, buffer, repeat); - case 'simple': { - const result = applyShortcut( - persistent, - buffer, - repeat.key, - repeat.count, - ); - return result ?? idleResult(persistent, buffer, null); - } - case 'visual': - return replayVisual(persistent, buffer, repeat); - case 'insert': - return replayInsert(persistent, buffer, repeat); - } -} - -function visualRepeatSpec( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - buffer: VimBuffer, - op: Operator, - insertedText: string | null, -): Extract<RepeatSpec, { readonly kind: 'visual' }> { - const range = selectionRange(state.anchor, buffer, state.kind); - const lineSpan = range.endLine - range.startLine; - const columnSpan = - state.kind === 'line' - ? 0 - : range.endColumn - range.startColumn; - return { - kind: 'visual', - op, - visual: state.kind, - lineSpan, - columnSpan, - insertedText, - }; -} - -function selectionStartBuffer( - lines: readonly string[], - range: OperatorRange, -): VimBuffer { - return normalBuffer({ - lines, - line: range.startLine, - column: range.kind === 'linewise' ? 0 : range.startColumn, - }); -} - -function finishVisualOperator( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, - op: Operator, -): CommandResult { - const range = selectionRange(state.anchor, buffer, state.kind); - const repeat = visualRepeatSpec(state, buffer, op, null); - const result = applyVisualOperator(buffer, op, range); - if (!result.applied) { - return idleResult(persistent, buffer, null); - } - const lastChange = - op === 'delete' ? repeat : copyRepeatSpec(persistent.lastChange); - const nextPersistent = persistentWithRegister( - persistent, - result.register, - result.registerIsLinewise, - copyLastFind(persistent), - lastChange, - ); - if (op === 'change') { - return { - state: { - mode: 'INSERT', - entry: insertEntry(result.buffer, repeat), - }, - persistent: nextPersistent, - buffer: result.buffer, - }; - } - return { - state: normalState(), - persistent: nextPersistent, - buffer: - op === 'yank' - ? selectionStartBuffer(buffer.lines, range) - : result.buffer, - }; -} - -function replaceVisualSelection( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, -): CommandResult { - const range = selectionRange(state.anchor, buffer, state.kind); - const replaced = replaceRangeWithRegister( - buffer, - range, - persistent.register, - persistent.registerIsLinewise, - ); - if (!replaced.applied) { - return idleResult(persistent, buffer, null); - } - return { - state: normalState(), - persistent: persistentWithRegister( - persistent, - replaced.register, - replaced.registerIsLinewise, - ), - buffer: replaced.buffer, - }; -} - -function visualIdleResult( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, - desiredColumn = persistent.desiredColumn, -): CommandResult { - return { - state: visualState(state.kind, state.anchor), - persistent: copyPersistent(persistent, desiredColumn), - buffer: normalBuffer(buffer), - }; -} - -function selectVisualTextObject( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, - scope: TextObjScope, - object: string, -): CommandResult { - const range = findTextObject(buffer, scope, object); - if (range === null || range.kind === 'linewise') { - return visualIdleResult(state, persistent, buffer, null); - } - const start = { - line: range.startLine, - column: range.startColumn, - }; - const startOffset = graphemeOffset(buffer.lines, start); - const endOffset = graphemeOffset(buffer.lines, { - line: range.endLine, - column: range.endColumn, - }); - if (endOffset <= startOffset) { - return visualIdleResult(state, persistent, buffer, null); - } - const end = positionAtGraphemeOffset(buffer.lines, endOffset - 1); - return { - state: visualState('char', start), - persistent: copyPersistent(persistent, null), - buffer: { - lines: buffer.lines, - line: end.line, - column: end.column, - }, - }; -} - -function applyVisualCommand( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, - key: string, - count: number, - hasExplicitCount: boolean, -): CommandResult { - const current = normalBuffer(buffer); - const movement = movementResult( - persistent, - current, - key, - count, - hasExplicitCount, - ); - if (movement !== null) { - return { - state: visualState(state.kind, state.anchor), - persistent: movement.persistent, - buffer: movement.buffer, - }; - } - const find = findType(key); - if (find !== null) { - return { - state: visualState( - state.kind, - state.anchor, - { type: 'find', find, count }, - ), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - switch (key) { - case 'd': - case 'x': - return finishVisualOperator(state, persistent, current, 'delete'); - case 'c': - case 's': - return finishVisualOperator(state, persistent, current, 'change'); - case 'y': - return finishVisualOperator(state, persistent, current, 'yank'); - case 'p': - return replaceVisualSelection(state, persistent, current); - case 'v': - return state.kind === 'char' - ? idleResult(persistent, current, null) - : { - state: visualState('char', state.anchor), - persistent: copyPersistent(persistent, null), - buffer: current, - }; - case 'V': - return state.kind === 'line' - ? idleResult(persistent, current, null) - : { - state: visualState('line', state.anchor), - persistent: copyPersistent(persistent, null), - buffer: current, - }; - case 'o': - return { - state: visualState(state.kind, current), - persistent: copyPersistent(persistent, null), - buffer: normalBuffer({ - lines: current.lines, - line: state.anchor.line, - column: state.anchor.column, - }), - }; - case 'i': - case 'a': - return { - state: visualState( - state.kind, - state.anchor, - { - type: 'visualTextObj', - scope: key === 'i' ? 'inner' : 'around', - }, - ), - persistent: copyPersistent(persistent), - buffer: current, - }; - case 'g': - return { - state: visualState( - state.kind, - state.anchor, - { type: 'g', count }, - ), - persistent: copyPersistent(persistent), - buffer: current, - }; - default: - return visualIdleResult(state, persistent, current, null); - } -} - -function applyVisualKey( - state: Extract<VimState, { readonly mode: 'VISUAL' }>, - persistent: PersistentState, - buffer: VimBuffer, - key: string, -): CommandResult { - const current = normalBuffer(buffer); - switch (state.command.type) { - case 'idle': - if (isNonZeroDigit(key)) { - return { - state: visualState( - state.kind, - state.anchor, - { type: 'count', digits: key }, - ), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - return applyVisualCommand( - state, - persistent, - current, - key, - 1, - false, - ); - case 'count': - if (isDigit(key)) { - return { - state: visualState( - state.kind, - state.anchor, - { - type: 'count', - digits: `${state.command.digits}${key}`, - }, - ), - persistent: copyPersistent(persistent), - buffer: current, - }; - } - return applyVisualCommand( - state, - persistent, - current, - key, - countValue(state.command.digits), - true, - ); - case 'find': - if (!isCharacterKey(key)) { - return visualIdleResult(state, persistent, current, null); - } - return { - state: visualState(state.kind, state.anchor), - persistent: { - ...copyPersistent(persistent, null), - lastFind: { type: state.command.find, char: key }, - }, - buffer: applyFind( - current, - state.command.count, - state.command.find, - key, - ), - }; - case 'g': - if (key !== 'g') { - return visualIdleResult(state, persistent, current, null); - } - return { - state: visualState(state.kind, state.anchor), - persistent: copyPersistent(persistent, null), - buffer: moveToFirstLine(current, state.command.count), - }; - case 'visualTextObj': - return isCharacterKey(key) - ? selectVisualTextObject( - state, - persistent, - current, - state.command.scope, - key, - ) - : visualIdleResult(state, persistent, current, null); - case 'operator': - case 'operatorCount': - case 'operatorFind': - case 'operatorTextObj': - case 'operatorG': - return visualIdleResult(state, persistent, current, null); - } -} - -/** - * Applies one key to the pure vim state machine. - * - * NORMAL and VISUAL consume every key. INSERT delegates every key except - * Escape to the editor that owns text insertion. - */ -export function applyKey( - state: VimState, - persistent: PersistentState, - buffer: VimBuffer, - key: string, -): { - readonly state: VimState; - readonly persistent: PersistentState; - readonly buffer: VimBuffer; - readonly handled: boolean; -} { - const escaped = key === '\u001B' || key === 'Escape'; - switch (state.mode) { - case 'INSERT': - if (escaped) { - return { - ...finishInsert(state.entry, persistent, buffer), - handled: true, - }; - } - return { - state: { - mode: 'INSERT', - entry: { - pendingRepeat: copyRepeatSpec(state.entry.pendingRepeat), - snapshotLines: [...state.entry.snapshotLines], - snapshotCursor: { - line: state.entry.snapshotCursor.line, - column: state.entry.snapshotCursor.column, - }, - }, - }, - persistent: copyPersistent(persistent), - buffer: copyBuffer(buffer), - handled: false, - }; - case 'VISUAL': { - const current = normalBuffer(buffer); - if (escaped) { - return { - state: normalState(), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - return { - ...applyVisualKey(state, persistent, current, key), - handled: true, - }; - } - case 'NORMAL': - break; - } - - const current = normalBuffer(buffer); - if (escaped) { - return { - state: normalState(), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - - switch (state.command.type) { - case 'idle': { - if (isNonZeroDigit(key)) { - return { - state: normalState({ type: 'count', digits: key }), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - const result = applyNormalCommand(persistent, current, key, 1, false); - return { ...(result ?? idleResult(persistent, current)), handled: true }; - } - case 'count': { - if (isDigit(key)) { - return { - state: normalState({ - type: 'count', - digits: `${state.command.digits}${key}`, - }), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - const result = applyNormalCommand( - persistent, - current, - key, - countValue(state.command.digits), - true, - ); - return { ...(result ?? idleResult(persistent, current)), handled: true }; - } - case 'find': { - if (!isCharacterKey(key)) { - return { ...idleResult(persistent, current), handled: true }; - } - return { - state: normalState(), - persistent: { - ...copyPersistent(persistent, null), - lastFind: { type: state.command.find, char: key }, - }, - buffer: applyFind( - current, - state.command.count, - state.command.find, - key, - ), - handled: true, - }; - } - case 'g': - if (key === 'g') { - return { - ...idleResult( - persistent, - moveToFirstLine(current, state.command.count), - null, - ), - handled: true, - }; - } - return { ...idleResult(persistent, current), handled: true }; - case 'operator': - if (isNonZeroDigit(key)) { - return { - state: normalState({ - type: 'operatorCount', - op: state.command.op, - count: state.command.count, - digits: key, - }), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - return { - ...applyPendingOperator( - persistent, - current, - state.command.op, - state.command.count, - key, - false, - ), - handled: true, - }; - case 'operatorCount': - if (isDigit(key)) { - return { - state: normalState({ - ...state.command, - digits: `${state.command.digits}${key}`, - }), - persistent: copyPersistent(persistent), - buffer: current, - handled: true, - }; - } - return { - ...applyPendingOperator( - persistent, - current, - state.command.op, - multiplyCounts( - state.command.count, - countValue(state.command.digits), - ), - key, - true, - ), - handled: true, - }; - case 'operatorFind': { - if (!isCharacterKey(key)) { - return { ...idleResult(persistent, current, null), handled: true }; - } - const lastFind = { type: state.command.find, char: key }; - return { - ...finishOperator( - persistent, - current, - state.command.op, - operatorRangeForFind( - current, - state.command.count, - state.command.find, - key, - ), - operatorRepeat( - state.command.op, - state.command.count, - { kind: 'motion', key: state.command.find, char: key }, - ), - lastFind, - ), - handled: true, - }; - } - case 'operatorTextObj': - if (!isCharacterKey(key)) { - return { ...idleResult(persistent, current, null), handled: true }; - } - return { - ...finishOperator( - persistent, - current, - state.command.op, - findTextObject( - current, - state.command.scope, - key, - ), - operatorRepeat( - state.command.op, - state.command.count, - { - kind: 'textObject', - scope: state.command.scope, - object: key, - }, - ), - ), - handled: true, - }; - case 'visualTextObj': - return { ...idleResult(persistent, current, null), handled: true }; - case 'operatorG': - return key === 'g' - ? { - ...finishOperator( - persistent, - current, - state.command.op, - operatorRangeForMotion( - current, - 'gg', - state.command.count, - state.command.count !== 1, - ), - operatorRepeat( - state.command.op, - state.command.count, - { kind: 'motion', key: 'gg' }, - ), - ), - handled: true, - } - : { ...idleResult(persistent, current, null), handled: true }; - } -} diff --git a/apps/pythinker-code/src/tui/editor/vim/text-objects.ts b/apps/pythinker-code/src/tui/editor/vim/text-objects.ts deleted file mode 100644 index 476de7ce..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/text-objects.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { graphemes } from './graphemes'; -import type { OperatorRange } from './operators'; -import type { TextObjScope, VimBuffer } from './types'; - -type CharacterKind = 'blank' | 'keyword' | 'punctuation'; - -interface BracketPair { - readonly start: number; - readonly end: number; -} - -const KEYWORD_CHARACTER = /[\p{Letter}\p{Number}_]/u; -const BLANK_CHARACTER = /\s/u; - -const BRACKETS: Readonly< - Record<string, readonly [open: string, close: string]> -> = { - '(': ['(', ')'], - ')': ['(', ')'], - b: ['(', ')'], - '[': ['[', ']'], - ']': ['[', ']'], - '{': ['{', '}'], - '}': ['{', '}'], - B: ['{', '}'], - '<': ['<', '>'], - '>': ['<', '>'], -}; - -function characterKind(point: string): CharacterKind { - if (BLANK_CHARACTER.test(point)) { - return 'blank'; - } - return KEYWORD_CHARACTER.test(point) ? 'keyword' : 'punctuation'; -} - -function currentLine(buffer: VimBuffer): number { - return Math.min( - Math.max(0, Math.floor(buffer.line)), - Math.max(0, buffer.lines.length - 1), - ); -} - -function currentColumn(points: readonly string[], column: number): number { - if (points.length === 0) { - return 0; - } - return Math.min(Math.max(0, Math.floor(column)), points.length - 1); -} - -function exclusiveRange( - startLine: number, - startColumn: number, - endLine: number, - endColumn: number, -): OperatorRange { - return { - kind: 'charwise-exclusive', - startLine, - startColumn, - endLine, - endColumn, - }; -} - -function findWordObject( - buffer: VimBuffer, - scope: TextObjScope, -): OperatorRange | null { - const line = currentLine(buffer); - const points = graphemes(buffer.lines[line] ?? ''); - if (points.length === 0) { - return null; - } - const column = currentColumn(points, buffer.column); - const kind = characterKind(points[column] ?? ''); - let start = column; - let end = column + 1; - while ( - start > 0 - && characterKind(points[start - 1] ?? '') === kind - ) { - start -= 1; - } - while ( - end < points.length - && characterKind(points[end] ?? '') === kind - ) { - end += 1; - } - - if (scope === 'around' && kind !== 'blank') { - if ( - end < points.length - && characterKind(points[end] ?? '') === 'blank' - ) { - while ( - end < points.length - && characterKind(points[end] ?? '') === 'blank' - ) { - end += 1; - } - } else { - while ( - start > 0 - && characterKind(points[start - 1] ?? '') === 'blank' - ) { - start -= 1; - } - } - } - return exclusiveRange(line, start, line, end); -} - -function findQuoteObject( - buffer: VimBuffer, - scope: TextObjScope, - quote: string, -): OperatorRange | null { - const line = currentLine(buffer); - const points = graphemes(buffer.lines[line] ?? ''); - if (points.length === 0) { - return null; - } - const column = currentColumn(points, buffer.column); - const quoteColumns: number[] = []; - for (let index = 0; index < points.length; index += 1) { - if (points[index] === quote) { - quoteColumns.push(index); - } - } - for (let index = 0; index + 1 < quoteColumns.length; index += 2) { - const open = quoteColumns[index]; - const close = quoteColumns[index + 1]; - if ( - open !== undefined - && close !== undefined - && open <= column - && column <= close - ) { - if (scope === 'inner') { - return exclusiveRange(line, open + 1, line, close); - } - const trailingSpace = points[close + 1] === ' ' ? 1 : 0; - return exclusiveRange(line, open, line, close + 1 + trailingSpace); - } - } - return null; -} - -function flatten(buffer: VimBuffer): { - readonly points: readonly string[]; - readonly lineStarts: readonly number[]; -} { - const points: string[] = []; - const lineStarts: number[] = []; - const lines = buffer.lines.length === 0 ? [''] : buffer.lines; - for (let line = 0; line < lines.length; line += 1) { - lineStarts.push(points.length); - points.push(...graphemes(lines[line] ?? '')); - if (line < lines.length - 1) { - points.push('\n'); - } - } - return { points, lineStarts }; -} - -function flatOffset( - buffer: VimBuffer, - lineStarts: readonly number[], -): number { - const line = currentLine(buffer); - const points = graphemes(buffer.lines[line] ?? ''); - return (lineStarts[line] ?? 0) + currentColumn(points, buffer.column); -} - -function boundaryPosition( - buffer: VimBuffer, - lineStarts: readonly number[], - offset: number, -): { readonly line: number; readonly column: number } { - const lines = buffer.lines.length === 0 ? [''] : buffer.lines; - for (let line = lines.length - 1; line >= 0; line -= 1) { - const start = lineStarts[line] ?? 0; - if (offset >= start) { - return { - line, - column: Math.min(offset - start, graphemes(lines[line] ?? '').length), - }; - } - } - return { line: 0, column: 0 }; -} - -function bracketPairs( - points: readonly string[], - open: string, - close: string, -): readonly BracketPair[] { - const stack: number[] = []; - const pairs: BracketPair[] = []; - for (let index = 0; index < points.length; index += 1) { - const point = points[index]; - if (point === open) { - stack.push(index); - } else if (point === close) { - const start = stack.pop(); - if (start !== undefined) { - pairs.push({ start, end: index }); - } - } - } - return pairs; -} - -function findBracketObject( - buffer: VimBuffer, - scope: TextObjScope, - open: string, - close: string, -): OperatorRange | null { - const flat = flatten(buffer); - const cursor = flatOffset(buffer, flat.lineStarts); - const containing = bracketPairs(flat.points, open, close) - .filter((pair) => pair.start <= cursor && cursor <= pair.end) - .toSorted((first, second) => second.start - first.start)[0]; - if (containing === undefined) { - return null; - } - const startOffset = - scope === 'inner' ? containing.start + 1 : containing.start; - const endOffset = - scope === 'inner' ? containing.end : containing.end + 1; - const start = boundaryPosition(buffer, flat.lineStarts, startOffset); - const end = boundaryPosition(buffer, flat.lineStarts, endOffset); - return exclusiveRange(start.line, start.column, end.line, end.column); -} - -export function findTextObject( - buffer: VimBuffer, - scope: TextObjScope, - kind: string, -): OperatorRange | null { - if (kind === 'w') { - return findWordObject(buffer, scope); - } - if (kind === '"' || kind === "'" || kind === '`') { - return findQuoteObject(buffer, scope, kind); - } - const pair = BRACKETS[kind]; - return pair === undefined - ? null - : findBracketObject(buffer, scope, pair[0], pair[1]); -} diff --git a/apps/pythinker-code/src/tui/editor/vim/types.ts b/apps/pythinker-code/src/tui/editor/vim/types.ts deleted file mode 100644 index a812a470..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/types.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * A buffer the vim layer operates on. Line/column, not absolute offset, - * matching both vim's mental model and CustomEditor's line-oriented API. - */ -export interface VimBuffer { - readonly lines: readonly string[]; - readonly line: number; - readonly column: number; -} - -export type VimMode = 'NORMAL' | 'INSERT' | 'VISUAL'; - -export type VisualKind = 'char' | 'line'; - -export interface Position { - readonly line: number; - readonly column: number; -} - -/** The supported line-local find command variants. */ -export type FindType = 'f' | 'F' | 't' | 'T'; - -export type Operator = 'delete' | 'change' | 'yank'; - -export type TextObjScope = 'inner' | 'around'; - -/** NORMAL-mode command parser state. Each variant names exactly what input it awaits. */ -export type CommandState = - | { readonly type: 'idle' } - | { readonly type: 'count'; readonly digits: string } - | { readonly type: 'find'; readonly find: FindType; readonly count: number } - | { readonly type: 'g'; readonly count: number } - | { readonly type: 'operator'; readonly op: Operator; readonly count: number } - | { - readonly type: 'operatorCount'; - readonly op: Operator; - readonly count: number; - readonly digits: string; - } - | { - readonly type: 'operatorFind'; - readonly op: Operator; - readonly count: number; - readonly find: FindType; - } - | { - readonly type: 'operatorTextObj'; - readonly op: Operator; - readonly count: number; - readonly scope: TextObjScope; - } - | { readonly type: 'visualTextObj'; readonly scope: TextObjScope } - | { readonly type: 'operatorG'; readonly op: Operator; readonly count: number }; - -/** What INSERT mode needs to remember so a change can be dot-repeated. */ -export interface InsertEntry { - /** The command that opened INSERT, replayed by `.`; null for an untracked entry. */ - readonly pendingRepeat: RepeatSpec | null; - /** - * Buffer contents when INSERT was entered. The editor owns insertion, so - * Escape recovers typed text by comparing the current buffer with this copy. - */ - readonly snapshotLines: readonly string[]; - readonly snapshotCursor: Position; -} - -/** A replayable command. Structured, never raw keystrokes. */ -export type RepeatSpec = - | { - readonly kind: 'operator'; - readonly op: Operator; - readonly count: number; - readonly target: RepeatTarget; - readonly insertedText: string | null; - } - | { readonly kind: 'simple'; readonly key: string; readonly count: number } - | { - readonly kind: 'visual'; - readonly op: Operator; - readonly visual: VisualKind; - readonly lineSpan: number; - readonly columnSpan: number; - readonly insertedText: string | null; - } - | { - readonly kind: 'insert'; - /** One of i I a A o O, which determines the replay insertion point. */ - readonly key: string; - readonly count: number; - readonly insertedText: string | null; - }; - -export type RepeatTarget = - | { readonly kind: 'motion'; readonly key: string; readonly char?: string } - | { - readonly kind: 'textObject'; - readonly scope: TextObjScope; - readonly object: string; - } - | { readonly kind: 'line' }; - -/** Complete state for the pure vim state machine. */ -export type VimState = - | { readonly mode: 'INSERT'; readonly entry: InsertEntry } - | { readonly mode: 'NORMAL'; readonly command: CommandState } - | { - readonly mode: 'VISUAL'; - readonly kind: VisualKind; - readonly anchor: Position; - readonly command: CommandState; - }; - -/** State that survives across commands. */ -export interface PersistentState { - readonly lastFind: { readonly type: FindType; readonly char: string } | null; - /** - * Column vertical motions aim for. `null` uses the cursor's current column; - * positive infinity preserves end-of-line movement across lines. - */ - readonly desiredColumn: number | null; - /** - * The unnamed register. Linewise content keeps its trailing newline - * semantics through `registerIsLinewise` rather than embedding a newline. - */ - readonly register: string; - readonly registerIsLinewise: boolean; - readonly lastChange: RepeatSpec | null; -} diff --git a/apps/pythinker-code/src/tui/editor/vim/visual.ts b/apps/pythinker-code/src/tui/editor/vim/visual.ts deleted file mode 100644 index 6d385581..00000000 --- a/apps/pythinker-code/src/tui/editor/vim/visual.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { OperatorRange } from './operators'; -import type { Position, VisualKind } from './types'; - -function precedes(first: Position, second: Position): boolean { - return first.line < second.line - || (first.line === second.line && first.column <= second.column); -} - -/** - * Converts visual-mode endpoints to the operator range model. - * Charwise visual mode includes both endpoints. - */ -export function selectionRange( - anchor: Position, - cursor: Position, - kind: VisualKind, -): OperatorRange { - if (kind === 'line') { - return { - kind: 'linewise', - startLine: Math.min(anchor.line, cursor.line), - startColumn: 0, - endLine: Math.max(anchor.line, cursor.line), - endColumn: 0, - }; - } - - const start = precedes(anchor, cursor) ? anchor : cursor; - const end = precedes(anchor, cursor) ? cursor : anchor; - return { - kind: 'charwise-inclusive', - startLine: start.line, - startColumn: start.column, - endLine: end.line, - endColumn: end.column, - }; -} diff --git a/apps/pythinker-code/src/tui/goal-queue-store.ts b/apps/pythinker-code/src/tui/goal-queue-store.ts index 3f2240ec..e1552342 100644 --- a/apps/pythinker-code/src/tui/goal-queue-store.ts +++ b/apps/pythinker-code/src/tui/goal-queue-store.ts @@ -210,7 +210,7 @@ function normalizeObjective(value: string): string { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { throw new PythinkerError( ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, - `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`, ); } return objective; diff --git a/apps/pythinker-code/src/tui/keybindings.ts b/apps/pythinker-code/src/tui/keybindings.ts deleted file mode 100644 index e9a21cb7..00000000 --- a/apps/pythinker-code/src/tui/keybindings.ts +++ /dev/null @@ -1,656 +0,0 @@ -import { readFileSync, unwatchFile, watchFile } from 'node:fs'; -import { join } from 'node:path'; - -import { matchesKey, type KeyId } from '@earendil-works/pi-tui'; -import { z } from 'zod'; - -import type { KeyboardShortcut } from './components/dialogs/help-panel'; - -const KeybindingContextSchema = z.enum([ - 'Global', - 'Chat', - 'Autocomplete', - 'Confirmation', - 'Help', - 'HistorySearch', - 'Tabs', - 'Footer', - 'MessageSelector', - 'MessageActions', - 'ModelPicker', - 'Select', - 'Plugin', -]); - -const BuiltinKeybindingActionSchema = z.enum([ - 'app:interrupt', - 'app:exit', - 'app:redraw', - 'app:toggleTranscript', - // Deprecated: no longer bound by default (shift+tab now cycles thinking - // effort). Kept so existing keybindings.json rebinds of chat:cycleMode - // don't fail schema validation; consumers treat it as chat:thinkingToggle. - 'chat:cycleMode', - 'chat:cancel', - 'chat:externalEditor', - 'chat:historySearch', - 'chat:messageActions', - 'chat:stash', - 'chat:modelPicker', - 'chat:submit', - 'chat:thinkingToggle', - 'chat:undo', - 'chat:newline', - 'chat:imagePaste', - 'history:search', - 'history:previous', - 'history:next', - 'autocomplete:accept', - 'autocomplete:dismiss', - 'autocomplete:previous', - 'autocomplete:next', - 'confirm:yes', - 'confirm:no', - 'confirm:previous', - 'confirm:next', - 'confirm:nextField', - 'confirm:previousField', - 'confirm:cycleMode', - 'confirm:toggle', - 'confirm:toggleExplanation', - 'permission:toggleDebug', - 'help:dismiss', - 'historySearch:next', - 'historySearch:accept', - 'historySearch:cancel', - 'historySearch:execute', - 'tabs:next', - 'tabs:previous', - 'footer:up', - 'footer:down', - 'footer:next', - 'footer:previous', - 'footer:openSelected', - 'footer:clearSelection', - 'messageSelector:up', - 'messageSelector:down', - 'messageSelector:top', - 'messageSelector:bottom', - 'messageSelector:select', - 'messageActions:prev', - 'messageActions:next', - 'messageActions:prevUser', - 'messageActions:nextUser', - 'messageActions:top', - 'messageActions:bottom', - 'messageActions:escape', - 'messageActions:ctrlc', - 'messageActions:enter', - 'messageActions:c', - 'messageActions:p', - 'modelPicker:decreaseEffort', - 'modelPicker:increaseEffort', - 'select:next', - 'select:previous', - 'select:accept', - 'select:cancel', - 'plugin:toggle', - 'plugin:install', -]); -const KeybindingActionSchema = z.union([ - BuiltinKeybindingActionSchema, - z.custom<`command:${string}`>((value) => - typeof value === 'string' && value.startsWith('command:'), - ), -]); - -export type KeybindingContext = z.infer<typeof KeybindingContextSchema>; -export type KeybindingAction = z.infer<typeof KeybindingActionSchema>; - -export interface KeybindingBlock { - readonly context: KeybindingContext; - readonly bindings: Readonly<Record<string, KeybindingAction | null>>; -} - -export interface ParsedKeybinding { - readonly context: KeybindingContext; - readonly chord: readonly string[]; - readonly action: KeybindingAction | null; -} - -export interface LoadedKeybindings { - readonly bindings: readonly ParsedKeybinding[]; - readonly warnings: readonly string[]; - readonly valid: boolean; -} - -const KeybindingsFileSchema = z.object({ - bindings: z.array( - z.object({ - context: z.string().min(1), - bindings: z.record(z.string(), KeybindingActionSchema.nullable()), - }), - ), -}); - -const DEFAULT_KEYBINDING_BLOCKS: readonly KeybindingBlock[] = [ - { - context: 'Global', - bindings: { - 'ctrl+c': 'app:interrupt', - 'ctrl+d': 'app:exit', - 'ctrl+l': 'app:redraw', - }, - }, - { - context: 'Chat', - bindings: { - 'shift+tab': 'chat:thinkingToggle', - 'ctrl+g': 'chat:externalEditor', - 'ctrl+r': 'history:search', - 'shift+up': 'chat:messageActions', - 'ctrl+o': 'app:toggleTranscript', - 'ctrl+s': 'chat:stash', - 'ctrl+t': 'chat:thinkingToggle', - 'alt+p': 'chat:modelPicker', - 'ctrl+-': 'chat:undo', - 'shift+enter': 'chat:newline', - 'ctrl+j': 'chat:newline', - [process.platform === 'win32' ? 'alt+v' : 'ctrl+v']: 'chat:imagePaste', - }, - }, - { - context: 'Autocomplete', - bindings: { - tab: 'autocomplete:accept', - escape: 'autocomplete:dismiss', - up: 'autocomplete:previous', - down: 'autocomplete:next', - }, - }, - { - context: 'Confirmation', - bindings: { - y: 'confirm:yes', - n: 'confirm:no', - enter: 'confirm:yes', - escape: 'confirm:no', - up: 'confirm:previous', - down: 'confirm:next', - tab: 'confirm:nextField', - 'shift+tab': 'confirm:previousField', - space: 'confirm:toggle', - 'ctrl+e': 'confirm:toggleExplanation', - 'ctrl+d': 'permission:toggleDebug', - }, - }, - { context: 'Help', bindings: { escape: 'help:dismiss' } }, - { - context: 'HistorySearch', - bindings: { - 'ctrl+r': 'historySearch:next', - escape: 'historySearch:accept', - tab: 'historySearch:accept', - 'ctrl+c': 'historySearch:cancel', - enter: 'historySearch:execute', - }, - }, - { - context: 'Tabs', - bindings: { - tab: 'tabs:next', - 'shift+tab': 'tabs:previous', - right: 'tabs:next', - left: 'tabs:previous', - }, - }, - { - context: 'Footer', - bindings: { - up: 'footer:up', - 'ctrl+p': 'footer:up', - down: 'footer:down', - 'ctrl+n': 'footer:down', - right: 'footer:next', - left: 'footer:previous', - enter: 'footer:openSelected', - escape: 'footer:clearSelection', - }, - }, - { - context: 'MessageSelector', - bindings: { - up: 'messageSelector:up', - down: 'messageSelector:down', - k: 'messageSelector:up', - j: 'messageSelector:down', - 'ctrl+p': 'messageSelector:up', - 'ctrl+n': 'messageSelector:down', - 'ctrl+up': 'messageSelector:top', - 'shift+up': 'messageSelector:top', - 'meta+up': 'messageSelector:top', - 'shift+k': 'messageSelector:top', - 'ctrl+down': 'messageSelector:bottom', - 'shift+down': 'messageSelector:bottom', - 'meta+down': 'messageSelector:bottom', - 'shift+j': 'messageSelector:bottom', - enter: 'messageSelector:select', - }, - }, - { - context: 'MessageActions', - bindings: { - up: 'messageActions:prev', - down: 'messageActions:next', - k: 'messageActions:prev', - j: 'messageActions:next', - 'meta+up': 'messageActions:top', - 'super+up': 'messageActions:top', - 'meta+down': 'messageActions:bottom', - 'super+down': 'messageActions:bottom', - 'shift+up': 'messageActions:prevUser', - 'shift+down': 'messageActions:nextUser', - escape: 'messageActions:escape', - 'ctrl+c': 'messageActions:ctrlc', - enter: 'messageActions:enter', - c: 'messageActions:c', - p: 'messageActions:p', - }, - }, - { - context: 'ModelPicker', - bindings: { - left: 'modelPicker:decreaseEffort', - right: 'modelPicker:increaseEffort', - }, - }, - { - context: 'Select', - bindings: { - up: 'select:previous', - down: 'select:next', - k: 'select:previous', - j: 'select:next', - 'ctrl+p': 'select:previous', - 'ctrl+n': 'select:next', - enter: 'select:accept', - escape: 'select:cancel', - }, - }, - { context: 'Plugin', bindings: { space: 'plugin:toggle', i: 'plugin:install' } }, -]; - -const RESERVED_KEYS = new Set(['ctrl+c', 'ctrl+d', 'ctrl+m']); -const INTERCEPTED_KEYS: Readonly<Record<string, string>> = { - 'ctrl+z': 'Unix process suspend (SIGTSTP)', - 'ctrl+\\': 'Terminal quit signal (SIGQUIT)', - ...(process.platform === 'darwin' - ? { - 'super+c': 'macOS system copy', - 'super+v': 'macOS system paste', - 'super+x': 'macOS system cut', - 'super+q': 'macOS quit application', - 'super+w': 'macOS close window or tab', - 'super+tab': 'macOS app switcher', - 'super+space': 'macOS Spotlight', - } - : {}), -}; -const ACTIVE_CONTEXTS = new Set<KeybindingContext>(KeybindingContextSchema.options); -const CHORD_TIMEOUT_MS = 1_000; - -export function defaultKeybindings(): readonly ParsedKeybinding[] { - return parseKeybindingBlocks(DEFAULT_KEYBINDING_BLOCKS); -} - -export function generateKeybindingsTemplate(): string { - const bindings = DEFAULT_KEYBINDING_BLOCKS.map((block) => ({ - context: block.context, - bindings: Object.fromEntries( - Object.entries(block.bindings).filter(([shortcut]) => - !RESERVED_KEYS.has(normalizeShortcut(shortcut)), - ), - ), - })).filter((block) => Object.keys(block.bindings).length > 0); - return `${JSON.stringify({ bindings }, null, 2)}\n`; -} - -export function parseKeybindingBlocks( - blocks: readonly KeybindingBlock[], -): readonly ParsedKeybinding[] { - return blocks.flatMap((block) => - Object.entries(block.bindings).map(([shortcut, action]) => ({ - context: block.context, - chord: parseChord(shortcut), - action, - })), - ); -} - -export function loadKeybindings(homeDir: string): LoadedKeybindings { - const defaults = defaultKeybindings(); - let raw: unknown; - let content: string; - try { - content = readFileSync(join(homeDir, 'keybindings.json'), 'utf8'); - raw = JSON.parse(content); - } catch (error) { - return isFileNotFound(error) - ? { bindings: defaults, warnings: [], valid: true } - : { bindings: defaults, warnings: ['Failed to parse keybindings.json.'], valid: false }; - } - - const parsed = KeybindingsFileSchema.safeParse(raw); - if (!parsed.success) { - return { - bindings: defaults, - warnings: ['keybindings.json must contain valid binding blocks.'], - valid: false, - }; - } - - const warnings = duplicateBindingWarnings(content); - const userBlocks: KeybindingBlock[] = []; - const seenBindings = new Map<string, KeybindingAction | null>(); - for (const block of parsed.data.bindings) { - if (!isKeybindingContext(block.context)) { - warnings.push( - `Unknown keybinding context: ${block.context}. Supported contexts: ${KeybindingContextSchema.options.join(', ')}.`, - ); - continue; - } - const bindings: Record<string, KeybindingAction | null> = {}; - for (const [shortcut, action] of Object.entries(block.bindings)) { - const normalized = normalizeShortcut(shortcut); - const bindingId = `${block.context}\0${normalized}`; - if (seenBindings.has(bindingId) && seenBindings.get(bindingId) !== action) { - warnings.push( - `Duplicate binding "${shortcut}" in ${block.context} bindings; the last value wins.`, - ); - } - seenBindings.set(bindingId, action); - if (!isValidChord(shortcut)) { - warnings.push(`Invalid keybinding: ${shortcut}`); - } else if (RESERVED_KEYS.has(normalized)) { - warnings.push(`${shortcut} is reserved and cannot be rebound.`); - } else { - const reason = INTERCEPTED_KEYS[normalized]; - if (reason !== undefined) { - warnings.push(`${shortcut} may be intercepted by the terminal: ${reason}.`); - } - bindings[shortcut] = action; - } - } - userBlocks.push({ context: block.context, bindings }); - } - - return { - bindings: [...defaults, ...parseKeybindingBlocks(userBlocks)], - warnings, - valid: true, - }; -} - -export function watchKeybindings(homeDir: string, onChange: () => void): () => void { - const path = join(homeDir, 'keybindings.json'); - watchFile(path, { persistent: false }, onChange); - return () => unwatchFile(path, onChange); -} - -export function keybindingDisplayText( - bindings: readonly ParsedKeybinding[], - context: KeybindingContext, - action: KeybindingAction, -): string | undefined { - const shortcuts = effectiveBindings(bindings) - .filter((binding) => binding.context === context && binding.action === action) - .map((binding) => binding.chord.join(' ')); - return shortcuts.length === 0 ? undefined : shortcuts.join(' / '); -} - -export type KeybindingHandler = () => void | false; -export type KeybindingHandlers = Readonly<Partial<Record<KeybindingAction, KeybindingHandler>>>; - -export interface KeybindingDispatchOptions { - readonly now?: number; - readonly onCommand?: (command: string) => void; -} - -export class KeybindingResolver { - private readonly bindings: readonly ParsedKeybinding[]; - private pending: - | { - readonly candidates: readonly ResolvedKeybinding[]; - readonly index: number; - readonly expiresAt: number; - } - | undefined; - private contextKey: string | undefined; - - constructor(bindings: readonly ParsedKeybinding[]) { - this.bindings = effectiveBindings(bindings).filter((binding) => ACTIVE_CONTEXTS.has(binding.context)); - } - - dispatch( - data: string, - contexts: readonly KeybindingContext[], - handlers: KeybindingHandlers, - options?: KeybindingDispatchOptions, - ): boolean { - return this.dispatchWithMatcher( - contexts, - handlers, - options, - (key) => matchesConfiguredKey(data, key), - ); - } - - dispatchKeyId( - keyId: string, - contexts: readonly KeybindingContext[], - handlers: KeybindingHandlers, - options?: KeybindingDispatchOptions, - ): boolean { - const normalized = normalizeStep(keyId); - return this.dispatchWithMatcher(contexts, handlers, options, (key) => key === normalized); - } - - private dispatchWithMatcher( - contexts: readonly KeybindingContext[], - handlers: KeybindingHandlers, - options: KeybindingDispatchOptions | undefined, - matches: (key: string) => boolean, - ): boolean { - const now = options?.now ?? Date.now(); - const candidates = this.candidatesFor(contexts); - const contextKey = candidates.contextKey; - if (this.contextKey !== contextKey) { - this.contextKey = contextKey; - this.pending = undefined; - } - if (this.pending !== undefined && now > this.pending.expiresAt) { - this.pending = undefined; - } - if (this.pending !== undefined) { - const nextIndex = this.pending.index + 1; - const nextCandidates = this.pending.candidates.filter((binding) => - matches(binding.chord[nextIndex] ?? ''), - ); - this.pending = undefined; - if (nextCandidates.length > 0) { - return this.finishMatch(nextCandidates, nextIndex, now, handlers, options); - } - // The pending chord is broken: fall through so this key can start a - // fresh match on its own. - } - - const matchesAtStart = candidates.bindings.filter((binding) => matches(binding.chord[0] ?? '')); - return matchesAtStart.length === 0 - ? false - : this.finishMatch(matchesAtStart, 0, now, handlers, options); - } - - private candidatesFor(contexts: readonly KeybindingContext[]): { - readonly bindings: readonly ResolvedKeybinding[]; - readonly contextKey: string; - } { - const orderedContexts = [...contexts.filter((context) => context !== 'Global'), 'Global']; - return { - contextKey: orderedContexts.join('\0'), - bindings: this.bindings.flatMap((binding) => { - const rank = orderedContexts.indexOf(binding.context); - return rank === -1 ? [] : [{ ...binding, rank }]; - }), - }; - } - - private finishMatch( - candidates: readonly ResolvedKeybinding[], - index: number, - now: number, - handlers: KeybindingHandlers, - options: KeybindingDispatchOptions | undefined, - ): boolean { - const bestRank = Math.min(...candidates.map((binding) => binding.rank)); - const bestCandidates = candidates.filter((binding) => binding.rank === bestRank); - const longer = bestCandidates.filter((binding) => binding.chord.length > index + 1); - if (longer.length > 0) { - this.pending = { candidates: longer, index, expiresAt: now + CHORD_TIMEOUT_MS }; - return true; - } - const match = bestCandidates.findLast((binding) => binding.chord.length === index + 1); - if (match === undefined || match.action === null) return true; - if (match.action.startsWith('command:')) { - if (options?.onCommand === undefined) return false; - options.onCommand(match.action.slice('command:'.length)); - return true; - } - return handlers[match.action]?.() !== false && handlers[match.action] !== undefined; - } -} - -type ResolvedKeybinding = ParsedKeybinding & { readonly rank: number }; - -export interface KeybindingAware { - setKeybindings(bindings: readonly ParsedKeybinding[]): void; -} - -export function isKeybindingAware(value: unknown): value is KeybindingAware { - return ( - (typeof value === 'object' || typeof value === 'function') && - value !== null && - 'setKeybindings' in value && - typeof value.setKeybindings === 'function' - ); -} - -const HELP_ACTIONS: ReadonlyArray<{ - readonly action: KeybindingAction; - readonly description: string; -}> = [ - { action: 'chat:modelPicker', description: 'Choose model' }, - { action: 'chat:externalEditor', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { action: 'history:search', description: 'Search input history' }, - { action: 'chat:messageActions', description: 'Select transcript messages' }, - { action: 'app:redraw', description: 'Redraw terminal UI' }, - { action: 'app:toggleTranscript', description: 'Toggle tool output expansion' }, - { action: 'chat:thinkingToggle', description: 'Cycle thinking effort for the current model' }, - { action: 'chat:stash', description: 'Steer — inject a follow-up during streaming' }, - { action: 'chat:newline', description: 'Insert newline' }, - { action: 'app:interrupt', description: 'Interrupt stream / clear input' }, - { action: 'app:exit', description: 'Exit (on empty input)' }, -]; - -export function editorShortcutHelp( - bindings: readonly ParsedKeybinding[], -): readonly KeyboardShortcut[] { - const configured = HELP_ACTIONS.flatMap(({ action, description }) => { - const shortcuts = effectiveBindings(bindings) - .filter((binding) => binding.action === action) - .map((binding) => binding.chord.join(' ')); - return shortcuts.length === 0 ? [] : [{ keys: shortcuts.join(' / '), description }]; - }); - return [ - ...configured, - { keys: 'escape', description: 'Close dialogs / interrupt streaming' }, - { keys: 'up / down', description: 'Browse input history' }, - { keys: 'enter', description: 'Submit' }, - ]; -} - -function effectiveBindings(bindings: readonly ParsedKeybinding[]): readonly ParsedKeybinding[] { - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - return [...winners.values()]; -} - -function parseChord(shortcut: string): readonly string[] { - return shortcut === ' ' ? ['space'] : shortcut.trim().split(/\s+/u).map(normalizeStep); -} - -function isValidChord(shortcut: string): boolean { - if (shortcut === ' ') return true; - const chord = shortcut.trim().split(/\s+/u); - return chord.length > 0 && chord.every((step) => { - const parts = step.split('+'); - return parts.length > 0 && parts.every((part) => part.trim().length > 0); - }); -} - -function normalizeShortcut(shortcut: string): string { - return parseChord(shortcut).join(' '); -} - -function normalizeStep(step: string): string { - const aliases: Readonly<Record<string, string>> = { - control: 'ctrl', - option: 'alt', - opt: 'alt', - command: 'super', - cmd: 'super', - win: 'super', - return: 'enter', - esc: 'escape', - }; - return step - .split('+') - .map((part) => aliases[part.trim().toLowerCase()] ?? part.trim().toLowerCase()) - .join('+'); -} - -function isKeybindingContext(context: string): context is KeybindingContext { - return ACTIVE_CONTEXTS.has(context as KeybindingContext); -} - -function isFileNotFound(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - error.code === 'ENOENT' - ); -} - -function duplicateBindingWarnings(content: string): string[] { - const warnings: string[] = []; - for (const block of content.matchAll(/"bindings"\s*:\s*\{([^{}]*)\}/gu)) { - const before = content.slice(0, block.index); - const context = /"context"\s*:\s*"([^"]+)"[^{]*$/u.exec(before)?.[1] ?? 'unknown'; - const seen = new Set<string>(); - for (const key of (block[1] ?? '').matchAll(/"([^"]+)"\s*:/gu)) { - const name = key[1]; - if (name === undefined) continue; - if (seen.has(name)) { - warnings.push(`Duplicate key "${name}" in ${context} bindings; the last value wins.`); - } - seen.add(name); - } - } - return warnings; -} - -function matchesConfiguredKey(data: string, key: string): boolean { - return matchesKey(data, key as KeyId); -} diff --git a/apps/pythinker-code/src/tui/presentation/dialog-list-model.ts b/apps/pythinker-code/src/tui/presentation/dialog-list-model.ts deleted file mode 100644 index 429e9fb7..00000000 --- a/apps/pythinker-code/src/tui/presentation/dialog-list-model.ts +++ /dev/null @@ -1,219 +0,0 @@ -export interface DialogRow { - readonly id: string; - readonly label: string; - readonly description?: string; - readonly disabled?: boolean; - readonly current?: boolean; -} - -export interface DialogViewModel { - readonly title: string; - readonly rows: readonly DialogRow[]; - readonly selectedIndex: number; - readonly query?: string; - readonly hint?: string; -} - -export interface DialogListOptions { - readonly title: string; - readonly rows: readonly DialogRow[]; - readonly pageSize?: number; - readonly emptyHint?: string; -} - -export type DialogListKeyEvent = - | { readonly kind: 'up' } - | { readonly kind: 'down' } - | { readonly kind: 'home' } - | { readonly kind: 'end' } - | { readonly kind: 'page-up' } - | { readonly kind: 'page-down' } - | { readonly kind: 'enter' } - | { readonly kind: 'escape' } - | { readonly kind: 'backspace' } - | { readonly kind: 'char'; readonly char: string }; - -export type DialogListKeyResult = - | { readonly type: 'consumed' } - | { readonly type: 'cancel' } - | { readonly type: 'select'; readonly row: DialogRow }; - -const DEFAULT_PAGE_SIZE = 8; -const DEFAULT_EMPTY_HINT = 'No matches'; - -function isOrderedSubsequence(token: string, value: string): boolean { - let tokenIndex = 0; - - for (const character of value) { - if (character === token[tokenIndex]) { - tokenIndex += 1; - if (tokenIndex === token.length) { - return true; - } - } - } - - return false; -} - -function firstEnabledIndex(rows: readonly DialogRow[]): number { - const index = rows.findIndex((row) => !row.disabled); - return index === -1 ? 0 : index; -} - -export class DialogListModel { - private readonly options: DialogListOptions; - private readonly pageSize: number; - private query = ''; - private selectedIndex: number; - - constructor(options: DialogListOptions) { - this.options = options; - this.pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE; - this.selectedIndex = firstEnabledIndex(this.filteredRows()); - } - - toViewModel(): DialogViewModel { - const rows = this.filteredRows(); - if (rows.length === 0) { - return { - title: this.options.title, - rows, - selectedIndex: 0, - query: this.query || undefined, - hint: this.options.emptyHint ?? DEFAULT_EMPTY_HINT, - }; - } - - const page = Math.floor(this.selectedIndex / this.pageSize); - const windowStart = page * this.pageSize; - const windowEnd = Math.min(windowStart + this.pageSize, rows.length); - - return { - title: this.options.title, - rows: rows.slice(windowStart, windowEnd), - selectedIndex: this.selectedIndex - windowStart, - query: this.query || undefined, - hint: undefined, - }; - } - - handleKey(event: DialogListKeyEvent): DialogListKeyResult { - const rows = this.filteredRows(); - - switch (event.kind) { - case 'up': - this.move(rows, -1); - return { type: 'consumed' }; - case 'down': - this.move(rows, 1); - return { type: 'consumed' }; - case 'home': - this.selectedIndex = firstEnabledIndex(rows); - return { type: 'consumed' }; - case 'end': { - const lastEnabledIndex = rows.findLastIndex((row) => !row.disabled); - this.selectedIndex = lastEnabledIndex === -1 ? Math.max(rows.length - 1, 0) : lastEnabledIndex; - return { type: 'consumed' }; - } - case 'page-up': - this.movePage(rows, -1); - return { type: 'consumed' }; - case 'page-down': - this.movePage(rows, 1); - return { type: 'consumed' }; - case 'char': - this.query += event.char; - this.resetSelection(); - return { type: 'consumed' }; - case 'backspace': - if (this.query.length > 0) { - this.query = this.query.slice(0, -1); - this.resetSelection(); - } - return { type: 'consumed' }; - case 'escape': - if (this.query.length > 0) { - this.query = ''; - this.resetSelection(); - return { type: 'consumed' }; - } - return { type: 'cancel' }; - case 'enter': { - const row = rows[this.selectedIndex]; - return row && !row.disabled ? { type: 'select', row } : { type: 'consumed' }; - } - } - } - - private filteredRows(): readonly DialogRow[] { - const tokens = this.query - .trim() - .split(/\s+|\//) - .filter(Boolean) - .map((token) => token.toLowerCase()); - - if (tokens.length === 0) { - return this.options.rows; - } - - return this.options.rows.filter((row) => { - const searchableText = `${row.label} ${row.description ?? ''}`.toLowerCase(); - return tokens.every((token) => isOrderedSubsequence(token, searchableText)); - }); - } - - private move(rows: readonly DialogRow[], direction: -1 | 1): void { - for ( - let index = this.selectedIndex + direction; - index >= 0 && index < rows.length; - index += direction - ) { - if (!rows[index]?.disabled) { - this.selectedIndex = index; - return; - } - } - } - - private movePage(rows: readonly DialogRow[], direction: -1 | 1): void { - if (rows.length === 0) { - return; - } - - const rawTarget = Math.min( - Math.max(this.selectedIndex + direction * this.pageSize, 0), - rows.length - 1, - ); - - if (!rows[rawTarget]?.disabled) { - this.selectedIndex = rawTarget; - return; - } - - const primary = this.findEnabledFrom(rows, rawTarget + direction, direction); - const fallbackDirection = direction === 1 ? -1 : 1; - const fallback = this.findEnabledFrom(rows, rawTarget + fallbackDirection, fallbackDirection); - const target = primary ?? fallback; - if (target !== undefined) { - this.selectedIndex = target; - } - } - - private findEnabledFrom( - rows: readonly DialogRow[], - startIndex: number, - direction: -1 | 1, - ): number | undefined { - for (let index = startIndex; index >= 0 && index < rows.length; index += direction) { - if (!rows[index]?.disabled) { - return index; - } - } - return undefined; - } - - private resetSelection(): void { - this.selectedIndex = firstEnabledIndex(this.filteredRows()); - } -} diff --git a/apps/pythinker-code/src/tui/presentation/task-output-model.ts b/apps/pythinker-code/src/tui/presentation/task-output-model.ts deleted file mode 100644 index 389d7890..00000000 --- a/apps/pythinker-code/src/tui/presentation/task-output-model.ts +++ /dev/null @@ -1,119 +0,0 @@ -export interface TaskOutputViewModel { - readonly taskId: string; - readonly title: string; - readonly lines: readonly string[]; - readonly follow: boolean; - readonly complete: boolean; -} - -export interface TaskOutputModelOptions { - readonly taskId: string; - readonly title: string; - readonly complete?: boolean; -} - -export type TaskOutputKeyEvent = - | { readonly kind: 'up' } - | { readonly kind: 'down' } - | { readonly kind: 'page-up' } - | { readonly kind: 'page-down' } - | { readonly kind: 'home' } - | { readonly kind: 'end' } - | { readonly kind: 'close' }; - -export type TaskOutputKeyResult = - | { readonly type: 'consumed' } - | { readonly type: 'close' }; - -function clamp(value: number, min: number, max: number): number { - return Math.min(Math.max(value, min), max); -} - -export class TaskOutputModel { - private readonly options: TaskOutputModelOptions; - private lines: readonly string[] = []; - private scrollTop = 0; - private follow = true; - private complete: boolean; - private lastViewportRows = 0; - - constructor(options: TaskOutputModelOptions) { - this.options = options; - this.complete = options.complete ?? false; - } - - setOutput(fullOutput: string): void { - this.lines = fullOutput.split('\n'); - const maxScroll = this.maxScroll(this.lastViewportRows); - - if (this.follow) { - this.scrollTop = maxScroll; - return; - } - - this.scrollTop = clamp(this.scrollTop, 0, maxScroll); - } - - setComplete(complete: boolean): void { - this.complete = complete; - } - - handleKey(event: TaskOutputKeyEvent): TaskOutputKeyResult { - const maxScroll = this.maxScroll(this.lastViewportRows); - - switch (event.kind) { - case 'up': - this.scrollTop = clamp(this.scrollTop - 1, 0, maxScroll); - this.follow = false; - return { type: 'consumed' }; - case 'down': - this.scrollTop = clamp(this.scrollTop + 1, 0, maxScroll); - this.follow = this.scrollTop === maxScroll; - return { type: 'consumed' }; - case 'page-up': { - const step = Math.max(this.lastViewportRows - 1, 1); - this.scrollTop = clamp(this.scrollTop - step, 0, maxScroll); - this.follow = false; - return { type: 'consumed' }; - } - case 'page-down': { - const step = Math.max(this.lastViewportRows - 1, 1); - this.scrollTop = clamp(this.scrollTop + step, 0, maxScroll); - this.follow = this.scrollTop === maxScroll; - return { type: 'consumed' }; - } - case 'home': - this.scrollTop = 0; - this.follow = false; - return { type: 'consumed' }; - case 'end': - this.scrollTop = maxScroll; - this.follow = true; - return { type: 'consumed' }; - case 'close': - return { type: 'close' }; - } - } - - toViewModel(viewportRows: number): TaskOutputViewModel { - this.lastViewportRows = viewportRows; - const maxScroll = this.maxScroll(viewportRows); - this.scrollTop = clamp(this.scrollTop, 0, maxScroll); - - if (this.follow) { - this.scrollTop = maxScroll; - } - - return { - taskId: this.options.taskId, - title: this.options.title, - lines: this.lines.slice(this.scrollTop, this.scrollTop + Math.max(viewportRows, 0)), - follow: this.follow, - complete: this.complete, - }; - } - - private maxScroll(viewportRows: number): number { - return Math.max(0, this.lines.length - Math.max(viewportRows, 1)); - } -} diff --git a/apps/pythinker-code/src/tui/presentation/tasks-browser-model.ts b/apps/pythinker-code/src/tui/presentation/tasks-browser-model.ts deleted file mode 100644 index 9a459142..00000000 --- a/apps/pythinker-code/src/tui/presentation/tasks-browser-model.ts +++ /dev/null @@ -1,208 +0,0 @@ -export type TasksFilter = 'all' | 'active'; - -export type BackgroundTaskStatus = - | 'running' - | 'completed' - | 'failed' - | 'timed_out' - | 'killed' - | 'lost'; - -export interface TaskRow { - readonly taskId: string; - readonly description: string; - readonly status: BackgroundTaskStatus; - readonly startedAt: number; - readonly endedAt: number | null; -} - -export interface TasksBrowserRow { - readonly taskId: string; - readonly description: string; - readonly status: BackgroundTaskStatus; - readonly statusLabel: string; -} - -export interface TasksBrowserViewModel { - readonly rows: readonly TasksBrowserRow[]; - readonly selectedIndex: number; - readonly filter: TasksFilter; - readonly stopPendingTaskId: string | undefined; -} - -export type TasksBrowserKeyEvent = - | { readonly kind: 'up' } - | { readonly kind: 'down' } - | { readonly kind: 'toggle-filter' } - | { readonly kind: 'refresh' } - | { readonly kind: 'stop' } - | { readonly kind: 'open' } - | { readonly kind: 'cancel' }; - -export type TasksBrowserKeyResult = - | { readonly type: 'consumed' } - | { readonly type: 'select'; readonly taskId: string } - | { readonly type: 'refresh' } - | { readonly type: 'open'; readonly taskId: string } - | { readonly type: 'cancel' } - | { readonly type: 'stop-armed'; readonly taskId: string } - | { readonly type: 'stop-ignored'; readonly taskId: string }; - -export type StopPromptKeyResult = - | { readonly type: 'confirmed'; readonly taskId: string } - | { readonly type: 'cancelled' }; - -const STATUS_LABEL: Record<BackgroundTaskStatus, string> = { - running: 'running', - completed: 'completed', - failed: 'failed', - timed_out: 'timed out', - killed: 'killed', - lost: 'lost', -}; - -function isTerminal(status: BackgroundTaskStatus): boolean { - return status !== 'running'; -} - -function visibleTasks(tasks: readonly TaskRow[], filter: TasksFilter): readonly TaskRow[] { - if (filter === 'all') { - return tasks; - } - return tasks.filter((task) => !isTerminal(task.status)); -} - -function compareTasks(a: TaskRow, b: TaskRow): number { - const aTerminal = isTerminal(a.status); - const bTerminal = isTerminal(b.status); - - if (aTerminal !== bTerminal) { - return aTerminal ? 1 : -1; - } - if (!aTerminal) { - return a.startedAt - b.startedAt; - } - return (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt); -} - -export class TasksBrowserModel { - private tasks: readonly TaskRow[]; - private filter: TasksFilter; - private sortedVisible: readonly TaskRow[]; - private selectedIndex = 0; - private stopPendingTaskId: string | undefined; - - constructor(tasks: readonly TaskRow[], filter?: TasksFilter) { - this.tasks = tasks; - this.filter = filter ?? 'all'; - this.sortedVisible = visibleTasks(this.tasks, this.filter).toSorted(compareTasks); - } - - setTasks(tasks: readonly TaskRow[]): void { - const previousSelectedTaskId = this.sortedVisible[this.selectedIndex]?.taskId; - this.tasks = tasks; - this.recomputeSortedVisible(); - this.preserveSelection(previousSelectedTaskId); - - if (this.stopPendingTaskId !== undefined) { - const pendingTask = this.tasks.find((task) => task.taskId === this.stopPendingTaskId); - if (!pendingTask || isTerminal(pendingTask.status)) { - this.stopPendingTaskId = undefined; - } - } - } - - handleKey(event: TasksBrowserKeyEvent): TasksBrowserKeyResult { - switch (event.kind) { - case 'up': - if (this.sortedVisible.length === 0) { - return { type: 'consumed' }; - } - this.selectedIndex = Math.max(0, this.selectedIndex - 1); - return { type: 'select', taskId: this.sortedVisible[this.selectedIndex]!.taskId }; - case 'down': - if (this.sortedVisible.length === 0) { - return { type: 'consumed' }; - } - this.selectedIndex = Math.min(this.sortedVisible.length - 1, this.selectedIndex + 1); - return { type: 'select', taskId: this.sortedVisible[this.selectedIndex]!.taskId }; - case 'toggle-filter': { - const previousSelectedTaskId = this.sortedVisible[this.selectedIndex]?.taskId; - this.filter = this.filter === 'all' ? 'active' : 'all'; - this.recomputeSortedVisible(); - this.preserveSelection(previousSelectedTaskId); - return { type: 'consumed' }; - } - case 'refresh': - return { type: 'refresh' }; - case 'stop': { - const task = this.sortedVisible[this.selectedIndex]; - if (!task) { - return { type: 'consumed' }; - } - if (isTerminal(task.status)) { - return { type: 'stop-ignored', taskId: task.taskId }; - } - this.stopPendingTaskId = task.taskId; - return { type: 'stop-armed', taskId: task.taskId }; - } - case 'open': { - const task = this.sortedVisible[this.selectedIndex]; - return task ? { type: 'open', taskId: task.taskId } : { type: 'consumed' }; - } - case 'cancel': - return { type: 'cancel' }; - } - } - - isStopPending(): boolean { - return this.stopPendingTaskId !== undefined; - } - - handleStopPromptKey(char: string): StopPromptKeyResult { - if (this.stopPendingTaskId === undefined) { - return { type: 'cancelled' }; - } - - const taskId = this.stopPendingTaskId; - this.stopPendingTaskId = undefined; - return char === 'y' || char === 'Y' - ? { type: 'confirmed', taskId } - : { type: 'cancelled' }; - } - - toViewModel(): TasksBrowserViewModel { - return { - rows: this.sortedVisible.map((task) => ({ - taskId: task.taskId, - description: task.description, - status: task.status, - statusLabel: STATUS_LABEL[task.status], - })), - selectedIndex: this.selectedIndex, - filter: this.filter, - stopPendingTaskId: this.stopPendingTaskId, - }; - } - - private recomputeSortedVisible(): void { - this.sortedVisible = visibleTasks(this.tasks, this.filter).toSorted(compareTasks); - } - - private preserveSelection(previousSelectedTaskId: string | undefined): void { - if (previousSelectedTaskId !== undefined) { - const newIndex = this.sortedVisible.findIndex( - (task) => task.taskId === previousSelectedTaskId, - ); - if (newIndex !== -1) { - this.selectedIndex = newIndex; - return; - } - } - - this.selectedIndex = - this.sortedVisible.length === 0 - ? 0 - : Math.min(Math.max(this.selectedIndex, 0), this.sortedVisible.length - 1); - } -} diff --git a/apps/pythinker-code/src/tui/presentation/tool-presentation-model.ts b/apps/pythinker-code/src/tui/presentation/tool-presentation-model.ts deleted file mode 100644 index 64ca9568..00000000 --- a/apps/pythinker-code/src/tui/presentation/tool-presentation-model.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* - * Derives renderer-neutral tool call presentation state and grouping decisions. - */ - -/** The current presentation status of a tool call. */ -export type ToolStatus = 'streaming' | 'truncated' | 'running' | 'done' | 'failed'; - -/** The verb used to describe a tool call's presentation status. */ -export type ToolVerb = 'Using' | 'Truncated' | 'Used'; - -/** Inputs used to derive a tool call's presentation status. */ -export interface ToolStatusInput { - readonly hasResult: boolean; - readonly isError?: boolean; - readonly truncated?: boolean; - readonly streamingArguments?: string; -} - -/** Derives a tool call's presentation status using renderer-independent precedence rules. */ -export function deriveToolStatus(input: ToolStatusInput): ToolStatus { - if (input.hasResult && input.isError) { - return 'failed'; - } - - if (input.hasResult) { - return 'done'; - } - - if (input.truncated) { - return 'truncated'; - } - - if (typeof input.streamingArguments === 'string') { - return 'streaming'; - } - - return 'running'; -} - -/** Maps a tool call status to its renderer-neutral presentation verb. */ -export function deriveToolVerb(status: ToolStatus): ToolVerb { - if (status === 'done' || status === 'failed') { - return 'Used'; - } - - if (status === 'truncated') { - return 'Truncated'; - } - - return 'Using'; -} - -/** Inputs used to plan the placement of a tool call. */ -export interface GroupPlanInput { - readonly toolCallId: string; - readonly name: string; - readonly step: number; - readonly turnId: string; -} - -/** A renderer-neutral placement decision for a tool call. */ -export type ToolPlacement = - | { readonly kind: 'deferred' } - | { readonly kind: 'standalone'; readonly toolCallId: string } - | { - readonly kind: 'open-group'; - readonly groupKey: string; - readonly toolCallIds: readonly string[]; - } - | { readonly kind: 'append-group'; readonly groupKey: string; readonly toolCallId: string }; - -interface SoloSlot { - readonly step: number; - readonly turnId: string; - readonly soloId: string; -} - -interface GroupSlot { - readonly step: number; - readonly turnId: string; - readonly groupKey: string; -} - -type PendingSlot = SoloSlot | GroupSlot; -type GroupableToolName = 'Agent' | 'Read'; - -/** Plans deterministic grouping for consecutive groupable tool calls. */ -export class ToolGroupPlanner { - private readonly pending: { - Agent: PendingSlot | undefined; - Read: PendingSlot | undefined; - } = { - Agent: undefined, - Read: undefined, - }; - - private nextGroupNumber = 0; - - /** Determines where a tool call belongs without retaining renderer or component state. */ - place(input: GroupPlanInput): ToolPlacement { - if (input.name === 'AskUserQuestion') { - return { kind: 'deferred' }; - } - - const groupableName = this.toGroupableName(input.name); - this.clearOtherSlots(groupableName); - - if (groupableName === undefined) { - return { kind: 'standalone', toolCallId: input.toolCallId }; - } - - let slot = this.pending[groupableName]; - if (slot !== undefined && (slot.step !== input.step || slot.turnId !== input.turnId)) { - this.pending[groupableName] = undefined; - slot = undefined; - } - - if (slot === undefined) { - this.pending[groupableName] = { - step: input.step, - turnId: input.turnId, - soloId: input.toolCallId, - }; - return { kind: 'standalone', toolCallId: input.toolCallId }; - } - - if ('groupKey' in slot) { - return { - kind: 'append-group', - groupKey: slot.groupKey, - toolCallId: input.toolCallId, - }; - } - - const groupKey = `group:${groupableName}:${this.nextGroupNumber}`; - this.nextGroupNumber += 1; - this.pending[groupableName] = { - step: input.step, - turnId: input.turnId, - groupKey, - }; - return { - kind: 'open-group', - groupKey, - toolCallIds: [slot.soloId, input.toolCallId], - }; - } - - /** Clears all pending groups and restarts deterministic group-key numbering. */ - reset(): void { - this.pending.Agent = undefined; - this.pending.Read = undefined; - this.nextGroupNumber = 0; - } - - private toGroupableName(name: string): GroupableToolName | undefined { - if (name === 'Agent' || name === 'Read') { - return name; - } - - return undefined; - } - - private clearOtherSlots(name: GroupableToolName | undefined): void { - if (name !== 'Agent') { - this.pending.Agent = undefined; - } - - if (name !== 'Read') { - this.pending.Read = undefined; - } - } -} diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 77339aa8..d73b498b 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -1,10 +1,10 @@ -import { - deleteAllKittyImages, - type Component, - type Focusable, - getCapabilities, - Spacer, -} from '@earendil-works/pi-tui'; +import { randomUUID } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { unlink } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { DeviceAuthorization } from '@pymodel/pythinker-code-oauth'; +import { effectiveModelAlias, log } from '@pymodel/pythinker-code-sdk'; import type { ApprovalRequest, ApprovalResponse, @@ -12,51 +12,58 @@ import type { CreateSessionOptions, PythinkerHarness, PermissionMode, + PluginCommandDef, PromptPart, Session, + SkillSummary, + TokenUsage, + TurnEndedEvent, + TurnStartedEvent, + WorkspaceTrustInfo, } from '@pymodel/pythinker-code-sdk'; +import type { MigrationPlan } from '@pymodel/migration-legacy'; +import { + deleteAllKittyImages, + type Component, + type Focusable, + getCapabilities, + Spacer, + TuiAltScreen, + TuiMainScreen, +} from '@pymodel/pi-tui'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; -import { readUpdateCache } from '#/cli/update/cache'; -import { readUpdateInstallState } from '#/cli/update/install-state'; -import { detectInstallSource } from '#/cli/update/source'; -import type { InstallSource } from '#/cli/update/types'; +import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; -import { - appendInputHistory, - loadInputHistory, - selectRecentInputHistory, -} from '#/utils/history/input-history'; +import { appendInputHistory, loadInputHistory } from '#/utils/history/input-history'; import { openUrl } from '#/utils/open-url'; import { getInputHistoryFile } from '#/utils/paths'; import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect'; import { quoteShellArg } from '#/utils/shell-quote'; +import { restoreTerminalModes } from '#/utils/terminal-restore'; import { BannerProvider } from './banner/banner-provider'; import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { BUILTIN_SLASH_COMMANDS, + buildPluginSlashCommands, buildSkillSlashCommands, + goalObjectiveLengthWarning, + isExperimentalFlagEnabled, + setExperimentalFeatures, sortSlashCommands, type PythinkerSlashCommand, type SkillListSession, - type SkillSlashCommands, } from './commands'; -import { - isExperimentalFlagEnabled, - onExperimentalFeaturesChanged, - setExperimentalFeatures, -} from './commands/experimental-flags'; -import { - isDynamicWorkflowDisabled, - setDynamicWorkflowDisabled, - setWorkflowSizeGuideline, -} from './commands/workflow-availability'; import * as slashCommands from './commands/dispatch'; +import { CacheHintController } from './controllers/cache-hint-controller'; import { BannerComponent } from './components/chrome/banner'; -import { ActivityLoader } from './components/chrome/activity-loader'; +import { DeviceCodeBoxComponent } from './components/chrome/device-code-box'; +import { GutterContainer } from './components/chrome/gutter-container'; +import { MoonLoader, type SpinnerStyle } from './components/chrome/moon-loader'; import { WelcomeComponent } from './components/chrome/welcome'; +import { pickRandomWorkingTip } from './components/chrome/working-tips'; import { ApprovalPanelComponent, type ApprovalPanelResponse, @@ -66,18 +73,15 @@ import { type ApprovalPreviewBlock, } from './components/dialogs/approval-preview'; import { CompactionComponent } from './components/dialogs/compaction'; -import { ChoicePickerComponent } from './components/dialogs/choice-picker'; -import { - HelpPanelComponent, - type KeyboardShortcut, -} from './components/dialogs/help-panel'; +import { HelpPanelComponent } from './components/dialogs/help-panel'; +import { defaultThinkingEffortFor } from './components/dialogs/model-selector'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; import { FileMentionProvider, type SlashAutocompleteCommand, } from './components/editor/file-mention-provider'; -import { findSlashAutocompleteContext } from './components/editor/slash-autocomplete-context'; import { AssistantMessageComponent } from './components/messages/assistant-message'; import { BackgroundAgentStatusComponent } from './components/messages/background-agent-status'; import { CronMessageComponent } from './components/messages/cron-message'; @@ -86,14 +90,20 @@ import { GoalCompletionMessageComponent, GoalSetMessageComponent, } from './components/messages/goal-panel'; +import { PluginCommandComponent } from './components/messages/plugin-command'; +import { ShellRunComponent } from './components/messages/shell-run'; import { SkillActivationComponent } from './components/messages/skill-activation'; import { NoticeMessageComponent, StatusMessageComponent, } from './components/messages/status-message'; +import { StepSummaryComponent } from './components/messages/step-summary'; import { ThinkingComponent } from './components/messages/thinking'; import { ToolCallComponent } from './components/messages/tool-call'; -import { UserMessageComponent } from './components/messages/user-message'; +import { + ReplayTurnBoundaryComponent, + UserMessageComponent, +} from './components/messages/user-message'; import { ActivityPaneComponent, type ActivityPaneMode } from './components/panes/activity-pane'; import { QueuePaneComponent } from './components/panes/queue-pane'; import type { TuiConfig } from './config'; @@ -102,28 +112,22 @@ import { MAIN_AGENT_ID, NO_ACTIVE_SESSION_MESSAGE, PRODUCT_NAME, + SESSION_LIST_PAGE_SIZE, + SESSIONLESS_STARTUP_NOTICE, } from './constant/pythinker-tui'; +import { IMAGE_INGESTION_SUBMIT_WAIT_MS } from './constant/media'; +import { CHROME_GUTTER } from './constant/rendering'; import { MAX_TERMINAL_TITLE_LENGTH } from './constant/terminal'; import { AuthFlowController } from './controllers/auth-flow'; import { BtwPanelController } from './controllers/btw-panel'; +import { ClipboardImageHintController } from './controllers/clipboard-image-hint'; import { EditorKeyboardController } from './controllers/editor-keyboard'; -import { - footerStatusFromAppState, - type FooterActionId, -} from '#/tui/components/chrome/footer'; -import { MouseController } from './controllers/mouse-controller'; import { SessionEventHandler } from './controllers/session-event-handler'; import { SessionReplayRenderer } from './controllers/session-replay'; +import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; -import { installRainbowColors } from './easter-eggs/rainbow-colors'; -import { - defaultKeybindings, - editorShortcutHelp, - isKeybindingAware, - loadKeybindings, - watchKeybindings, -} from './keybindings'; +import { installRainbowDance } from './easter-eggs/dance'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -131,47 +135,69 @@ import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; -import type { TuiPresentation } from './runtime/contracts'; -import { - foldFooterEvents, - selectFooterViewModel, - selectStatusBarExtras, - selectStatusItemParts, - type FooterActivity, - type FooterEvent, - type FooterGoal, - type FooterUpdate, -} from './runtime/footer/footer-model'; -import { footerUpdateFromState } from './runtime/footer/update-status'; -import { LegacyPiPresentation } from './runtime/legacy-pi-presentation'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, type AppState, + type InlineSkillActivation, type PythinkerTUIOptions, type LivePaneState, type LoginProgressSpinnerHandle, type QueuedMessage, + type SteerInputItem, + type StepRetryState, type TranscriptEntry, + type TUIStartupOptions, + type TUIStartupState, } from './types'; -import { isExpandable } from './utils/component-capabilities'; +import { hasDispose, isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; +import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; -import { extractMediaAttachments } from './utils/image-placeholder'; -import { REPLAY_TURN_LIMIT } from './utils/message-replay'; +import { + extractMediaAttachments, + originalsDirForSession, + pendingImageIngestions, + refreshExpiringImageFileRefs, + resolveOriginalCaptions, + rewriteMediaPlaceholders, + videoAttachmentIdsInText, +} from './utils/image-placeholder'; +import type { ExtractionResult } from './utils/image-placeholder'; +import { installInputLatencyProbe } from './utils/input-latency'; +import { combineSteerInput } from './utils/steer-input'; +import { startupTrace } from '#/utils/startup-trace'; +import { REPLAY_FETCH_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; +import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './utils/screen-takeover'; import { sessionRowsForPicker } from './utils/session-picker-rows'; +import { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; +import { formatBashOutputForDisplay } from './utils/shell-output'; +import { thinkingEffortFromConfig } from './utils/thinking-config'; import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup'; import { installTerminalFocusTracking } from './utils/terminal-focus'; import { notifyTerminalOnce } from './utils/terminal-notification'; -import { waitForTerminalSize } from './utils/terminal-size'; import { installTerminalThemeTracking } from './utils/terminal-theme'; import { detectTmuxKeyboardWarning } from './utils/tmux-keyboard'; -import { markTranscriptComponent } from './utils/transcript-component-metadata'; +import { + getTranscriptComponentEntry, + markTranscriptComponent, +} from './utils/transcript-component-metadata'; import { nextTranscriptId } from './utils/transcript-id'; +import { + TRANSCRIPT_EXPAND_TURNS, + TRANSCRIPT_HYSTERESIS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_MAX_TURNS, + TRANSCRIPT_WINDOW_ENABLED, + groupTurns, + turnsToTrim, +} from './utils/transcript-window'; export type { TUIState } from './tui-state'; export { createTUIState } from './tui-state'; @@ -184,21 +210,41 @@ export type { export interface PythinkerTUIStartupInput { readonly cliOptions: CLIOptions; + /** Profile name resolved from cliOptions --agent/--agent-file (see resolveAgentProfileSelection). */ + readonly agentProfile?: string; + readonly additionalDirs?: readonly string[]; readonly tuiConfig: TuiConfig; readonly version: string; readonly workDir: string; readonly startupNotice?: string; + readonly migrationPlan?: MigrationPlan | null; + /** When true, run only the migration screen, then exit (the `pythinker migrate` command). */ + readonly migrateOnly?: boolean; + /** agent-core-v2 engine; enables the startup workspace-trust prompt. */ + readonly engineV2?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +type LoadingTipKind = 'moon' | 'composing'; -/** Poll cadence for the update cache and install state files. */ -const UPDATE_STATUS_POLL_INTERVAL_MS = 2_000; +function loadingTipKind(mode: EffectiveActivityPaneMode): LoadingTipKind | undefined { + if (mode === 'waiting' || mode === 'tool') return 'moon'; + if (mode === 'composing') return 'composing'; + return undefined; +} -function footerUpdateEquals(a: FooterUpdate, b: FooterUpdate): boolean { - return a.version === b.version && a.state === b.state && a.percent === b.percent; +function waitingSpinnerLabel(retry: StepRetryState | null): string { + return retry === null ? '' : formatStepRetryLabel(retry); } +function sameStringArrays(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +type MutableCreateSessionOptions = { + -readonly [P in keyof CreateSessionOptions]: CreateSessionOptions[P]; +}; + function createInitialAppState(input: PythinkerTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto ? 'auto' @@ -208,13 +254,13 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { return { model: '', workDir: input.workDir, + additionalDirs: [...(input.additionalDirs ?? [])], sessionId: '', permissionMode: startupPermission, planMode: input.cliOptions.plan, + inputMode: 'prompt', dynamicWorkflowMode: false, - fastMode: false, - fastModeSupported: false, - thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -222,9 +268,13 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: input.tuiConfig.theme, version: input.version, editorCommand: input.tuiConfig.editorCommand, + disablePasteBurst: input.tuiConfig.disablePasteBurst, + renderLatex: input.tuiConfig.renderLatex, + cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, statusLine: input.tuiConfig.statusLine, @@ -240,26 +290,41 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { interface SendMessageOptions { readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + readonly stagingPaths?: readonly string[]; readonly hasMedia?: boolean; + /** + * Lease pre-created at extraction time by `sendNormalUserInput`. Dispatch + * reuses it (carrying its exact-binding submission id); enqueueing defers + * it — the queue item owns the raw ids/paths and re-leases at dequeue. + */ + readonly lease?: StagingLease; } +/** How long the one-shot "moved to background" footer hint stays visible. */ +const DETACH_HINT_DISPLAY_MS = 4_000; + export class PythinkerTUI { readonly harness: PythinkerHarness; readonly options: PythinkerTUIOptions; session: Session | undefined; state: TUIState; - readonly presentation: TuiPresentation; + /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */ + private ensureSessionPromise: Promise<Session | undefined> | null = null; + private readonly cacheHint = new CacheHintController(this); + /** Staged prompt media lifecycle (daemon uploads + cache copies) — see StagingLeaseTracker. */ + private readonly staging: StagingLeaseTracker; private readonly approvalController = new ApprovalController(); private readonly questionController = new QuestionController(); private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly PythinkerSlashCommand[] = []; readonly skillCommandMap = new Map<string, string>(); - /** Bumped per refresh so a slow one cannot apply over a newer one. */ - private skillCommandRefresh = 0; - /** The session whose skills the commands on screen were built from. */ - private skillCommandSession: SkillListSession | undefined; + private pluginCommands: readonly PythinkerSlashCommand[] = []; + readonly pluginCommandMap = new Map<string, string>(); private readonly imageStore = new ImageAttachmentStore(); - private fdPath: string | null = detectFdPath(); + // Detected lazily in startBackgroundFdAutocomplete() — detection spawns + // `fd --version`, which must not happen before the workspace trust gate: + // on Windows a bare command name resolves into the (untrusted) cwd first. + private fdPath: string | null = null; private fdDownloadStarted = false; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; @@ -267,18 +332,28 @@ export class PythinkerTUI { aborted = false; private terminalFocusTrackingDispose: (() => void) | undefined; private terminalThemeTrackingDispose: (() => void) | undefined; - private uninstallRainbowColors: () => void; + private clipboardImageHintController: ClipboardImageHintController | undefined; + private uninstallRainbowDance: () => void; private signalCleanupHandlers: Array<() => void> = []; private isShuttingDown = false; + private backgroundRefreshPromise: Promise<void> | undefined; + private readonly migrationPlan: MigrationPlan | null; + private readonly migrateOnly: boolean; + /** Whether the harness runs on the agent-core-v2 engine (lazy session creation). */ + readonly engineV2: boolean; private startupNotice: string | undefined; - private keyboardShortcuts: readonly KeyboardShortcut[] = []; - private keybindings = defaultKeybindings(); - private hasInstalledKeybindings = false; - private mountedEditorReplacement: (Component & Focusable) | undefined; private lastActivityMode: string | undefined; + private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = + undefined; private lastHistoryContent: string | undefined; - private footerGoalSnapshotKey: string | null = null; - private footerGoalObservedAtMs = Date.now(); + // Live `!` shell output entries, keyed by commandId so concurrent commands + // each update their own card and stale events are dropped. Mutated in place + // as `shell.output` events arrive; removed when the command completes. + // `taskId` (from `shell.started`) lets ctrl+b detach the exact task. + private readonly shellOutputStreams = new Map< + string, + { entry: TranscriptEntry; component: ShellRunComponent; taskId?: string } + >(); readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; readonly btwPanelController: BtwPanelController; @@ -286,45 +361,58 @@ export class PythinkerTUI { readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; - readonly mouseController: MouseController; + + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType<typeof setTimeout> | undefined; // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. private activeApprovalPanel: ApprovalPanelComponent | undefined; - // Active full-screen approval preview. While set, the root UI's normal - // children are stashed in `savedChildren`; closing restores them. + // Active full-screen approval preview. While set, the previous screen is + // stashed in `takeover` (root children in regular mode, the layout root in + // fullscreen); closing restores it. private approvalPreview: | { component: ApprovalPreviewViewer; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; panel: ApprovalPanelComponent; } | undefined; - private stopKeybindingsWatcher: (() => void) | undefined; - private updateStatusSource: InstallSource | null = null; - private updateStatusTimer: ReturnType<typeof setInterval> | undefined; - private lastDispatchedUpdate: FooterUpdate = { - version: null, - state: null, - percent: null, - }; public onExit?: (exitCode?: number) => Promise<void>; /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; + /** + * Task that takes over the process after the TUI shuts down, instead of + * exiting (`/web` starting a new server: the server keeps this terminal + * attached until Ctrl+C). Set via {@link setExitForegroundTask}. + */ + public exitForegroundTask: ((exitCode: number) => Promise<void>) | undefined; + track(event: string, properties?: Parameters<PythinkerHarness['track']>[1]): void { this.harness.track(event, properties); } - constructor( - harness: PythinkerHarness, - startupInput: PythinkerTUIStartupInput, - presentation?: TuiPresentation, - ) { + constructor(harness: PythinkerHarness, startupInput: PythinkerTUIStartupInput) { this.harness = harness; + this.staging = new StagingLeaseTracker({ + takeFileIds: (ids) => this.imageStore.takeFileIds(ids), + releaseRetains: (ids) => { + this.imageStore.releaseRetains(ids); + }, + deleteFiles: async (fileIds, paths) => { + await Promise.all([ + ...fileIds.map((fileId) => this.harness.deleteFile(fileId).catch(() => undefined)), + ...paths.map((path) => unlink(path).catch(() => undefined)), + ]); + }, + warn: (message) => { + this.track('staging_lease_invariant', { message }); + }, + }); const tuiOptions: PythinkerTUIOptions = { initialAppState: createInitialAppState(startupInput), startup: { @@ -332,33 +420,20 @@ export class PythinkerTUI { continueLast: startupInput.cliOptions.continue, yolo: startupInput.cliOptions.yolo, auto: startupInput.cliOptions.auto, - init: startupInput.cliOptions.init, - maintenance: startupInput.cliOptions.maintenance, plan: startupInput.cliOptions.plan, model: startupInput.cliOptions.model, - additionalDirs: startupInput.cliOptions.additionalDirs ?? [], + agentProfile: startupInput.agentProfile, + agentFiles: startupInput.cliOptions.agentFiles, startupNotice: startupInput.startupNotice, }, - layout: startupInput.tuiConfig.layout, - copyFullResponse: startupInput.tuiConfig.copyFullResponse, }; this.options = tuiOptions; + this.migrationPlan = startupInput.migrationPlan ?? null; + this.migrateOnly = startupInput.migrateOnly ?? false; + this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); - const keybindingWarnings = this.reloadKeybindings(); - if (keybindingWarnings.length > 0) { - this.startupNotice = combineStartupNotice( - this.startupNotice, - `Keybindings: ${keybindingWarnings.join(' ')}`, - ); - } - onExperimentalFeaturesChanged(() => { - this.state.editor.setVimMode(isExperimentalFlagEnabled('vim_mode')); - }); - this.presentation = presentation ?? new LegacyPiPresentation(this.state); - this.state.footer.setRefreshHandler(() => this.refreshFooter()); - this.syncFooterState(); - this.uninstallRainbowColors = installRainbowColors(() => { + this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); }); @@ -385,9 +460,7 @@ export class PythinkerTUI { this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); this.editorKeyboard = new EditorKeyboardController(this, this.imageStore); - this.editorKeyboard.setKeybindings(this.keybindings); this.editorKeyboard.install(); - this.mouseController = new MouseController(this); this.buildLayout(); } @@ -396,13 +469,10 @@ export class PythinkerTUI { // ========================================================================= private getSlashCommands(): readonly PythinkerSlashCommand[] { - const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter( - (command) => - command.hidden !== true && - (command.name !== 'workflow' || !isDynamicWorkflowDisabled()) && - isExperimentalFlagEnabled(command.experimentalFlag), + const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => + isExperimentalFlagEnabled(command.experimentalFlag), ); - return [...builtins, ...this.skillCommands]; + return [...builtins, ...this.skillCommands, ...this.pluginCommands]; } private setupAutocomplete(): void { @@ -418,58 +488,106 @@ export class PythinkerTUI { : {}), }; }); + const skillCommandNames = new Set(this.skillCommandMap.keys()); const provider = new FileMentionProvider( slashCommands, this.state.appState.workDir, this.fdPath, + this.state.appState.additionalDirs, + () => this.state.appState.inputMode, + skillCommandNames, ); this.state.editor.setAutocompleteProvider(provider); + + const argumentHints = new Map<string, string>(); + for (const cmd of slashCommands) { + if (cmd.argumentHint === undefined) continue; + argumentHints.set(cmd.name, cmd.argumentHint); + for (const alias of cmd.aliases ?? []) { + argumentHints.set(alias, cmd.argumentHint); + } + } + this.state.editor.setArgumentHints(argumentHints); + this.state.editor.setSkillCommandNames(skillCommandNames); + } + + refreshSlashCommandAutocomplete(): void { + this.setupAutocomplete(); } - /** - * The one way to refresh the slash-command set. Every caller that can change - * it — session switch, login/logout, experimental flags, `/reload`, saving a - * workflow — goes through here, so autocomplete and `skillCommandMap` are - * never rebuilt from a skill list that has moved on. - */ async refreshSkillCommands(session?: SkillListSession): Promise<void> { - const refresh = (this.skillCommandRefresh += 1); if (session === undefined) { - this.skillCommandSession = undefined; + // v2 engine: skills live on the workspace handler, not the session, so + // they are available before the first (lazy) session is created — the + // workspace catalog is the same merged view a session would serve. + if (this.engineV2) { + try { + const skills = await this.harness.listWorkspaceSkills(this.state.appState.workDir); + this.applySkillCommands(skills); + return; + } catch { + return; + } + } this.skillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); return; } - let built: SkillSlashCommands | undefined; + let skills; try { - built = buildSkillSlashCommands(await session.listSkills()); + skills = await session.listSkills(); } catch { - // What to do about a failure depends on which session the commands on - // screen came from, and that is only decided after the await below. + return; } + this.applySkillCommands(skills); + } - // A refresh that started later has already applied. Several callers start - // this without awaiting it, so a slow list for the session the user just - // left would otherwise land on top of the one they switched to. - if (refresh !== this.skillCommandRefresh) return; + private applySkillCommands(skills: readonly SkillSummary[]): void { + const skillCommands = buildSkillSlashCommands(skills); + this.skillCommands = skillCommands.commands; + this.skillCommandMap.clear(); + for (const [commandName, skillName] of skillCommands.commandMap) { + this.skillCommandMap.set(commandName, skillName); + } + this.setupAutocomplete(); + } - if (built === undefined && this.skillCommandSession === session) { - // A transient failure for the session already on screen: its commands are - // still the right ones, so keep them. The builtin command set may still - // have changed, so the autocomplete provider is rebuilt either way. + async refreshPluginCommands(session?: Session): Promise<void> { + if (session === undefined) { + // v2 engine: the enabled plugin commands are an app-global live view, + // available before the first (lazy) session is created. + if (this.engineV2) { + try { + const defs = await this.harness.listPluginCommands(); + this.applyPluginCommands(defs); + return; + } catch { + return; + } + } + this.pluginCommands = []; + this.pluginCommandMap.clear(); this.setupAutocomplete(); return; } - // Either it listed, or it failed for a session whose skills were never on - // screen — keeping another session's commands would be worse than none. - this.skillCommandSession = built === undefined ? undefined : session; - this.skillCommands = built?.commands ?? []; - this.skillCommandMap.clear(); - for (const [commandName, skillName] of built?.commandMap ?? []) { - this.skillCommandMap.set(commandName, skillName); + let defs; + try { + defs = await session.listPluginCommands(); + } catch { + return; + } + this.applyPluginCommands(defs); + } + + private applyPluginCommands(defs: readonly PluginCommandDef[]): void { + const pluginSlashCommands = buildPluginSlashCommands(defs); + this.pluginCommands = pluginSlashCommands.commands; + this.pluginCommandMap.clear(); + for (const [commandName, body] of pluginSlashCommands.commandMap) { + this.pluginCommandMap.set(commandName, body); } this.setupAutocomplete(); } @@ -479,24 +597,63 @@ export class PythinkerTUI { // ========================================================================= async start(): Promise<void> { + startupTrace('tui:start'); // Signal handlers must be installed before raw mode to avoid EIO loops. this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. try { - // Start the loop before mounting anything: pi-tui paints on invalidate - // even before ui.start(), so mounting first anchors early frames to the - // shell cursor and startEventLoop's scroll-to-home would push the live - // frame's top rows into scrollback for good. - this.startEventLoop(); + // The workspace trust gate must run before anything else in startup — + // including the migration branch: a workspace that needs migration is + // not implicitly trusted, and later startup steps spawn child processes. + startupTrace('trustPrompt:begin'); + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + + if (this.migrationPlan !== null) { + // Migration needs the event loop running first (pi-tui component). + // When the trust prompt already started it, starting it again would + // re-run pi-tui's terminal.start() — stacking a second Kitty + // keyboard-protocol push and duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); + try { + const migrationResult = await this.runMigrationScreen(this.migrationPlan); + if (this.migrateOnly) { + const failed = migrationResult.decision === 'now' && migrationResult.migrated === false; + this.disposeTerminalTracking(); + this.state.ui.stop(); + await this.onExit?.(failed ? 1 : 0); + return; + } + const shouldReplayHistory = await this.initMainTui(); + this.startBackgroundFdAutocomplete(); + await this.finishStartup(shouldReplayHistory); + } catch (error) { + this.disposeTerminalTracking(); + this.state.ui.stop(); + throw error; + } + return; + } + + startupTrace('initMainTui:begin'); + const shouldReplayHistory = await this.initMainTui(); + startupTrace('initMainTui:end'); + // Debug-only input→render latency overlay (PYTHINKER_TUI_INPUT_LATENCY=1). + if (process.env['PYTHINKER_TUI_INPUT_LATENCY']) installInputLatencyProbe(this.state.ui); + // When the trust prompt already started the event loop, starting it + // again would re-run pi-tui's terminal.start() — stacking a second + // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and + // duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); + startupTrace('eventLoop:started'); try { - const shouldReplayHistory = await this.initMainTui(); this.startBackgroundFdAutocomplete(); + startupTrace('finishStartup:begin'); await this.finishStartup(shouldReplayHistory); - this.startKeybindingsWatcher(); + startupTrace('finishStartup:end'); } catch (error) { - this.mouseController.stop(); this.disposeTerminalTracking(); - this.presentation.stop(); + this.state.ui.stop(); throw error; } } catch (error) { @@ -545,15 +702,9 @@ export class PythinkerTUI { ); const banner = new BannerComponent(this.state.appState.banner); if (welcomeIndex >= 0) { - this.state.transcriptContainer.addTranscriptChildAt(welcomeIndex + 1, banner, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); } else { - this.state.transcriptContainer.addTranscriptChildAt(0, banner, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + this.state.transcriptContainer.children.unshift(banner); } this.state.transcriptContainer.invalidate(); } @@ -569,42 +720,42 @@ export class PythinkerTUI { void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); - this.presentation.focusComposer(); + this.state.ui.setFocus(this.state.editor); return shouldReplayHistory; } private startEventLoop(): void { - const start = (): void => { - // The fixed layout emits exactly `terminal.rows` lines per frame, and - // pi-tui's first render assumes a clean screen. Any shell output already - // on screen would scroll the frame's top rows (panel border included) - // out of view for good, so scroll the history up first and start at home. - if (this.state.layout === 'fixed') { - const rows = this.state.terminal.rows; - this.presentation.writeTerminalControl('\n'.repeat(Math.max(1, rows)) + '\u001B[H'); - } - this.presentation.start(() => { - this.refreshFooter(); + // Dispose any previous focus/clipboard/theme tracking so re-entering the + // event loop (e.g. a future TUI reconnect) can't stack duplicate listeners. + this.disposeTerminalTracking(); + this.state.ui.start(); + this.startClipboardImageHintController(); + this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); + this.refreshTerminalThemeTracking(); + } + + private startClipboardImageHintController(): void { + this.clipboardImageHintController = new ClipboardImageHintController({ + ui: this.state.ui, + footer: this.state.footer, + getModelSupportsImage: () => this.supportsCurrentModelCapability('image_in'), + requestRender: () => { this.state.ui.requestRender(); - }); - this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state); - this.refreshTerminalThemeTracking(); - if (this.state.layout === 'fixed') { - this.mouseController.start(); - } - }; - - if (typeof process.stdout.columns === 'number' && process.stdout.columns > 0) { - start(); - return; - } - void waitForTerminalSize(process.stdout).then(start); + }, + }); + this.clipboardImageHintController.start(); } private startBackgroundFdAutocomplete(): void { - if (this.fdPath !== null || this.fdDownloadStarted) return; + if (this.fdDownloadStarted) return; this.fdDownloadStarted = true; + this.fdPath = detectFdPath(); + if (this.fdPath !== null) { + this.setupAutocomplete(); + return; + } + void ensureFdPath() .then((fdPath) => { if (fdPath === null) return; @@ -616,57 +767,6 @@ export class PythinkerTUI { }); } - // Update availability + install progress poll. The install source is - // resolved once — it cannot change mid-session and detecting it repeatedly - // costs a subprocess. Both state files are read off the render path, and a - // quiet session repaints only when the computed update changes. - private startUpdateStatusPolling(): void { - void (async () => { - let source: InstallSource = 'unsupported'; - try { - source = await detectInstallSource(); - } catch { - // Detection failure means the update flow treats this install as unsupported. - } - this.updateStatusSource = source; - if (this.isShuttingDown) return; - await this.pollUpdateStatus(); - if (this.isShuttingDown) return; - this.updateStatusTimer = setInterval(() => { - void this.pollUpdateStatus(); - }, UPDATE_STATUS_POLL_INTERVAL_MS); - // A cosmetic poll must never be the reason the process refuses to exit. - this.updateStatusTimer.unref(); - })(); - } - - private async pollUpdateStatus(): Promise<void> { - if (this.isShuttingDown || this.updateStatusSource === null) return; - try { - const [cache, installState] = await Promise.all([ - readUpdateCache(), - readUpdateInstallState(), - ]); - const update = footerUpdateFromState( - this.state.appState.version, - this.updateStatusSource, - cache, - installState, - ); - if (footerUpdateEquals(this.lastDispatchedUpdate, update)) return; - this.lastDispatchedUpdate = update; - this.dispatchFooter({ type: 'update.updated', update }); - } catch { - // An unreadable update state file means "no update to show", never a crash. - } - } - - private stopUpdateStatusPolling(): void { - if (this.updateStatusTimer === undefined) return; - clearInterval(this.updateStatusTimer); - this.updateStatusTimer = undefined; - } - private async refreshProviderModelsInBackground(): Promise<void> { try { const result = await this.authFlow.refreshProviderModels(); @@ -683,12 +783,15 @@ export class PythinkerTUI { } private async finishStartup(shouldReplayHistory: boolean): Promise<void> { - this.startUpdateStatusPolling(); if (this.startupNotice !== undefined) { this.showStatus(this.startupNotice); this.startupNotice = undefined; } void this.showTmuxKeyboardWarningIfNeeded(); + // Config diagnostics (deprecated keys/env vars, invalid sections) in + // warning yellow at boot; `run-prompt`/`run-v2-print` print them to + // stderr for non-interactive runs. + void this.showConfigWarningsIfAny(); if (this.state.startupState === 'picker') { void this.bootstrapFromPicker(); return; @@ -697,14 +800,36 @@ export class PythinkerTUI { await this.sessionReplay.hydrateFromReplay(this.requireSession()); this.applyStartupPermissionAndPlanToAppState(); } + const resumeState = this.session?.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } if (this.session !== undefined) { this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(this.session); + } + if (shouldReplayHistory) { + void this.cacheHint.maybeShowOnResume(); } void this.fetchSessions(); if (this.session !== undefined) { this.updateTerminalTitle(); } void this.refreshSkillCommands(this.session); + void this.refreshPluginCommands(this.session); + } + + private async showSessionWarnings(session: Session): Promise<void> { + try { + const warnings = await session.getSessionWarnings(); + if (this.session !== session) return; + for (const warning of warnings) { + const severity = warning.severity === 'error' ? 'error' : 'warning'; + this.showStatus(`Warning: ${warning.message}`, severity); + } + } catch { + // Best-effort: startup must not block on warning retrieval. + } } private async showTmuxKeyboardWarningIfNeeded(): Promise<void> { @@ -715,24 +840,27 @@ export class PythinkerTUI { private async init(): Promise<boolean> { setExperimentalFeatures(await this.harness.getExperimentalFeatures()); - const pythinkerConfig = await this.harness.getConfig(); - setDynamicWorkflowDisabled(pythinkerConfig.disableWorkflows); - setWorkflowSizeGuideline(pythinkerConfig.workflowSizeGuideline); await this.authFlow.refreshAvailableModels(); - void this.refreshProviderModelsInBackground(); + this.backgroundRefreshPromise = this.refreshProviderModelsInBackground(); const { startup } = this.options; const { workDir } = this.state.appState; let session: Session | undefined; let shouldReplayHistory = false; const isResumeStartup = startup.sessionFlag !== undefined || startup.continueLast; - const createSessionOptions: CreateSessionOptions = { + const createSessionOptions: MutableCreateSessionOptions = { workDir, model: startup.model, permission: startup.auto ? 'auto' : startup.yolo ? 'yolo' : undefined, - setupTrigger: startup.init ? 'init' : startup.maintenance ? 'maintenance' : undefined, planMode: startup.plan ? true : undefined, + // --agent/--agent-file bind the startup session only; sessions created + // later in this process fall back to the default profile. + agentProfile: startup.agentProfile, + agentFiles: startup.agentFiles?.length ? [...startup.agentFiles] : undefined, }; + if (this.state.appState.additionalDirs.length > 0) { + createSessionOptions.additionalDirs = [...this.state.appState.additionalDirs]; + } try { if (isResumeStartup) { @@ -751,7 +879,7 @@ export class PythinkerTUI { throw new Error(`Session "${startup.sessionFlag}" not found.`); } if (resolve(target.workDir) !== resolve(workDir)) { - this.presentation.stop(); + this.state.ui.stop(); process.stderr.write( `${currentTheme.fg( 'warning', @@ -765,18 +893,20 @@ export class PythinkerTUI { } session = await this.harness.resumeSession({ id: startup.sessionFlag, - setupTrigger: startup.init ? 'init' : startup.maintenance ? 'maintenance' : undefined, - replayTurnLimit: REPLAY_TURN_LIMIT, + additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); shouldReplayHistory = true; } else { - const sessions = await this.harness.listSessions({ workDir }); - const target = sessions[0]; + // Only the most recent session matters here — fetch a one-item page + // instead of materializing the whole listing. + const page = await this.harness.listSessionsPage({ workDir, limit: 1 }); + const target = page.items[0]; if (target !== undefined) { session = await this.harness.resumeSession({ id: target.id, - setupTrigger: startup.init ? 'init' : startup.maintenance ? 'maintenance' : undefined, - replayTurnLimit: REPLAY_TURN_LIMIT, + additionalDirs: createSessionOptions.additionalDirs, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); shouldReplayHistory = true; } else { @@ -787,6 +917,14 @@ export class PythinkerTUI { ); } } + } else if (this.engineV2) { + // Lazy session creation (v2 engine): start session-less and create the + // session on the first message. Startup flags are carried in appState + // and applied when that session is created; until then the footer + // shows the config defaults the engine would apply at createSession + // time (model, permission, plan mode, thinking effort, context cap). + await this.hydrateLazyConfigDefaults(); + this.appendStartupNotice(SESSIONLESS_STARTUP_NOTICE); } else { session = await this.harness.createSession(createSessionOptions); } @@ -802,21 +940,13 @@ export class PythinkerTUI { return false; } - if (session === undefined) { + if (!this.engineV2 && session === undefined) { throw new Error('Startup session was not initialized.'); } - for (const directory of startup.additionalDirs ?? []) { - try { - await session.addWorkspaceDirectory(directory); - } catch (error) { - this.startupNotice = combineStartupNotice( - this.startupNotice, - `Could not add working directory "${directory}": ${formatErrorMessage(error)}`, - ); - } + if (session !== undefined) { + await this.setSession(session); + await this.syncRuntimeState(session); } - await this.setSession(session); - await this.syncRuntimeState(session); this.applyStartupPermissionAndPlanToAppState(); this.state.startupState = 'ready'; return shouldReplayHistory; @@ -826,24 +956,59 @@ export class PythinkerTUI { if (this.isShuttingDown) return; this.isShuttingDown = true; this.unregisterSignalHandlers(); - this.mouseController.stop(); this.aborted = true; + // Give the startup provider-model refresh a brief chance to finish before + // the harness closes (and the process exits): its config writes are each + // atomic, so draining can only ever leave a complete file behind. Bounded + // so a slow network never delays the exit. + if (this.backgroundRefreshPromise !== undefined) { + await Promise.race([ + this.backgroundRefreshPromise, + new Promise((resolve) => setTimeout(resolve, 1500)), + ]); + } this.streamingUI.discardPending(); - this.editorKeyboard.clearPendingExit(); - this.stopKeybindingsWatcher?.(); - this.stopKeybindingsWatcher = undefined; - this.stopUpdateStatusPolling(); + // Stop background polling, streaming intervals, and per-component timers + // before tearing the UI down, so they can't keep firing requestRender after + // stop() returns (or leak when stop() runs without process.exit). + this.tasksBrowserController.close(); + this.btwPanelController.clear(); + this.stopActivitySpinner(); + this.streamingUI.disposeActiveCompactionBlock(); + this.streamingUI.resetToolUi(); + this.disposeTranscriptChildren(); + this.editorKeyboard.dispose(); + this.state.footer.dispose(); for (const dispose of this.reverseRpcDisposers) { dispose(); } this.reverseRpcDisposers.length = 0; this.disposeTerminalTracking(); - await this.closeSession('shutting down'); - await this.harness.close(); - this.sessionEventHandler.disposeMcpServerStatusRows(); - this.uninstallRainbowColors(); - await this.presentation.drainInput(); - this.presentation.stop(); + // Restore the terminal even if closing the session / harness throws — a + // SIGTERM during a network or MCP shutdown must not leave the user stuck in + // raw mode with a hidden cursor. + try { + await this.closeSession('shutting down'); + this.clearQueuedMessages(); + this.staging.releaseAll(); + this.staging.deleteStaged(this.imageStore.clear()); + await this.staging.drain(); + await this.harness.close(); + } finally { + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.sessionEventHandler.clearStepRetryAttemptTimer(); + this.uninstallRainbowDance(); + try { + await this.state.terminal.drainInput(); + } catch { + // best effort — the terminal may already be dead (SIGHUP / EIO). + } + try { + this.stopUiForExit(); + } catch { + // best effort terminal restore. + } + } if (this.onExit) { await this.onExit(exitCode); } @@ -891,7 +1056,8 @@ export class PythinkerTUI { process.stderr.on('error', terminalErrorHandler); this.signalCleanupHandlers.push(() => { process.stdout.off('error', terminalErrorHandler); - }, () => { + }); + this.signalCleanupHandlers.push(() => { process.stderr.off('error', terminalErrorHandler); }); } @@ -906,34 +1072,33 @@ export class PythinkerTUI { private emergencyTerminalExit(exitCode = 129): never { this.isShuttingDown = true; this.unregisterSignalHandlers(); - this.mouseController.stop(); + // Best-effort terminal restore: stop() may not have run (SIGHUP) or may + // have thrown (SIGTERM cleanup failure), so recover raw mode / cursor / + // bracketed paste before exiting instead of leaving the user's shell broken. + restoreTerminalModes(); process.exit(exitCode); } private disposeTerminalTracking(): void { this.stopTerminalThemeTracking(); + this.clipboardImageHintController?.stop(); + this.clipboardImageHintController = undefined; this.terminalFocusTrackingDispose?.(); this.terminalFocusTrackingDispose = undefined; } private buildLayout(): void { const { ui } = this.state; + // Fullscreen mounts its layout root (transcript ScrollView + bottom dock) + // in createTUIState; the root children list stays empty there. + if (ui instanceof TuiAltScreen) return; ui.clear(); - if (this.state.layout === 'fixed') { - // Full-height root: transcript viewport + chrome + footer fill the - // screen; the editor stays pinned to the bottom (mountFooter toggles - // the footer slot once init succeeds). - ui.addChild(this.state.layoutRoot); - return; - } ui.addChild(this.state.transcriptContainer); ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); - ui.addChild(this.state.mcpStatusContainer); ui.addChild(this.state.editorContainer); - ui.addChild(this.state.statusBarContainer); // Footer is mounted later (mountFooter), not here. } @@ -943,47 +1108,314 @@ export class PythinkerTUI { // only once init() succeeds. FooterComponent isn't a Container, so wrap it to // pick up the same outer gutter as the panels above. private mountFooter(): void { - this.state.statusBarContainer.clear(); - this.state.statusBarContainer.addChild(this.state.statusBar); - if (this.state.layout === 'fixed') { - this.state.layoutRoot.setFooterMounted(true); + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + const dock = this.state.dockContainer; + if (dock !== undefined) { + // Dock sizing contract: the footer may shrink to 1 row under extreme + // height pressure, but never disappears (see createTUIState). + dock.addChild(footerWrap, { shrink: 1, minSize: 1 }); + return; + } + this.state.ui.addChild(footerWrap); + } + + // Fullscreen exit: leave the alternate screen with the frame preserved, + // then replay the transcript through a main-screen renderer so native + // scrollback ends up with the same inline layout a regular session would + // have produced (pi's "transcript" exit form). + private stopUiForExit(): void { + const ui = this.state.ui; + if (!(ui instanceof TuiAltScreen)) { + ui.stop(); return; } - this.state.ui.addChild(this.state.footerWrap); + ui.stop({ preserveScreen: true }); + const main = new TuiMainScreen(ui.terminal); + main.addChild(this.state.transcriptContainer); + main.addChild(this.state.activityContainer); + main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.queueContainer); + main.addChild(this.state.btwPanelContainer); + main.addChild(this.state.editorContainer); + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + main.addChild(footerWrap); + // First paint of a main-screen renderer writes every line sequentially, + // landing the whole transcript in native scrollback. + main.renderNow(); + main.stop(); } // ========================================================================= // Input Dispatch // ========================================================================= + handlePlanToggle(next: boolean): void { + void slashCommands.handlePlanCommand(this, next ? 'on' : 'off'); + } + + handleInputModeChange(mode: 'prompt' | 'bash'): void { + this.setAppState({ inputMode: mode }); + this.updateEditorBorderHighlight(); + } + handleUserInput(text: string): void { + const wasBashMode = this.state.appState.inputMode === 'bash'; + if (wasBashMode) { + // A submit always exits bash mode (the `!` is consumed by this command). + this.state.editor.inputMode = 'prompt'; + this.handleInputModeChange('prompt'); + } if (text.trim().length === 0) return; if (this.state.appState.isReplaying) { this.showError('Cannot send input while session history is replaying.'); return; } - void this.persistInputHistory(text); + // Shell commands are stored with a leading `!` so ↑ recall can tell them + // apart from prompts and restore bash mode (see CustomEditor's mode-aware + // history navigation). The `!` is stripped again when the entry is recalled. + const historyText = wasBashMode ? `!${text}` : text; + void this.persistInputHistory(historyText); + if (wasBashMode) { + // Only one foreground action at a time: queue the shell command while + // another shell command is running or an agent turn is in progress. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(text, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + void this.runShellCommandFromInput(text); + return; + } slashCommands.dispatchInput(this, text); } - sendNormalUserInput(text: string): void { + private async runShellCommandFromInput(command: string): Promise<void> { + let session = this.session; + if (session === undefined) { + if (!this.engineV2) { + this.showError('No active session for shell command.'); + return; + } + session = await this.ensureSession(); + if (session === undefined) return; + // A concurrent first message may have started a prompt while this lazy + // creation was in flight (both inputs share the same creation promise); + // honor the busy gate here, like handleUserInput does before the await, + // instead of running the shell command concurrently with an agent turn. + if (this.state.appState.streamingPhase !== 'idle') { + this.enqueueMessage(command, undefined, 'bash'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + } + // Echo the command locally (bash-input) with a `$` prompt. The agent also + // records it for resume; this is the live view. + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: currentTheme.fg('shellMode', `$ ${command}`), + bullet: '', + }); + // Create the live output entry up front. ShellRunComponent owns its own + // rendering (running card → final view) and is mutated in place as output + // streams in and on completion. + const commandId = nextTranscriptId(); + const outputEntry: TranscriptEntry = { + id: commandId, + kind: 'status', + turnId: undefined, + renderMode: 'plain', + content: '', + }; + const outputComponent = new ShellRunComponent(() => this.state.ui.requestRender()); + this.shellOutputStreams.set(commandId, { entry: outputEntry, component: outputComponent }); + this.state.transcriptEntries.push(outputEntry); + markTranscriptComponent(outputComponent, outputEntry); + this.state.transcriptContainer.addChild(outputComponent); + // Treat command execution as a streaming phase so input queues, the activity + // pane shows the moon spinner, and ctrl+b is enabled while it runs. + this.setAppState({ streamingPhase: 'shell' }); + this.state.ui.requestRender(); + + this.track('shell_command'); + + void session.runShellCommand(command, { commandId }).then( + ({ stdout, stderr, isError, backgrounded }) => { + this.finishShellOutput(commandId, stdout, stderr, isError, backgrounded); + }, + (error: unknown) => { + const message = formatErrorMessage(error); + this.finishShellOutput(commandId, '', message, true); + this.showError(`Shell command failed: ${message}`); + }, + ); + } + + handleShellOutput(event: { commandId: string; update: { kind: string; text?: string } }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + const text = event.update.text ?? ''; + if (text.length === 0) return; + stream.component.append(text); + } + + handleShellStarted(event: { commandId: string; taskId: string }): void { + const stream = this.shellOutputStreams.get(event.commandId); + if (stream === undefined) return; + stream.taskId = event.taskId; + } + + cancelRunningShellCommand(): void { + const session = this.session; + if (session === undefined) return; + for (const commandId of this.shellOutputStreams.keys()) { + void session.cancelShellCommand(commandId).catch((error: unknown) => { + this.showError(`Failed to cancel shell command: ${formatErrorMessage(error)}`); + }); + } + } + + private finishShellOutput( + commandId: string, + stdout: string, + stderr: string, + isError?: boolean, + backgrounded?: boolean, + ): void { + const stream = this.shellOutputStreams.get(commandId); + if (stream === undefined) return; + if (backgrounded === true) { + // The command was moved to the background; detachRunningShellCommand owns + // the UI and the model notification, so there is nothing to render here. + return; + } + stream.component.finish(stdout, stderr, isError); + // Keep the transcript entry's metadata in sync for anything that reads it + // (export / copy). The component renders itself. + stream.entry.content = formatBashOutputForDisplay(stdout, stderr, isError); + this.shellOutputStreams.delete(commandId); + // When the last shell command finishes, leave the shell streaming phase, + // release one queued message (if any), and refresh the activity pane. + if (this.shellOutputStreams.size === 0) { + this.setAppState({ streamingPhase: 'idle' }); + this.drainOneQueuedMessage(); + } + } + + private drainOneQueuedMessage(): void { + const session = this.session; + if (session === undefined) return; + const item = this.shiftQueuedMessage(); + if (item === undefined) return; + if (item.mode === 'bash') { + this.staging.releaseQueued([item]); + void this.runShellCommandFromInput(item.text); + } else { + this.sendQueuedMessage(session, item); + } + this.updateQueueDisplay(); + } + + async sendNormalUserInput(text: string, preExtracted?: ExtractionResult): Promise<void> { if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; } - const extraction = extractMediaAttachments(text, this.imageStore); - if (!this.validateMediaCapabilities(extraction)) return; - const session = this.session; - if (session === undefined) { - this.showError(LLM_NOT_SET_MESSAGE); + let extraction: ReturnType<typeof extractMediaAttachments>; + if (preExtracted === undefined) { + // A just-pasted image may still be finishing its background ingestion + // (compression/daemon upload): give it a bounded moment so the submit + // can use the compressed/daemon-ref form — a slower ingestion extracts + // to the inline fallback instead. Undefined when nothing is pending, + // keeping the media-free send path synchronous. + const ingestionWait = pendingImageIngestions( + text, + this.imageStore, + IMAGE_INGESTION_SUBMIT_WAIT_MS, + ); + if (ingestionWait !== undefined) await ingestionWait; + } + try { + // Pasted videos are copied into the cache and expand to a `file://` + // `video_url` part; the engine resolves (uploads or degrades) them + // inside the turn, so submission stays fully synchronous. + // + // A cache-hint-swallowed resend passes its pre-dialog extraction back + // in: the image store may already be cleared (e.g. after "Start a new + // session"), so re-extracting from the text would lose the media. + extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); + if (preExtracted !== undefined) { + const parts = refreshExpiringImageFileRefs( + extraction.parts, + extraction.imageAttachmentIds, + this.imageStore, + ); + if (parts !== extraction.parts) extraction = { ...extraction, parts }; + } + } catch (error) { + // A video cache copy failed (unwritable cache dir, vanished source…); + // nothing was dispatched. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); return; } + // Create the staging lease right after extraction, so every exit below + // releases through the tracker instead of open-coding ids/paths — a + // forgotten exit degrades to an unclaimed lease (swept by `releaseAll`) + // instead of a permanently retained upload. The lease carries the + // exact-binding submission id: the consuming turn's `turn.started` echoes + // it as `promptId`. A goal-active submission is steered and binds its + // lease explicitly in sendMessageInternal, so it gets no id. + const stagingLease = this.staging.create( + // One retain per unique id per extraction: dedupe repeated placeholder + // occurrences so the lease's id multiplicity matches the retain count. + [...new Set(extraction.imageAttachmentIds)], + extraction.stagingPaths, + 'user', + extraction.hasMedia && this.state.appState.goal?.status !== 'active' + ? randomUUID() + : undefined, + ); + if (!this.validateMediaCapabilities(extraction)) { + this.staging.release(stagingLease); + return; + } + // Idle cache-hint interception sits before session creation; it is + // synchronous unless a hint actually fires. Aside from the bounded + // ingestion wait above, the send path stays await-free up to sendMessage. + if (this.cacheHint.maybeInterceptOnSubmit(text, extraction)) { + // The stash owns the extraction from here: its resend re-leases inside + // the re-entered send path, its restore goes through releaseRecalled + // (see CacheHintController). Detach so the stash is not double-owned. + this.staging.defer(stagingLease); + return; + } + let session = this.session; + if (session === undefined) { + if (!this.engineV2) { + this.showError(LLM_NOT_SET_MESSAGE); + this.staging.release(stagingLease); + return; + } + session = await this.ensureSession(); + if (session === undefined) { + this.staging.release(stagingLease); + return; + } + } if (extraction.hasMedia) { this.sendMessage(session, text, { hasMedia: true, parts: extraction.parts, imageAttachmentIds: extraction.imageAttachmentIds, + stagingPaths: extraction.stagingPaths, + lease: stagingLease, }); } else { this.sendMessage(session, text); @@ -992,12 +1424,115 @@ export class PythinkerTUI { this.state.ui.requestRender(); } - private validateMediaCapabilities( + async sendInlineSkillUserInput( + text: string, + activations: readonly InlineSkillActivation[], + preExtracted?: ExtractionResult, + ): Promise<void> { + if (this.btwPanelController.sendUserInput(text, activations)) return; + if (this.state.appState.model.trim().length === 0) { + this.showError(LLM_NOT_SET_MESSAGE); + return; + } + let extraction: ReturnType<typeof extractMediaAttachments>; + try { + extraction = preExtracted ?? extractMediaAttachments(text, this.imageStore); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(extraction)) return; + if (this.cacheHint.maybeInterceptOnSubmit(text, extraction, activations)) return; + let session = this.session; + if (session === undefined) { + // Dispatch only routes here on the v2 engine, so the session is created + // lazily on first use exactly like a normal prompt. + session = await this.ensureSession(); + if (session === undefined) return; + } + if ( + this.deferUserMessages || + this.state.appState.goal?.status === 'active' || + this.state.appState.streamingPhase !== 'idle' || + this.state.appState.isCompacting + ) { + this.enqueueMessage( + text, + extraction.hasMedia + ? { + hasMedia: true, + parts: extraction.parts, + imageAttachmentIds: extraction.imageAttachmentIds, + stagingPaths: extraction.stagingPaths, + inlineSkillActivations: activations, + } + : { inlineSkillActivations: activations }, + ); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + this.beginSessionRequest(); + void this.runInlineSkillActivations(session, text, activations, extraction).catch( + (error: unknown) => { + this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); + }, + ); + } + + private async runInlineSkillActivations( + session: Session, + text: string, + activations: readonly InlineSkillActivation[], extraction: ReturnType<typeof extractMediaAttachments>, - ): boolean { + ): Promise<void> { + const knownEntryIds = new Set(this.state.transcriptEntries.map((entry) => entry.id)); + await session.promptWithSkills( + extraction.hasMedia + ? resolveOriginalCaptions( + extraction.parts, + extraction.imageAttachmentIds, + this.imageStore, + originalsDirForSession(session), + ) + : text, + activations.map((activation) => ({ name: activation.skillName, args: activation.args })), + ); + // The engine bundles the activations into the prompt's own message, and + // the `skill.activated` events land synchronously during the call — so + // the cards appended for this submission are the skill_activation entries + // with fresh ids (the window trim may replace the entries array mid-call, + // so membership is decided by id, not by index into a captured array). + // Appending the user entry afterwards keeps the live transcript in the + // same order as a resumed replay (skill cards first, prompt last). + // Marking only happens once the submission was accepted: a rejected + // bundle leaves no cards and must not leave a local undo anchor the + // engine never recorded. + for (const entry of this.state.transcriptEntries) { + if (entry.kind === 'skill_activation' && !knownEntryIds.has(entry.id)) { + entry.bundledWithPrompt = true; + } + } + this.appendTranscriptEntry({ + id: nextTranscriptId(), + kind: 'user', + turnId: undefined, + renderMode: 'plain', + content: text, + imageAttachmentIds: + extraction.imageAttachmentIds.length > 0 ? extraction.imageAttachmentIds : undefined, + }); + } + + validateMediaCapabilities(extraction: { + hasMedia: boolean; + imageAttachmentIds: readonly number[]; + videoAttachmentIds: readonly number[]; + imageSnapshots?: readonly unknown[]; + }): boolean { if (!extraction.hasMedia) return true; if ( - extraction.imageAttachmentIds.length > 0 && + (extraction.imageAttachmentIds.length > 0 || (extraction.imageSnapshots?.length ?? 0) > 0) && !this.supportsCurrentModelCapability('image_in') ) { this.showError('Current model does not support image input.'); @@ -1025,7 +1560,7 @@ export class PythinkerTUI { const file = getInputHistoryFile(this.state.appState.workDir); const entries = await loadInputHistory(file); for (const entry of entries) { - this.presentation.addComposerHistory(entry.content); + this.state.editor.addToHistory(entry.content); } this.lastHistoryContent = entries.at(-1)?.content; } catch { @@ -1037,7 +1572,7 @@ export class PythinkerTUI { const trimmed = text.trim(); if (trimmed.length === 0) return; if (trimmed === this.lastHistoryContent) return; - this.presentation.addComposerHistory(trimmed); + this.state.editor.addToHistory(trimmed); try { const file = getInputHistoryFile(this.state.appState.workDir); const written = await appendInputHistory(file, trimmed, this.lastHistoryContent); @@ -1047,18 +1582,56 @@ export class PythinkerTUI { } } - recallLastQueued(): string | undefined { + recallLastQueued(): QueuedMessage | undefined { if (this.state.queuedMessages.length === 0) return undefined; const last = this.state.queuedMessages.at(-1)!; this.state.queuedMessages = this.state.queuedMessages.slice(0, -1); - return last.text; + // A recall restores the draft into the editor — it is not a discard: + // consumes the retains only, keeping staged files alive (see + // `releaseRecalled`), and rebases recalled videos onto their staged cache + // copies so a vanished original source cannot lose the media. + this.staging.releaseRecalled(last); + this.rebaseRecalledVideoSources(last.text, last.stagingPaths); + return last; + } + + /** + * Cache-hint restore: a dismissed/hand-back interception returns its draft + * to the editor — same semantics as a queue recall (consume the stash + * extraction's retains, retire its staged copies, rebase videos onto them). + */ + recallStashedMedia(text: string, extraction: ExtractionResult | undefined): void { + if (extraction === undefined) return; + this.staging.releaseRecalled({ + imageAttachmentIds: extraction.imageAttachmentIds, + stagingPaths: extraction.stagingPaths, + }); + this.rebaseRecalledVideoSources(text, extraction.stagingPaths); + } + + private rebaseRecalledVideoSources( + text: string, + stagingPaths: readonly string[] | undefined, + ): void { + if (stagingPaths === undefined || stagingPaths.length === 0) return; + const videoIds = videoAttachmentIdsInText(text, this.imageStore); + stagingPaths.forEach((path, index) => { + const id = videoIds[index]; + if (id !== undefined) this.imageStore.rebaseVideoSource(id, path); + }); } // ========================================================================= // Session Requests / Queues // ========================================================================= - private enqueueMessage(text: string, options?: SendMessageOptions): void { + private enqueueMessage( + text: string, + options?: SendMessageOptions & { + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; + }, + mode?: 'prompt' | 'bash', + ): void { this.state.queuedMessages.push({ text, agentId: this.harness.interactiveAgentId, @@ -1067,11 +1640,18 @@ export class PythinkerTUI { options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 ? options.imageAttachmentIds : undefined, + stagingPaths: + options?.stagingPaths !== undefined && options.stagingPaths.length > 0 + ? options.stagingPaths + : undefined, + mode, + inlineSkillActivations: options?.inlineSkillActivations, }); this.track('input_queue'); } beginSessionRequest(): void { + this.cacheHint.onTurnBegin(); this.streamingUI.setTurnId(undefined); this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); @@ -1095,21 +1675,80 @@ export class PythinkerTUI { } sendQueuedMessage(session: Session, item: QueuedMessage): void { + if (item.mode === 'bash') { + this.staging.releaseQueued([item]); + void this.runShellCommandFromInput(item.text); + return; + } + if (item.mode === 'skill' && item.skillName !== undefined) { + // sendSkillActivation re-checks the busy state, so a premature drain + // re-queues at the tail instead of racing the running turn. + this.sendSkillActivation(session, item.skillName, item.skillArgs ?? ''); + return; + } + if (item.inlineSkillActivations !== undefined && item.inlineSkillActivations.length > 0) { + // Media was extracted and validated at enqueue time; reuse the queued + // parts rather than re-extracting from a possibly-cleared image store. + // Expiring daemon refs refresh at dispatch, same as the plain tail below. + const refreshed = + item.parts === undefined + ? [] + : [ + ...refreshExpiringImageFileRefs( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + ), + ]; + this.beginSessionRequest(); + void this.runInlineSkillActivations( + session, + item.text, + item.inlineSkillActivations, + { + parts: refreshed, + hasMedia: refreshed.length > 0, + imageAttachmentIds: item.imageAttachmentIds !== undefined ? [...item.imageAttachmentIds] : [], + videoAttachmentIds: [], + imageSnapshots: [], + stagingPaths: item.stagingPaths !== undefined ? [...item.stagingPaths] : [], + }, + ).catch((error: unknown) => { + this.failSessionRequest(`Skill activation failed: ${formatErrorMessage(error)}`); + }); + return; + } + const parts = + item.parts === undefined + ? undefined + : refreshExpiringImageFileRefs( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + ); this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => { this.sendMessageInternal(session, item.text, { - parts: item.parts, + parts, imageAttachmentIds: item.imageAttachmentIds, + stagingPaths: item.stagingPaths, }); }); } - requestQueuedGoalPromotion(): void { - this.sessionEventHandler.requestQueuedGoalPromotion(); + handleTurnStarted(event: TurnStartedEvent): void { + this.staging.handleTurnStarted(event); + } + + handleTurnEnded(event: TurnEndedEvent): void { + this.staging.handleTurnEnded(event); } - /** Retires all live Dynamic Workflow mission controls (undo / turn cleanup). */ - clearDynamicWorkflowMissionControls(): void { - this.sessionEventHandler.clearDynamicWorkflowMissionControls(); + releaseStagingMedia(imageAttachmentIds: readonly number[], paths: readonly string[]): void { + this.staging.releaseMedia(imageAttachmentIds, paths); + } + + requestQueuedGoalPromotion(): void { + this.sessionEventHandler.requestQueuedGoalPromotion(); } private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { @@ -1125,29 +1764,171 @@ export class PythinkerTUI { content: input, imageAttachmentIds, }); - + // A goal-active steer is buffered into the running goal turn — no new + // turn.started will fire for handleTurnStarted to claim the lease — so + // bind it to that turn here. The turn context must be read BEFORE + // beginSessionRequest resets it, and only while a turn is actually live + // (finalizeTurn clears the id at turn end; a queued dispatch can land + // while the goal driver's next continuation turn is already streaming). + const runningTurnId = + this.state.appState.streamingPhase === 'idle' || this.state.appState.streamingPhase === 'shell' + ? undefined + : this.streamingUI.getTurnContext().turnId; this.beginSessionRequest(); - const sdkInput = options?.parts ?? input; - void session.prompt(sdkInput).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Failed to send: ${message}`); + // Compression captions for pasted images are authored here — not at + // extraction — because only now is the session (and its media-originals + // dir) known: extraction runs before a first session exists. + const sdkInput = + options?.parts !== undefined + ? resolveOriginalCaptions( + options.parts, + options.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ) + : input; + const goalActive = this.state.appState.goal?.status === 'active'; + // The lease normally arrives pre-created by sendNormalUserInput (carrying + // its exact-binding submission id). Queued dispatches and steer batches + // arrive with raw ids/paths instead: a prompt submission carrying staged + // media gets a client-chosen prompt id minted here — the engine echoes it + // on the consuming turn's `turn.started` (`promptId`), so the lease binds + // exactly instead of through the origin heuristic. The goal-steer path + // binds its lease explicitly below, so it gets no id. + const stagingLease = + options?.lease ?? + this.staging.create( + // One retain per unique id per extraction: dedupe repeated placeholder + // occurrences so the lease's id multiplicity matches the retain count. + imageAttachmentIds === undefined ? [] : [...new Set(imageAttachmentIds)], + options?.stagingPaths ?? [], + 'user', + !goalActive && (imageAttachmentIds !== undefined || (options?.stagingPaths?.length ?? 0) > 0) + ? randomUUID() + : undefined, + ); + const submissionId = stagingLease?.submissionId; + // While a goal is being pursued the engine holds its active turn across the + // whole continuation loop, so a fresh prompt races the goal driver at every + // continuation boundary and is rejected with `turn.agent_busy`, dropping + // the message. Steer instead: the engine buffers it into the running goal + // turn, or launches a turn of its own if the loop just ended. + if (goalActive) { + if (runningTurnId !== undefined) this.staging.bindToTurn(stagingLease, runningTurnId); + this.staging.trackDispatch(stagingLease, session.steer(sdkInput), (error) => { + // Same reset as the prompt path: beginSessionRequest already moved the + // TUI to the waiting phase, and no turn events may follow a failed + // steer (e.g. the session is gone), which would leave the UI stuck + // queueing input behind a request that never completes. + this.failSessionRequest(`Failed to steer: ${formatErrorMessage(error)}`); + }); + return; + } + this.staging.trackDispatch(stagingLease, session.prompt(sdkInput, { promptId: submissionId }), (error) => { + this.failSessionRequest(`Failed to send: ${formatErrorMessage(error)}`); }); } sendSkillActivation(session: Session, skillName: string, skillArgs: string): void { - this.beginSessionRequest(); - void session - .activateSkill(skillName, skillArgs) - .then((result) => { - if (result.execution === 'fork') { - this.streamingUI.finalizeTurn((item) => this.sendQueuedMessage(session, item)); - } - }) - .catch((error: unknown) => { - const message = formatErrorMessage(error); - this.failSessionRequest(`Skill "${skillName}" failed: ${message}`); + // Args are a plain-text channel, so pasted media can't ride along as + // inline parts. Skill args are XML-escaped on render (renderSkillAttributes + // + expandSkillParameters), so rewrite placeholders into escape-proof + // plain-text file references the model can open with ReadMediaFile. + let rewrite: ReturnType<typeof rewriteMediaPlaceholders>; + try { + rewrite = rewriteMediaPlaceholders(skillArgs, this.imageStore, 'plain'); + } catch (error) { + // Cache copy failed (unwritable cache dir, vanished video source…); + // nothing has been dispatched yet, so just report and keep the input. + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + if (!this.validateMediaCapabilities(rewrite)) { + this.staging.releaseMedia(rewrite.imageAttachmentIds, rewrite.stagingPaths); + return; + } + // Compacting (or deferred input): queue behind it — visible and recallable. + // Slash-skill items steer like any queued input on Ctrl-S (the activation + // fires into the running turn instead of the literal text) — see + // editor-keyboard.ts. + // A running turn queues the activation too: every skill behaves like + // plain input — queued by default, steered on demand — because the engine + // steers activations into a running turn exactly like a steered user + // message (v2 `prompt.inject`, v1 `SkillManager.recordActivation`). + // The rewritten args reference the staging cache copies by plain path, + // never the daemon uploads, so queueing takes recall semantics: the + // retains are consumed and the copies retire to session lifetime — they + // must stay readable until the item drains. + const turnRunning = this.state.appState.streamingPhase !== 'idle'; + if (this.deferUserMessages || this.state.appState.isCompacting || turnRunning) { + const args = rewrite.text.trim(); + this.state.queuedMessages.push({ + text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`, + agentId: this.harness.interactiveAgentId, + mode: 'skill', + skillName, + skillArgs: rewrite.text, + }); + this.staging.releaseRecalled({ + imageAttachmentIds: rewrite.imageAttachmentIds, + stagingPaths: rewrite.stagingPaths, }); + this.track('input_queue'); + this.updateQueueDisplay(); + this.state.ui.requestRender(); + return; + } + const stagingLease = this.staging.create( + [...new Set(rewrite.imageAttachmentIds)], + rewrite.stagingPaths, + 'skill_activation', + ); + this.beginSessionRequest(); + this.staging.trackDispatch( + stagingLease, + session.activateSkill(skillName, rewrite.text), + (error) => { + this.failSessionRequest(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); + }, + ); + } + + activatePluginCommand( + session: Session, + pluginId: string, + commandName: string, + args: string, + ): void { + // Plugin command args are expanded verbatim (no XML escaping), so the + // standard <image|video path> tag convention works — see + // sendSkillActivation for the escaped-channel variant. + let rewrite: ReturnType<typeof rewriteMediaPlaceholders>; + try { + rewrite = rewriteMediaPlaceholders(args, this.imageStore, 'tag'); + } catch (error) { + this.showError(`Failed to prepare media attachment: ${formatErrorMessage(error)}`); + return; + } + const stagingLease = this.staging.create( + [...new Set(rewrite.imageAttachmentIds)], + rewrite.stagingPaths, + 'plugin_command', + ); + if (!this.validateMediaCapabilities(rewrite)) { + this.staging.release(stagingLease); + return; + } + this.beginSessionRequest(); + this.staging.trackDispatch( + stagingLease, + session.activatePluginCommand(pluginId, commandName, rewrite.text), + (error) => { + this.failSessionRequest( + `Command "${pluginId}:${commandName}" failed: ${formatErrorMessage(error)}`, + ); + }, + ); } private sendMessage(session: Session, input: string, options?: SendMessageOptions): void { @@ -1156,39 +1937,78 @@ export class PythinkerTUI { this.state.appState.streamingPhase !== 'idle' || this.state.appState.isCompacting ) { + // A queued message re-leases its staged media at dequeue dispatch; the + // pre-dispatch lease defers to the queue item's raw ids/paths. + this.staging.defer(options?.lease); this.enqueueMessage(input, options); return; } this.sendMessageInternal(session, input, options); } - steerMessage(session: Session, input: string[]): void { + steerMessage(session: Session, input: readonly SteerInputItem[]): void { if (this.deferUserMessages || this.state.appState.isCompacting) { - for (const part of input) { - this.enqueueMessage(part); + for (const item of input) { + this.enqueueMessage(item.text, item); } return; } if (this.state.appState.streamingPhase === 'idle') { - for (const part of input) { - this.sendMessageInternal(session, part); + for (const item of input) { + this.sendMessageInternal(session, item.text, item); } return; } - for (const part of input) { + for (const item of input) { this.appendTranscriptEntry({ id: nextTranscriptId(), kind: 'user', turnId: this.streamingUI.getTurnContext().turnId, renderMode: 'plain', - content: part, + content: item.text, + imageAttachmentIds: + item.imageAttachmentIds !== undefined && item.imageAttachmentIds.length > 0 + ? item.imageAttachmentIds + : undefined, }); } - void session.steer(input.join('\n\n')).catch((error: unknown) => { - const message = formatErrorMessage(error); - this.showError(`Failed to steer: ${message}`); + // Dedupe per item, not across the batch: each queued message retained a + // shared image once, so the batch's id multiplicity is the retain count. + const imageAttachmentIds = input.flatMap((item) => [ + ...new Set(item.imageAttachmentIds ?? []), + ]); + const stagingPaths = input.flatMap((item) => item.stagingPaths ?? []); + const stagingLease = this.staging.create(imageAttachmentIds, stagingPaths, 'user'); + const currentTurnId = this.streamingUI.getTurnContext().turnId; + if (currentTurnId !== undefined) this.staging.bindToTurn(stagingLease, currentTurnId); + // Same dispatch-time caption resolution as sendMessageInternal — the + // running turn's session owns the persisted originals. + const resolvedInput = input.map((item) => + item.parts === undefined + ? item + : { + ...item, + parts: resolveOriginalCaptions( + item.parts, + item.imageAttachmentIds ?? [], + this.imageStore, + originalsDirForSession(session), + ), + }, + ); + this.staging.trackDispatch(stagingLease, session.steer(combineSteerInput(resolvedInput)), (error) => { + this.showError(`Failed to steer: ${formatErrorMessage(error)}`); + }); + } + + steerSkillActivation(session: Session, skillName: string, skillArgs: string): void { + // Ctrl-S on a queued slash-skill item: the activation fires into the + // running turn (the engine steers it there, never the literal text). No + // beginSessionRequest — the live pane belongs to the running turn. + void session.activateSkill(skillName, skillArgs).catch((error: unknown) => { + this.showError(`Skill "${skillName}" failed: ${formatErrorMessage(error)}`); }); } @@ -1201,7 +2021,9 @@ export class PythinkerTUI { } clearQueuedMessages(): void { + const queued = this.state.queuedMessages; this.state.queuedMessages = []; + this.staging.releaseQueued(queued); } shiftQueuedMessage(): QueuedMessage | undefined { @@ -1219,56 +2041,10 @@ export class PythinkerTUI { this.state.externalEditorRunning = running; } - reloadKeybindings(): readonly string[] { - const loaded = loadKeybindings(this.harness.homeDir); - if (loaded.valid || !this.hasInstalledKeybindings) { - this.keybindings = loaded.bindings; - this.hasInstalledKeybindings = true; - for (const component of [ - this.state.editor, - this.state.footer, - this.mountedEditorReplacement, - ]) { - if (isKeybindingAware(component)) component.setKeybindings(this.keybindings); - } - this.editorKeyboard?.setKeybindings(this.keybindings); - this.keyboardShortcuts = editorShortcutHelp(this.keybindings); - } - return loaded.warnings; - } - - private startKeybindingsWatcher(): void { - this.stopKeybindingsWatcher ??= watchKeybindings(this.harness.homeDir, () => { - const warnings = this.reloadKeybindings(); - if (warnings.length === 0) return; - this.showStatus(`Keybindings reloaded with warnings: ${warnings.join(' ')}`, 'warning'); - }); - } - setTasksBrowser(value: TUIState['tasksBrowser']): void { this.state.tasksBrowser = value; } - openFooterAction(id: FooterActionId): void { - if (id === 'goal') { - this.handleUserInput('/goal status'); - return; - } - void this.tasksBrowserController.show(); - } - - canFocusFooter(): boolean { - return ( - this.state.footer.actionItems().length > 0 && - !this.state.editor.isShowingAutocomplete() && - this.mountedEditorReplacement === undefined && - this.state.tasksBrowser === undefined && - this.state.btwPanelContainer.children.length === 0 && - !this.state.appState.isCompacting && - !this.state.ui.hasOverlay() - ); - } - appendStartupNotice(extra: string): void { this.startupNotice = combineStartupNotice(this.startupNotice, extra); } @@ -1289,6 +2065,10 @@ export class PythinkerTUI { this.exitOpenUrl = url; } + setExitForegroundTask(task: (exitCode: number) => Promise<void>): void { + this.exitForegroundTask = task; + } + async getStartupMcpMs(): Promise<number> { const session = this.session; if (session === undefined) return 0; @@ -1302,112 +2082,20 @@ export class PythinkerTUI { setAppState(patch: Partial<AppState>): void { if (!hasPatchChanges(this.state.appState, patch)) return; + const additionalDirsChanged = + 'additionalDirs' in patch && + !sameStringArrays(this.state.appState.additionalDirs, patch.additionalDirs ?? []); const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; Object.assign(this.state.appState, patch); - if ('planMode' in patch || 'permissionMode' in patch) this.updateEditorBorderHighlight(); - this.state.footer.syncAppState(this.state.appState); - this.syncFooterState(); + if ('planMode' in patch) this.updateEditorBorderHighlight(); + this.state.footer.setState(this.state.appState); this.updateActivityPane(); if (busyChanged) { this.updateQueueDisplay(); this.sessionEventHandler.retryQueuedGoalPromotion(); } - if (patch.streamingPhase === 'idle') { - this.presentation.notifyIdle(); - } else { - this.state.ui.requestRender(); - } - } - - dispatchFooter(event: FooterEvent): void { - this.dispatchFooterEvents([event]); - } - - private syncFooterState(): void { - this.dispatchFooterEvents([ - { - type: 'status.updated', - changes: footerStatusFromAppState( - this.state.appState, - this.state.footer.getGitStatus(), - ), - }, - { type: 'goal.updated', goal: this.footerGoal() }, - ]); - } - - private refreshFooter(): void { - this.dispatchFooterEvents([ - { - type: 'status.updated', - changes: footerStatusFromAppState( - this.state.appState, - this.state.footer.getGitStatus(), - ), - }, - ]); - } - - private dispatchFooterEvents(events: readonly FooterEvent[]): void { - for (const event of events) { - if (event.type === 'background-counts.updated') { - this.state.footer.syncActionCounts(event.counts); - } - } - this.state.footerState = foldFooterEvents(this.state.footerState, events); - this.presentation.updateFooter( - selectFooterViewModel( - this.state.footerState, - Date.now(), - this.state.appState.statusLine, - ), - ); - const statusParts = selectStatusItemParts( - this.state.footerState, - Date.now(), - this.state.appState.statusLine, - ); - this.state.statusBar.update({ - ...this.state.footerState.status, - extras: selectStatusBarExtras( - this.state.footerState, - Date.now(), - this.state.appState.statusLine, - ), - updateExtra: statusParts.update ?? undefined, - sessionKey: - this.state.appState.sessionTitle?.trim() || - this.state.appState.sessionId || - this.state.appState.workDir, - statusLine: this.state.appState.statusLine, - }); - } - - private footerGoal(): FooterGoal | null { - const goal = this.state.appState.goal; - if (goal === null || goal === undefined) { - this.footerGoalSnapshotKey = null; - return null; - } - const snapshotKey = [ - goal.goalId, - goal.status, - String(goal.turnsUsed), - String(goal.tokensUsed), - String(goal.wallClockMs), - String(goal.budget.turnBudget), - ].join('\u0000'); - if (snapshotKey !== this.footerGoalSnapshotKey) { - this.footerGoalSnapshotKey = snapshotKey; - this.footerGoalObservedAtMs = Date.now(); - } - return { - status: goal.status, - turnsUsed: goal.turnsUsed, - turnBudget: goal.budget.turnBudget, - wallClockMs: goal.wallClockMs, - observedAtMs: this.footerGoalObservedAtMs, - }; + if (additionalDirsChanged) this.setupAutocomplete(); + this.state.ui.requestRender(); } patchLivePane(patch: Partial<LivePaneState>): void { @@ -1423,6 +2111,12 @@ export class PythinkerTUI { this.state.ui.requestRender(); } + private syncAdditionalDirs(session: Session): void { + const additionalDirs = session.summary?.additionalDirs ?? []; + if (sameStringArrays(this.state.appState.additionalDirs, additionalDirs)) return; + this.setAppState({ additionalDirs: [...additionalDirs] }); + } + // ========================================================================= // Session Runtime // ========================================================================= @@ -1434,27 +2128,199 @@ export class PythinkerTUI { return this.session; } - private async createSessionFromCurrentState(): Promise<Session> { + /** + * Seed appState with the config defaults the v2 engine would apply at + * createSession time (model, permission, plan mode, thinking effort, + * context cap), so the footer and the lazy create path reflect them while + * no session exists. Runs at session-less startup and again on /reload + * while still session-less, so externally edited defaults take effect + * before the first lazy-created session. + */ + async hydrateLazyConfigDefaults(): Promise<void> { + const { startup } = this.options; + const config = await this.harness.getConfig({ reload: true }); + const patch: Partial<AppState> = {}; + const startupModel = startup.model ?? config.defaultModel; + if (startupModel !== undefined) { + patch.model = startupModel; + const selected = config.models?.[startupModel]; + if (selected?.maxContextSize !== undefined) { + patch.maxContextTokens = selected.maxContextSize; + } + } else { + // The default disappeared from config (edited externally): clear the + // previously hydrated value instead of passing a stale explicit model + // to the first lazy-created session. + patch.model = ''; + patch.maxContextTokens = 0; + } + // CLI --auto/--yolo/--plan win over config defaults; the flags are + // re-applied by applyStartupPermissionAndPlanToAppState at startup. + if (!startup.auto && !startup.yolo) { + // Reset to manual when the default was removed from config — a stale + // elevated mode must not be passed to the first lazy-created session. + patch.permissionMode = config.defaultPermissionMode ?? 'manual'; + } + // Track the config default itself (vs an explicit CLI --plan) so the lazy + // create path can tell which one would activate plan mode; a removed + // default also clears the hydrated footer value. + patch.configDefaultPlanMode = config.defaultPlanMode === true; + if (!startup.plan) { + patch.planMode = config.defaultPlanMode === true; + } + const effort = thinkingEffortFromConfig(config.thinking); + if (effort !== undefined) { + patch.thinkingEffort = effort; + } else if (startupModel !== undefined) { + // No concrete effort configured: mirror the engine, which resolves the + // model's default effort at createSession time. + const raw = config.models?.[startupModel]; + if (raw !== undefined) { + const providerType = config.providers?.[raw.provider]?.type; + patch.thinkingEffort = defaultThinkingEffortFor( + effectiveModelAlias(raw, providerType ?? raw.protocol), + ); + } + } + if (startup.agentProfile !== undefined || startup.agentFiles !== undefined) { + patch.agentProfile = startup.agentProfile; + patch.agentFiles = startup.agentFiles?.length ? [...startup.agentFiles] : undefined; + } + this.setAppState(patch); + } + + private async createSessionFromCurrentState(bindStartupAgent = false): Promise<Session> { + // Background warm-up of the cache-hint config on every new session. + this.cacheHint.refreshConfigInBackground(); const model = this.state.appState.model.trim(); if (model.length === 0) { throw new Error(LLM_NOT_SET_MESSAGE); } - return this.harness.createSession({ - workDir: this.state.appState.workDir, - model, - thinking: - this.session === undefined ? undefined : this.state.appState.thinkingLevel, - permission: this.state.appState.permissionMode, - planMode: this.state.appState.planMode ? true : undefined, - }); + // With an active session, carry the live plan state. Session-less (lazy + // creation / `/new` before the first session) on v2, pass only the + // explicit CLI --plan intent — and only when the engine is not already + // applying `defaultPlanMode` at create time (sessionLifecycleService), + // since re-entering an active plan mode throws. On v1 (which never + // pre-fills plan mode from config), keep the historical appState value. + const explicitPlanMode = + this.session !== undefined || !this.engineV2 + ? this.state.appState.planMode + : this.options.startup.plan && this.state.appState.configDefaultPlanMode !== true; + const options: MutableCreateSessionOptions = { + workDir: this.state.appState.workDir, + model, + // With an active session, carry the live effort. Session-less (lazy + // creation / `/new` before the first session), carry the session-only + // thinking override chosen via Alt+S if any — never the initial 'off' + // default, which would force thinking off where the engine's config or + // model default would apply. + thinking: + this.session === undefined + ? this.state.appState.lazySessionThinking + : this.state.appState.thinkingEffort, + permission: this.state.appState.permissionMode, + planMode: explicitPlanMode ? true : undefined, + }; + if (this.state.appState.additionalDirs.length > 0) { + options.additionalDirs = [...this.state.appState.additionalDirs]; + } + if (bindStartupAgent) { + // The --agent/--agent-file startup binding is consumed by the first + // lazy-created session; `/new` sessions fall back to the default profile. + if (this.state.appState.agentProfile !== undefined) { + options.agentProfile = this.state.appState.agentProfile; + } + if (this.state.appState.agentFiles !== undefined) { + options.agentFiles = [...this.state.appState.agentFiles]; + } + } + return this.harness.createSession(options); + } + + /** + * Lazy-create the session on first use (v2 engine, session-less startup). + * Returns the existing session, or creates one from the current state and + * runs the same assembly `createNewSession` performs. Returns undefined and + * shows the error when creation fails; callers must still guard on + * `appState.model`. + * + * Concurrent first-use triggers (a double Enter, or a slash command right + * after a prompt) both observe `session === undefined`, so the first caller + * owns the creation and the rest share the in-flight promise — otherwise + * two sessions would be created and the later `setSession` would close the + * first one mid-dispatch. + */ + async ensureSession(): Promise<Session | undefined> { + // Even when a session is already assigned, a previous lazy creation may + // still be finishing its assembly (runtime sync, command refresh, + // subscription). Wait for it so callers never dispatch against a + // partially initialized session. + if (this.ensureSessionPromise !== null) return this.ensureSessionPromise; + if (this.session !== undefined) return this.session; + this.ensureSessionPromise = this.lazyCreateSession().finally(() => { + this.ensureSessionPromise = null; + }); + return this.ensureSessionPromise; + } + + /** Await the in-flight lazy session creation, if any (v2); no-op otherwise. */ + async waitForLazyCreation(): Promise<void> { + await this.ensureSessionPromise; + } + + private async lazyCreateSession(): Promise<Session | undefined> { + let session: Session; + try { + session = await this.createSessionFromCurrentState(true); + } catch (error) { + const msg = formatErrorMessage(error); + this.showError(`Failed to start a session: ${msg}`); + return undefined; + } + this.resetSessionRuntime(); + await this.setSession(session); + this.setAppState({ sessionId: session.id }); + try { + await this.activateRuntime(); + await this.syncRuntimeState(session); + } catch (error) { + this.sessionEventHandler.startSubscription(); + const msg = formatErrorMessage(error); + this.showError(`Post-create setup failed: ${msg}`); + return undefined; + } + try { + await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); + } catch { + /* keep the new session usable even if dynamic skills fail */ + } + this.sessionEventHandler.startSubscription(); + void this.showSessionWarnings(session); + // The session-only thinking override was consumed by this session; the + // runtime status now owns the displayed effort. + if (this.state.appState.lazySessionThinking !== undefined) { + this.setAppState({ lazySessionThinking: undefined }); + } + return session; } async setSession(session: Session): Promise<void> { const previous = this.unloadCurrentSession('switching session'); await previous?.close(); + // A session switch abandons the previous session's in-flight staging + // leases and retires its history-owned cache copies. Do this at the + // boundary so retired paths cannot accumulate until process shutdown. + // Only when actually replacing a live session, though: on lazy first + // creation the outstanding lease belongs to the new session's first + // prompt, whose dispatch continues right after this — releasing it here + // would delete the staged media (e.g. a pasted image's daemon upload) + // before the engine's intake can read it. + if (previous !== undefined) this.staging.releaseAll(); this.session = session; this.harness.setTelemetryContext({ sessionId: session.id }); this.registerSessionHandlers(session); + this.syncAdditionalDirs(session); } async syncRuntimeState(session: Session = this.requireSession()): Promise<void> { @@ -1462,20 +2328,17 @@ export class PythinkerTUI { this.setAppState({ sessionId: session.id, model: status.model ?? '', - modelCostRates: status.modelCostRates, - totalCostUsd: status.usage?.totalCostUsd, - thinkingLevel: status.thinkingLevel, + thinkingEffort: status.thinkingEffort, permissionMode: status.permission, planMode: status.planMode, dynamicWorkflowMode: status.dynamicWorkflowMode ?? false, - fastMode: status.fastMode ?? false, - fastModeSupported: status.fastModeSupported ?? false, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, sessionTitle: session.summary?.title ?? null, goal: goalResult.goal, }); + this.syncAdditionalDirs(session); } // Apply --auto/--yolo/--plan startup flags to a resumed session. The resumed @@ -1522,6 +2385,7 @@ export class PythinkerTUI { async closeSession(reason: string): Promise<void> { const previous = this.unloadCurrentSession(reason); await previous?.close(); + this.staging.releaseAll(); } private unloadCurrentSession(reason: string): Session | undefined { @@ -1544,6 +2408,7 @@ export class PythinkerTUI { for (const dispose of this.reverseRpcDisposers) { dispose(); } + this.reverseRpcDisposers.length = 0; } private registerSessionHandlers(session: Session): void { @@ -1552,49 +2417,128 @@ export class PythinkerTUI { this.appendApprovalTranscriptEntry(request, response); }), ); - session.setQuestionHandler(createQuestionAskHandler(this.questionController, openUrl)); + session.setQuestionHandler(createQuestionAskHandler(this.questionController)); } async fetchSessions(scope: 'cwd' | 'all' = this.state.sessionsScope): Promise<void> { this.state.loadingSessions = true; this.state.sessionsScope = scope; + this.state.sessionsNextCursor = undefined; + this.state.sessionsLoadingMore = false; try { - const sessions = - scope === 'all' - ? await this.harness.listSessions({}) - : await this.harness.listSessions({ workDir: this.state.appState.workDir }); + const page = await this.harness.listSessionsPage({ + workDir: scope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + }); + this.state.sessionsNextCursor = page.nextCursor; this.state.sessions = sessionRowsForPicker( - sessions, + page.items, this.state.appState.sessionId, this.hasSessionContent(), ); - } catch { - /* silently ignore */ + } catch (error) { + // The picker must keep working (it renders the empty state), but a + // swallowed failure surfaces as a misleading "No sessions found." — + // keep a log trail so the real error stays discoverable. + log.warn('failed to fetch sessions for picker', { error: String(error) }); } finally { this.state.loadingSessions = false; } } + /** + * Pulls the next keyset page into the session picker (scroll-bottom paging). + * A scope switch or picker close bumps `sessionPickerScopeRequestToken`, + * which makes an in-flight append discard its result. Returns whether a page + * was appended — callers draining pages stop on the first `false`. + * Scroll triggers pass no argument and are dropped while a fetch is running; + * the search drain passes `waitForInFlight` to join the running fetch and + * continue with the next page, so a query typed mid-fetch still ends up + * covering every session. + */ + private async fetchMoreSessions(waitForInFlight = false): Promise<boolean> { + while (this.sessionsPageFetchInFlight !== undefined) { + if (!waitForInFlight) return false; + await this.sessionsPageFetchInFlight; + } + const cursor = this.state.sessionsNextCursor; + if (cursor === undefined) return false; + const requestToken = this.sessionPickerScopeRequestToken; + this.state.sessionsLoadingMore = true; + this.sessionPickerComponent?.setPaging(true, true); + this.state.ui.requestRender(); + const run = this.appendNextSessionPage(cursor, requestToken); + this.sessionsPageFetchInFlight = run; + try { + return await run; + } finally { + if (this.sessionsPageFetchInFlight === run) this.sessionsPageFetchInFlight = undefined; + } + } + + private async appendNextSessionPage(cursor: string, requestToken: number): Promise<boolean> { + try { + const page = await this.harness.listSessionsPage({ + workDir: this.state.sessionsScope === 'all' ? undefined : this.state.appState.workDir, + limit: SESSION_LIST_PAGE_SIZE, + before: cursor, + }); + if (requestToken !== this.sessionPickerScopeRequestToken) return false; + this.state.sessionsNextCursor = page.nextCursor; + const rows = sessionRowsForPicker( + page.items, + this.state.appState.sessionId, + this.hasSessionContent(), + ); + this.state.sessions = [...this.state.sessions, ...rows]; + this.sessionPickerComponent?.appendSessions(rows); + this.sessionPickerComponent?.setPaging(page.nextCursor !== undefined, false); + return true; + } catch (error) { + log.warn('failed to fetch more sessions for picker', { error: String(error) }); + return false; + } finally { + if (requestToken === this.sessionPickerScopeRequestToken) { + this.state.sessionsLoadingMore = false; + this.sessionPickerComponent?.setPaging(this.state.sessionsNextCursor !== undefined, false); + this.state.ui.requestRender(); + } + } + } + + /** + * Search covers every session: while a query is active the picker asks for + * all remaining pages, drained one at a time in the background. A failed or + * superseded fetch stops the drain (the next fresh query re-triggers it). + */ + private async drainSessionsForSearch(): Promise<void> { + const requestToken = this.sessionPickerScopeRequestToken; + while ( + this.state.sessionsNextCursor !== undefined && + requestToken === this.sessionPickerScopeRequestToken + ) { + if (!(await this.fetchMoreSessions(true))) return; + } + } + updateTerminalTitle(): void { const trimmed = this.state.appState.sessionTitle?.trim() ?? ''; const label = trimmed.length > 0 ? trimmed.slice(0, MAX_TERMINAL_TITLE_LENGTH) : PRODUCT_NAME; - this.presentation.setTerminalTitle(label); + this.state.terminal.setTitle(label); } resetSessionRuntime(): void { this.aborted = false; + this.cacheHint.resetRuntime(); this.streamingUI.discardPending(); - this.state.queuedMessages = []; + this.clearQueuedMessages(); this.state.dynamicWorkflowModeEntry = undefined; this.streamingUI.resetToolCallState(); this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); this.btwPanelController.clear(); - this.dispatchFooter({ - type: 'background-counts.updated', - counts: { bashTasks: 0, agentTasks: 0 }, - }); + this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); this.setAppState({ mcpServersSummary: null }); @@ -1616,6 +2560,10 @@ export class PythinkerTUI { } private async resumeSession(targetSessionId: string): Promise<boolean> { + // A first-use lazy creation may still be in flight: wait it out so the + // checks below see settled state — the pending prompt would otherwise + // replace the resumed session when creation completes. + await this.waitForLazyCreation(); if (targetSessionId === this.state.appState.sessionId) { this.showStatus('Already on this session.'); return true; @@ -1633,7 +2581,7 @@ export class PythinkerTUI { try { session = await this.harness.resumeSession({ id: targetSessionId, - replayTurnLimit: REPLAY_TURN_LIMIT, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); } catch (error) { const msg = formatErrorMessage(error); @@ -1652,6 +2600,7 @@ export class PythinkerTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the switched session usable even if dynamic skills fail */ } @@ -1664,7 +2613,13 @@ export class PythinkerTUI { } finally { this.sessionEventHandler.startSubscription(); } + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } this.showStatus(statusMessage); + void this.showSessionWarnings(session); + void this.cacheHint.maybeShowOnResume(); } async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> { @@ -1684,11 +2639,17 @@ export class PythinkerTUI { this.updateTerminalTitle(); try { await this.refreshSkillCommands(session); + await this.refreshPluginCommands(session); } catch { /* keep the reloaded session usable even if dynamic skills fail */ } this.sessionEventHandler.startSubscription(); + const resumeState = session.getResumeState(); + if (resumeState?.warning !== undefined) { + this.showStatus(`Warning: ${resumeState.warning}`, 'warning'); + } this.showStatus(statusMessage); + void this.showSessionWarnings(session); } async createNewSession(): Promise<void> { @@ -1720,12 +2681,14 @@ export class PythinkerTUI { } try { await this.refreshSkillCommands(this.session); + await this.refreshPluginCommands(this.session); } catch { /* keep the new session usable even if dynamic skills fail */ } this.sessionEventHandler.startSubscription(); this.clearTranscriptAndRedraw(); this.showStatus(`Started a new session (${session.id}).`); + void this.showSessionWarnings(session); void this.showConfigWarningsIfAny(); } @@ -1752,7 +2715,10 @@ export class PythinkerTUI { if (data.result === 'cancelled') { block.markCanceled(); } else { - block.markDone(data.tokensBefore, data.tokensAfter); + block.markDone(data.tokensBefore, data.tokensAfter, data.summary); + if (this.state.toolOutputExpanded) { + block.setExpanded(true); + } } return block; } @@ -1762,7 +2728,7 @@ export class PythinkerTUI { const images = entry.imageAttachmentIds ?.map((id) => this.imageStore.get(id)) .filter((a): a is ImageAttachment => a?.kind === 'image'); - return new UserMessageComponent(entry.content, images); + return new UserMessageComponent(entry.content, images, entry.bullet); } case 'skill_activation': return new SkillActivationComponent( @@ -1770,6 +2736,11 @@ export class PythinkerTUI { entry.skillArgs, entry.skillTrigger, ); + case 'plugin_command': { + const data = entry.pluginCommandData; + if (data === undefined) return null; + return new PluginCommandComponent(data.pluginId, data.commandName, data.args); + } case 'cron': return new CronMessageComponent(entry.content, entry.cronData ?? {}); case 'goal': @@ -1829,10 +2800,11 @@ export class PythinkerTUI { const component = this.createTranscriptComponent(entry); if (component) { markTranscriptComponent(component, entry); - this.state.transcriptContainer.addTranscriptChild(component, { - role: 'durable', - edgeBlankPolicy: 'trim-plain', - }); + this.state.transcriptContainer.addChild(component); + } + const trimmed = this.trimTranscriptWindow(); + const merged = this.mergeCurrentTurnSteps(); + if (component || trimmed || merged) { this.state.ui.requestRender(); } } @@ -1841,7 +2813,12 @@ export class PythinkerTUI { request: ApprovalRequest, response: ApprovalResponse, ): void { - if (request.toolName === 'ExitPlanMode' || request.display.kind === 'plan_review') return; + if ( + request.toolName === 'ExitPlanMode' || + request.display.kind === 'plan_review' || + request.display.kind === 'goal_start' + ) + return; const parts: string[] = []; switch (response.decision) { case 'approved': @@ -1873,18 +2850,23 @@ export class PythinkerTUI { ) { return; } - const welcome = new WelcomeComponent(this.state.appState, () => { - this.state.ui.requestRender(); - }); - this.state.transcriptContainer.addTranscriptChild(welcome, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + const welcome = new WelcomeComponent(this.state.appState); + this.state.transcriptContainer.addChild(welcome); } private clearTerminalInlineImages(): void { if (getCapabilities().images !== 'kitty') return; - this.presentation.writeTerminalControl(deleteAllKittyImages()); + this.state.terminal.write(deleteAllKittyImages()); + } + + private disposeTranscriptChildren(): void { + // Dispose disposable children (e.g. ShellRunComponent's 1s timer, + // ThinkingComponent's spinner) before dropping them, so a /clear, session + // switch, or shutdown can't leak intervals that keep firing requestRender + // on a removed component. + for (const child of this.state.transcriptContainer.children) { + if (hasDispose(child)) child.dispose(); + } } private clearTranscriptAndRedraw(): void { @@ -1893,30 +2875,304 @@ export class PythinkerTUI { this.streamingUI.disposeActiveCompactionBlock(); this.streamingUI.resetLiveText(); this.streamingUI.resetToolUi(); - this.sessionEventHandler.disposeMcpServerStatusRows(); + this.sessionEventHandler.stopAllMcpServerStatusSpinners(); + this.disposeTranscriptChildren(); this.state.transcriptContainer.clear(); - this.state.transcriptViewport.scrollToBottom(); - this.state.transcriptViewport.clearSelection(); this.btwPanelController.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); - this.imageStore.clear(); + const stagingFileIds = this.imageStore.clear(); + this.staging.deleteStaged(stagingFileIds); this.renderWelcome(); + // No forced full render on session reset: let the differential renderer + // converge on its own (a mass change above the viewport still makes the + // engine repaint everything, but nothing is forced destructively here). + this.state.ui.requestRender(); } - showStatus(message: string, color?: ColorToken): void { - this.state.transcriptContainer.addTranscriptChild( - new StatusMessageComponent(message, color), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, + + private isTurnBoundaryComponent(child: Component): boolean { + if ( + !(child instanceof UserMessageComponent) && + !(child instanceof SkillActivationComponent) && + !(child instanceof PluginCommandComponent) && + !(child instanceof ReplayTurnBoundaryComponent) + ) { + return false; + } + const entry = getTranscriptComponentEntry(child); + if (entry === undefined) return false; + // Live user messages / slash activations have an undefined turnId; replayed + // ones get a `replay:N` turnId. Both start a new turn. Steer messages carry + // a defined non-replay turnId and are not boundaries. + return entry.turnId === undefined || entry.turnId.startsWith('replay:'); + } + + private trimTranscriptWindow(): boolean { + if (!TRANSCRIPT_WINDOW_ENABLED || TRANSCRIPT_MAX_TURNS <= 0) return false; + // Session replay already caps history to its own turn limit; trimming during + // replay would shrink it further and fight that limit. + if (this.state.appState.isReplaying) return false; + + const children = this.state.transcriptContainer.children; + + // Trim whole turns by *position* in the child list rather than by entry + // lookup — otherwise only the (registered) user message would be removed and + // the rest of the turn would be left behind. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + + const turns = groupTurns(this.state.transcriptEntries); + + const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS); + if (toRemove.size === 0) return false; + + // Reclaim image bytes referenced by trimmed user messages. The transcript + // renders historical thumbnails via imageStore.get(id), so an attachment can + // only be dropped once its owning user message leaves the transcript. + for (const entry of toRemove) { + if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) { + const stagingFileIds = this.imageStore.removeMany(entry.imageAttachmentIds); + this.staging.deleteStaged(stagingFileIds); + } + } + + let boundariesToRemove = 0; + for (const entry of toRemove) { + if ( + (entry.kind === 'user' || + entry.kind === 'skill_activation' || + entry.kind === 'plugin_command') && + entry.turnId === undefined + ) { + boundariesToRemove++; + } + } + if (boundariesToRemove === 0) { + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + let boundariesSeen = 0; + let cutoff = 0; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) { + if (boundariesSeen === boundariesToRemove) { + cutoff = i; + break; + } + boundariesSeen++; + } + } + + const componentsToRemove: Component[] = []; + for (let i = 0; i < cutoff; i++) { + const child = children[i]!; + if (child instanceof WelcomeComponent) continue; + componentsToRemove.push(child); + } + for (const child of componentsToRemove) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.state.transcriptContainer.removeChild(child); + if (hasDispose(child)) child.dispose(); + } + + this.state.transcriptEntries = this.state.transcriptEntries.filter((e) => !toRemove.has(e)); + return true; + } + + mergeCurrentTurnSteps(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, ); + } + + /** + * Fold the just-finished turn's assistant messages down to the completed-turn + * cap: while a turn is live it may keep TRANSCRIPT_KEEP_RECENT_ASSISTANT + * messages mounted, but once it ends only the conclusion-bearing tail stays. + * Called when a turn finishes; the finished turn is still the current one at + * that point (no newer boundary exists yet). + */ + mergeCompletedTurnAssistants(): boolean { + return this.foldCurrentTurnContent( + TRANSCRIPT_KEEP_RECENT_STEPS, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + ); + } + + private foldCurrentTurnContent(keepSteps: number, keepAssistants: number): boolean { + if (keepSteps <= 0 && keepAssistants <= 0) return false; + const children = this.state.transcriptContainer.children; + + // Find the start of the current turn (last turn-starting user message). + let turnStart = -1; + for (let i = children.length - 1; i >= 0; i--) { + if (this.isTurnBoundaryComponent(children[i]!)) { + turnStart = i; + break; + } + } + if (turnStart < 0) return false; + + // Locate an existing summary, the assistant messages, and the mergeable steps. + let summaryIndex = -1; + const stepIndices: number[] = []; + const assistantIndices: number[] = []; + for (let i = turnStart + 1; i < children.length; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) { + summaryIndex = i; + continue; + } + if (child instanceof AssistantMessageComponent) { + assistantIndices.push(i); + continue; + } + stepIndices.push(i); + } + + // Fold the oldest steps / assistant messages beyond their respective caps; + // the most recent ones stay mounted. Children are chronological, so the + // oldest of each kind sit at the front of their index lists. + const stepMergeCount = keepSteps > 0 ? Math.max(0, stepIndices.length - keepSteps) : 0; + const assistantMergeCount = + keepAssistants > 0 ? Math.max(0, assistantIndices.length - keepAssistants) : 0; + if (stepMergeCount === 0 && assistantMergeCount === 0) return false; + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; + + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + if (thinkingCount === 0 && toolCount === 0 && assistantMergeCount === 0) return false; + + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } + + // Rebuild children: keep everything except the merged steps, with the summary + // sitting right after the user message. + const toMergeSet = new Set(toMergeIndices); + const newChildren: Component[] = []; + for (let i = 0; i <= turnStart; i++) newChildren.push(children[i]!); + newChildren.push(summary); + for (let i = turnStart + 1; i < children.length; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (hasDispose(child)) child.dispose(); + } + + children.splice(0, children.length, ...newChildren); + return true; + } + + mergeAllTurnSteps(): void { + if (TRANSCRIPT_KEEP_RECENT_STEPS <= 0 && TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED <= 0) + return; + const children = this.state.transcriptContainer.children; + + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + if (boundaries.length === 0) return; + + const newChildren: Component[] = []; + const toDispose: Component[] = []; + for (let i = 0; i < boundaries[0]!; i++) newChildren.push(children[i]!); + + for (let t = 0; t < boundaries.length; t++) { + const turnStart = boundaries[t]!; + const turnEnd = t + 1 < boundaries.length ? boundaries[t + 1]! : children.length; + newChildren.push(children[turnStart]!); + + let summaryIndex = -1; + const stepIndices: number[] = []; + const assistantIndices: number[] = []; + for (let i = turnStart + 1; i < turnEnd; i++) { + const child = children[i]!; + if (child instanceof StepSummaryComponent) summaryIndex = i; + else if (child instanceof AssistantMessageComponent) assistantIndices.push(i); + else stepIndices.push(i); + } + + const stepMergeCount = + TRANSCRIPT_KEEP_RECENT_STEPS > 0 + ? Math.max(0, stepIndices.length - TRANSCRIPT_KEEP_RECENT_STEPS) + : 0; + // Replayed turns are all completed turns, so the stricter completed-turn + // assistant cap applies (matching what live turns fold to on turn end). + const assistantMergeCount = + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED > 0 + ? Math.max(0, assistantIndices.length - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED) + : 0; + if (stepMergeCount > 0 || assistantMergeCount > 0) { + const toMergeIndices = [ + ...stepIndices.slice(0, stepMergeCount), + ...assistantIndices.slice(0, assistantMergeCount), + ]; + let thinkingCount = 0; + let toolCount = 0; + for (const idx of toMergeIndices) { + const child = children[idx]!; + if (child instanceof ThinkingComponent) thinkingCount++; + else if (child instanceof ToolCallComponent) toolCount++; + } + let summary: StepSummaryComponent; + if (summaryIndex >= 0) { + summary = children[summaryIndex] as StepSummaryComponent; + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } else { + summary = new StepSummaryComponent(); + summary.addCounts(thinkingCount, toolCount, assistantMergeCount); + } + newChildren.push(summary); + for (const idx of toMergeIndices) toDispose.push(children[idx]!); + const toMergeSet = new Set(toMergeIndices); + for (let i = turnStart + 1; i < turnEnd; i++) { + if (i === summaryIndex) continue; + if (toMergeSet.has(i)) continue; + newChildren.push(children[i]!); + } + } else { + for (let i = turnStart + 1; i < turnEnd; i++) newChildren.push(children[i]!); + } + } + + for (const child of toDispose) { + if (hasDispose(child)) child.dispose(); + } + children.splice(0, children.length, ...newChildren); + } + + showStatus(message: string, color?: ColorToken): void { + this.state.transcriptContainer.addChild(new StatusMessageComponent(message, color)); this.state.ui.requestRender(); } showNotice(title: string, detail?: string): void { - this.state.transcriptContainer.addTranscriptChild( - new NoticeMessageComponent(title, detail), - { role: 'ephemeral', edgeBlankPolicy: 'preserve' }, - ); + this.state.transcriptContainer.addChild(new NoticeMessageComponent(title, detail)); this.state.ui.requestRender(); } @@ -1930,15 +3186,9 @@ export class PythinkerTUI { showProgressSpinner(label: string): LoginProgressSpinnerHandle { const tint = (s: string): string => currentTheme.fg('primary', s); - const spinner = new ActivityLoader(this.state.ui, tint, label); - this.state.transcriptContainer.addTranscriptChild(new Spacer(1), { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); - this.state.transcriptContainer.addTranscriptChild(spinner, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + const spinner = new MoonLoader(this.state.ui, 'braille', tint, label); + this.state.transcriptContainer.addChild(new Spacer(1)); + this.state.transcriptContainer.addChild(spinner); this.state.ui.requestRender(); return { stop: ({ ok, label: finalLabel }) => { @@ -1948,9 +3198,25 @@ export class PythinkerTUI { spinner.setText(currentTheme.fg(tone, `${symbol} ${finalLabel}`)); this.state.ui.requestRender(); }, + setLabel: (nextLabel) => { + spinner.setLabel(nextLabel); + }, }; } + showLoginAuthorizationPrompt(auth: DeviceAuthorization): LoginProgressSpinnerHandle { + openUrl(auth.verificationUriComplete); + this.state.transcriptContainer.addChild( + new DeviceCodeBoxComponent({ + title: 'Sign in to Pythinker Code', + url: auth.verificationUriComplete, + code: auth.userCode, + hint: 'Press Ctrl-C to cancel', + }), + ); + this.state.ui.requestRender(); + return this.showLoginProgressSpinner('Waiting for authorization…'); + } // ========================================================================= // Panes / Presentation State @@ -1958,42 +3224,39 @@ export class PythinkerTUI { updateActivityPane(): void { const effectiveMode = this.resolveActivityPaneMode(); - this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); - const placeSpinnerInDynamicWorkflow = this.shouldPlaceActivitySpinnerInDynamicWorkflow(effectiveMode); - let footerActivityPhase: FooterActivity['phase'] = 'hidden'; - let footerSpinnerActive = false; - if (!placeSpinnerInDynamicWorkflow) { - switch (effectiveMode) { - case 'waiting': - case 'thinking': - case 'composing': - case 'tool': - footerActivityPhase = effectiveMode; - footerSpinnerActive = effectiveMode !== 'thinking'; - break; - case 'hidden': - case 'idle': - case 'session': - break; - } + const tipKind = loadingTipKind(effectiveMode); + // Pick a fresh loading tip when the loading kind changes. The same kind + // covers waiting/tool (both moon spinners) and any intermediate thinking + // phase, so a continuous burst of tool calls does not flip tips. Clear the + // cache only when there is no loading UI at all. + if (effectiveMode === 'idle' || effectiveMode === 'session' || effectiveMode === 'hidden') { + this.currentLoadingTip = undefined; + } else if ( + tipKind !== undefined && + (this.currentLoadingTip === undefined || this.currentLoadingTip.kind !== tipKind) + ) { + const previousTip = this.currentLoadingTip?.tip; + this.currentLoadingTip = { + kind: tipKind, + tip: pickRandomWorkingTip(previousTip)?.text, + }; } - this.dispatchFooter({ - type: 'activity.updated', - activity: { - phase: footerActivityPhase, - label: null, - spinnerActive: footerSpinnerActive, - spinnerFrame: '⠋', - }, - }); - const activityModeKey = `${effectiveMode}:${placeSpinnerInDynamicWorkflow ? 'dynamic-workflow' : 'pane'}`; + this.syncTerminalProgress(this.shouldShowTerminalProgress(effectiveMode)); + const placeSpinnerInAgentDynamicWorkflow = this.shouldPlaceActivitySpinnerInAgentDynamicWorkflow(effectiveMode); + // Carry the retry state in the mode key so an incoming/cleared + // `turn.step.retrying` rebuilds the waiting pane with fresh label and + // detail instead of hitting the cached-pane early return below. + const retry = effectiveMode === 'waiting' ? this.state.appState.stepRetry : null; + const retryKey = + retry === null ? '' : `${formatStepRetryLabel(retry)}|${formatStepRetryDetail(retry)}`; + const activityModeKey = `${effectiveMode}:${placeSpinnerInAgentDynamicWorkflow ? 'dynamic_workflow' : 'pane'}:${retryKey}`; if ( activityModeKey === this.lastActivityMode && (effectiveMode === 'waiting' || effectiveMode === 'thinking' || effectiveMode === 'tool') ) { - if (placeSpinnerInDynamicWorkflow) { - this.syncDynamicWorkflowActivitySpinner(this.state.activitySpinner?.instance); + if (placeSpinnerInAgentDynamicWorkflow) { + this.syncAgentDynamicWorkflowActivitySpinner(this.state.activitySpinner?.instance); } return; } @@ -2004,56 +3267,52 @@ export class PythinkerTUI { switch (effectiveMode) { case 'hidden': this.stopActivitySpinner(); - this.syncDynamicWorkflowActivitySpinner(undefined); + this.syncAgentDynamicWorkflowActivitySpinner(undefined); this.state.ui.requestRender(); return; case 'waiting': { - const spinner = this.ensureActivitySpinner( - undefined, - (s) => currentTheme.fg('primary', s), - !placeSpinnerInDynamicWorkflow, - ); - this.syncDynamicWorkflowActivitySpinner(placeSpinnerInDynamicWorkflow ? spinner : undefined); - if (placeSpinnerInDynamicWorkflow) break; + const stepRetry = this.state.appState.stepRetry; + const spinner = this.ensureActivitySpinner('moon', waitingSpinnerLabel(stepRetry)); + this.syncAgentDynamicWorkflowActivitySpinner(placeSpinnerInAgentDynamicWorkflow ? spinner : undefined); + if (placeSpinnerInAgentDynamicWorkflow) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'waiting', spinner, + tip: stepRetry === null ? this.currentLoadingTip?.tip : undefined, + detail: stepRetry === null ? undefined : formatStepRetryDetail(stepRetry), }), ); break; } case 'thinking': { this.stopActivitySpinner(); - this.syncDynamicWorkflowActivitySpinner(undefined); + this.syncAgentDynamicWorkflowActivitySpinner(undefined); break; } case 'composing': { - const spinner = this.ensureActivitySpinner(undefined, (s) => + const spinner = this.ensureActivitySpinner('braille', 'working...', (s) => currentTheme.fg('primary', s), - true, ); - this.syncDynamicWorkflowActivitySpinner(undefined); + this.syncAgentDynamicWorkflowActivitySpinner(undefined); this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'composing', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; } case 'tool': { - const spinner = this.ensureActivitySpinner( - undefined, - (s) => currentTheme.fg('primary', s), - !placeSpinnerInDynamicWorkflow, - ); - this.syncDynamicWorkflowActivitySpinner(placeSpinnerInDynamicWorkflow ? spinner : undefined); - if (placeSpinnerInDynamicWorkflow) break; + const spinner = this.ensureActivitySpinner('moon'); + this.syncAgentDynamicWorkflowActivitySpinner(placeSpinnerInAgentDynamicWorkflow ? spinner : undefined); + if (placeSpinnerInAgentDynamicWorkflow) break; this.state.activityContainer.addChild( new ActivityPaneComponent({ mode: 'tool', spinner, + tip: this.currentLoadingTip?.tip, }), ); break; @@ -2061,7 +3320,11 @@ export class PythinkerTUI { case 'idle': case 'session': { this.stopActivitySpinner(); - this.syncDynamicWorkflowActivitySpinner(undefined); + this.syncAgentDynamicWorkflowActivitySpinner(undefined); + // Keep a placeholder row so the activity area does not fully shrink + // when the spinner is removed at the end of streaming; combined with + // pi-tui's clamp, this avoids a destructive full redraw (viewport jump). + this.state.activityContainer.addChild(new Spacer(1)); break; } } @@ -2075,6 +3338,11 @@ export class PythinkerTUI { if (this.state.livePane.pendingQuestion !== null) return 'hidden'; const streamingPhase = this.state.appState.streamingPhase; + + // A running `!` shell command shows the moon spinner (same as `waiting`) + // until it finishes, signalling that input is busy / queued. + if (streamingPhase === 'shell') return 'waiting'; + if (this.state.livePane.mode === 'idle') { if (streamingPhase === 'thinking' || streamingPhase === 'composing') { return streamingPhase; @@ -2101,32 +3369,170 @@ export class PythinkerTUI { toggleToolOutputExpansion(): void { this.state.toolOutputExpanded = !this.state.toolOutputExpanded; - for (const container of [ - this.state.transcriptContainer, - this.state.activityContainer, - ]) { - for (const child of container.children) { - if (isExpandable(child)) { - child.setExpanded(this.state.toolOutputExpanded); - } + const children = this.state.transcriptContainer.children; + + // A component is expandable only if it sits at or after the start of the + // (totalTurns - expandTurns)-th turn — i.e. it belongs to one of the most + // recent `expandTurns` turns. Position-based so it also covers streaming + // components that have no entry in the metadata map. + const boundaries: number[] = []; + for (let i = 0; i < children.length; i++) { + if (this.isTurnBoundaryComponent(children[i]!)) boundaries.push(i); + } + const expandCutoff = + TRANSCRIPT_EXPAND_TURNS <= 0 + ? children.length + : boundaries.length > TRANSCRIPT_EXPAND_TURNS + ? boundaries[boundaries.length - TRANSCRIPT_EXPAND_TURNS]! + : 0; + + for (let i = 0; i < children.length; i++) { + const child = children[i]!; + if (!isExpandable(child)) continue; + child.setExpanded(this.state.toolOutputExpanded && i >= expandCutoff); + } + // Differential render only — no destructive full redraw on expand/collapse. + // (When the expanded region reaches above the viewport, the engine's own + // fallback may still do a full render; that path is not forced from here.) + this.state.ui.requestRender(); + } + + toggleTodoPanelExpansion(): void { + this.state.todoPanel.toggleExpanded(); + this.state.ui.requestRender(); + } + + private async detachRunningShellCommand(): Promise<void> { + // Only one `!` command runs at a time (input is queued while busy). + const next = this.shellOutputStreams.entries().next(); + if (next.done) { + this.showDetachHint('No shell command running.'); + return; + } + const [commandId, stream] = next.value; + if (stream.taskId === undefined) { + this.showDetachHint('Command is still starting — try again.'); + return; + } + const session = this.session; + if (session === undefined) return; + try { + const info = await session.detachBackgroundTask(stream.taskId); + if (info === undefined) { + this.showDetachHint('Command already finished.'); + return; + } + } catch (error) { + this.showError(`Failed to move to background: ${formatErrorMessage(error)}`); + return; + } + // Finalize the card as backgrounded and drop the stream so the eventual + // runShellCommand resolution (which carries background metadata) is a no-op + // instead of overwriting this view. + stream.component.finishBackgrounded(); + stream.entry.content = 'Moved to background.'; + this.shellOutputStreams.delete(commandId); + // The backgrounded command's notification turn (started by agent-core via + // appendSystemReminderAndNotify) owns the streaming phase and drains the + // queue when it completes, so we intentionally leave both untouched here. + this.showDetachHint('Moved to background. /tasks to view.'); + } + + async detachCurrentForegroundTask(): Promise<void> { + // A running `!` shell command takes priority over agent foreground tasks. + if (this.shellOutputStreams.size > 0) { + await this.detachRunningShellCommand(); + return; + } + + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); } } + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = + detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); this.state.ui.requestRender(); } updateEditorBorderHighlight(text?: string): void { - const { line, col } = this.state.editor.getCursor(); - const currentLine = - this.state.editor.getLines()[line] ?? - (text ?? this.presentation.getComposerText()).split('\n')[line] ?? - ''; - const highlighted = - this.state.appState.planMode || findSlashAutocompleteContext(currentLine, col) !== null; + const trimmed = (text ?? this.state.editor.getText()).trimStart(); + const isBash = this.state.appState.inputMode === 'bash'; + const highlighted = this.state.appState.planMode || isBash || trimmed.startsWith('/'); this.state.editor.borderHighlighted = highlighted; - this.state.editor.borderColor = (s: string) => { - if (highlighted) return currentTheme.fg('primary', s); - return currentTheme.fg('border', s); - }; + // Shell mode gets its own hue; plan-mode and slash context stay primary. + const borderToken = isBash ? 'shellMode' : highlighted ? 'primary' : 'border'; + this.state.editor.borderColor = (s: string) => currentTheme.fg(borderToken, s); + this.state.ui.requestRender(); + } + + /** + * Live pre-send warning in the footer while the typed `/goal` objective + * exceeds the length limit, so the user can trim it (or move it into a + * file) before submitting instead of losing the input to a rejection. + * `undefined` input means the text cannot be a `/goal` command and is not + * measured at all. The footer keeps this warning in its own slot, so + * transient hints (exit confirm, detach, image paste) only displace it + * temporarily. + */ + updateGoalLengthWarning(text: string | undefined): void { + const warning = text === undefined ? undefined : goalObjectiveLengthWarning(text); + this.state.footer.setWarningHint(warning ?? null); this.state.ui.requestRender(); } @@ -2177,47 +3583,46 @@ export class PythinkerTUI { ); } - private shouldPlaceActivitySpinnerInDynamicWorkflow( + private shouldPlaceActivitySpinnerInAgentDynamicWorkflow( effectiveMode: EffectiveActivityPaneMode, ): boolean { return ( - this.sessionEventHandler.hasActiveDynamicWorkflowToolCall() && + this.sessionEventHandler.hasActiveAgentDynamicWorkflowToolCall() && (effectiveMode === 'waiting' || effectiveMode === 'tool') ); } - private syncDynamicWorkflowActivitySpinner(spinner: ActivityLoader | undefined): void { - this.sessionEventHandler.syncDynamicWorkflowActivitySpinner(spinner); + private syncAgentDynamicWorkflowActivitySpinner(spinner: MoonLoader | undefined): void { + this.sessionEventHandler.syncAgentDynamicWorkflowActivitySpinner(spinner); } private syncTerminalProgress(active: boolean): void { if (!this.state.terminalState.supportsProgress) return; if (this.state.terminalState.progressActive === active) return; - this.presentation.setTerminalProgress(active); + this.state.terminal.setProgress(active); this.state.terminalState.progressActive = active; } private ensureActivitySpinner( - label?: string, + style: SpinnerStyle, + label = '', colorFn?: (s: string) => string, - verbLabels = false, - ): ActivityLoader { + ): MoonLoader { + if (this.state.activitySpinner?.style !== style) { + this.stopActivitySpinner(); + } + if (this.state.activitySpinner === null) { - const instance = new ActivityLoader(this.state.ui, colorFn, label ?? '', { verbLabels }); - this.state.activitySpinner = { instance }; + const instance = new MoonLoader(this.state.ui, style, colorFn, label); + this.state.activitySpinner = { instance, style }; return instance; } - const spinner = this.state.activitySpinner.instance; - if (verbLabels) { - spinner.setVerbLabels(true); - } else { - spinner.setLabel(label ?? ''); - } + this.state.activitySpinner.instance.setLabel(label); if (colorFn !== undefined) { - spinner.setColorFn(colorFn); + this.state.activitySpinner.instance.setColorFn(colorFn); } - return spinner; + return this.state.activitySpinner.instance; } private stopActivitySpinner(): void { @@ -2232,68 +3637,129 @@ export class PythinkerTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.state.editorReplacementMounted = true; this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); - this.mountedEditorReplacement = panel; - if (isKeybindingAware(panel)) panel.setKeybindings(this.keybindings); this.state.ui.setFocus(panel); this.state.ui.requestRender(); } restoreEditor(): void { + this.state.editorReplacementMounted = false; this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); - this.mountedEditorReplacement = undefined; - this.presentation.focusComposer(); + this.state.ui.setFocus(this.state.editor); + // Differential render only: closing a tall panel leaves the editor a few + // rows above the bottom (blank tail) until the next append, but avoids a + // destructive full redraw on every dialog close. this.state.ui.requestRender(); } restoreInputText(text: string): void { this.restoreEditor(); - this.presentation.setComposerText(text); + this.state.editor.setText(text); this.updateEditorBorderHighlight(text); this.state.ui.requestRender(); } - showMessageActions(): void { - slashCommands.showMessageActions(this); + /** Latest in-process LLM round-trip; feeds the idle cache-hint scenario. */ + recordSessionActivity(): void { + this.cacheHint.recordActivity(); + } + + /** Per-step usage for the client-side cache-break detector. */ + noteStepUsage(usage: TokenUsage | undefined): void { + this.cacheHint.noteStepUsage(usage); + } + + /** Compaction shrinks the cached prefix — reset the cache-break baseline. */ + noteCompactionFinished(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + + /** /undo cut the context — the next step's cache drop is expected. */ + noteContextCut(): void { + this.cacheHint.resetCacheBreakBaseline(); + } + + private async runMigrationScreen(plan: MigrationPlan): Promise<MigrationScreenResult> { + const result = await new Promise<MigrationScreenResult>((resolve) => { + const screen = new MigrationScreenComponent({ + plan, + sourceHome: plan.sourceHome, + targetHome: this.harness.homeDir, + skipDecisionStep: this.migrateOnly, + requestRender: () => { + this.state.ui.requestRender(); + }, + onComplete: (r) => { + resolve(r); + }, + }); + this.mountEditorReplacement(screen); + }); + this.restoreEditor(); + if (result.decision === 'never') { + // Persist the skip marker `detectPendingMigration` checks, so "Never ask + // again" actually stops the prompt from reappearing every launch. + try { + writeFileSync(join(this.harness.homeDir, '.skip-migration-from-pythinker-cli'), '', 'utf-8'); + } catch { + // Non-blocking: a failed marker write must never crash startup. + } + } + return result; } - async showInputHistoryPicker(): Promise<void> { - let entries; + /** + * agent-core-v2 startup gate: before any session is created, ask whether to + * trust this folder when the workspace is not trusted yet (project-level MCP + * servers stay disabled while untrusted). Best-effort throughout — a failed + * check or trust write never blocks startup. Choosing "don't trust" (or Esc) + * exits the program before any session is created; the prompt reappears on + * the next launch: the engine's untrusted state is indistinguishable from + * never-trusted. Returns true when the prompt started the event loop (the + * caller must not start it again). + */ + private async maybeRunWorkspaceTrustPrompt(): Promise<boolean> { + if (!this.engineV2) return false; + const workDir = this.state.appState.workDir; + let info: WorkspaceTrustInfo; try { - entries = await loadInputHistory(getInputHistoryFile(this.state.appState.workDir)); - } catch (error) { - this.showError(`Failed to load input history: ${formatErrorMessage(error)}`); - return; + info = await this.harness.getWorkspaceTrustInfo(workDir); + } catch { + return false; } - const history = selectRecentInputHistory(entries); - if (history.length === 0) { - this.showNotice('No input history'); - return; + if (info.trusted) return false; + this.startEventLoop(); + const choice = await new Promise<TrustPromptChoice>((resolve) => { + this.state.activeDialog = 'trust-prompt'; + this.mountEditorReplacement( + new TrustPromptComponent({ + workDir, + gatedMcpServers: info.gatedMcpServers, + onSelect: (c) => { + resolve(c); + }, + }), + ); + }); + this.state.activeDialog = null; + if (choice !== 'trust') { + // Declining trust exits the program (Claude Code's "No, exit" semantics): + // stop() runs the standard shutdown path and ends in process.exit. The + // editor is NOT restored first — its frame would linger as an orphaned + // input box above the exit message; the prompt stays as the last frame. + await this.stop(); + return true; } - this.mountEditorReplacement( - new ChoicePickerComponent({ - title: 'Prompt history', - options: history.map((content) => ({ - value: content, - label: content.replaceAll(/\s+/gu, ' '), - })), - searchable: true, - pageSize: 8, - keybindingContext: 'HistorySearch', - onSelect: (content) => { - this.restoreInputText(content); - }, - onExecute: (content) => { - this.restoreEditor(); - this.handleUserInput(content); - }, - onCancel: () => { - this.restoreEditor(); - }, - }), - ); + this.restoreEditor(); + try { + await this.harness.trustWorkspace(workDir); + } catch { + // A failed write leaves the workspace untrusted (re-asked next launch). + } + return true; } showHelpPanel(): void { @@ -2301,7 +3767,6 @@ export class PythinkerTUI { this.mountEditorReplacement( new HelpPanelComponent({ commands: this.getSlashCommands(), - shortcuts: this.keyboardShortcuts, onClose: () => { this.hideHelpPanel(); }, @@ -2324,6 +3789,8 @@ export class PythinkerTUI { forwardEditorExit: false, }; private sessionPickerScopeRequestToken = 0; + private sessionPickerComponent: SessionPickerComponent | undefined; + private sessionsPageFetchInFlight: Promise<boolean> | undefined; async showSessionPicker(): Promise<void> { await this.openSessionPicker({ @@ -2395,11 +3862,16 @@ export class PythinkerTUI { hideSessionPicker(): void { this.sessionPickerScopeRequestToken += 1; + this.sessionPickerComponent = undefined; this.editorKeyboard.clearPendingExit(); this.state.activeDialog = null; this.restoreEditor(); } + openUndoSelector(): void { + void slashCommands.handleUndoCommand(this, ''); + } + private mountSessionPicker(options: { readonly onCancel: () => void; readonly onCtrlC?: () => void; @@ -2411,29 +3883,37 @@ export class PythinkerTUI { readonly applyStartupModes?: boolean; }): void { this.state.activeDialog = 'session-picker'; - this.mountEditorReplacement( - new SessionPickerComponent({ - sessions: this.state.sessions, - loading: this.state.loadingSessions, - currentSessionId: this.state.appState.sessionId, - scope: this.state.sessionsScope, - initialSelectedSessionId: options.initialSelectedSessionId, - pageSize: 50, - onSelect: (session: SessionRow) => { - void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( - (error) => { - this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); - }, - ); - }, - onCancel: options.onCancel, - onCtrlC: options.onCtrlC, - onCtrlD: options.onCtrlD, - onToggleScope: (selectedSessionId: string) => { - void this.toggleSessionPickerScope(selectedSessionId); - }, - }), - ); + const picker = new SessionPickerComponent({ + sessions: this.state.sessions, + loading: this.state.loadingSessions, + currentSessionId: this.state.appState.sessionId, + scope: this.state.sessionsScope, + initialSelectedSessionId: options.initialSelectedSessionId, + pageSize: SESSION_LIST_PAGE_SIZE, + hasMore: this.state.sessionsNextCursor !== undefined, + loadingMore: this.state.sessionsLoadingMore, + onLoadMore: () => { + void this.fetchMoreSessions(); + }, + onSearchDrain: () => { + void this.drainSessionsForSearch(); + }, + onSelect: (session: SessionRow) => { + void this.handleSessionPickerSelect(session, options.applyStartupModes === true).catch( + (error) => { + this.showError(`Failed to apply startup flags: ${formatErrorMessage(error)}`); + }, + ); + }, + onCancel: options.onCancel, + onCtrlC: options.onCtrlC, + onCtrlD: options.onCtrlD, + onToggleScope: (selectedSessionId: string) => { + void this.toggleSessionPickerScope(selectedSessionId); + }, + }); + this.sessionPickerComponent = picker; + this.mountEditorReplacement(picker); } private async handleSessionPickerSelect( @@ -2488,12 +3968,12 @@ export class PythinkerTUI { // Mounts the full-screen approval preview viewer on top of the current // approval panel. Uses the same nested-takeover pattern as - // openTaskOutputViewer: we snapshot the root container's children, swap - // in the viewer, and restore on close. The approval panel instance is + // openTaskOutputViewer: beginScreenTakeover swaps the viewer in (root + // children in regular mode, layout root in fullscreen) and closing restores + // it. The approval panel instance is // kept around in `activeApprovalPanel` so its selection state survives. private openApprovalPreview(panel: ApprovalPanelComponent, block: ApprovalPreviewBlock): void { if (this.approvalPreview !== undefined) return; - const savedChildren = [...this.state.ui.children]; const viewer = new ApprovalPreviewViewer( { block, @@ -2503,21 +3983,17 @@ export class PythinkerTUI { }, this.state.terminal, ); - this.state.ui.clear(); - this.state.ui.addChild(viewer); + const takeover = beginScreenTakeover(this.state.ui, viewer); this.state.ui.setFocus(viewer); this.state.ui.requestRender(true); - this.approvalPreview = { component: viewer, savedChildren, panel }; + this.approvalPreview = { component: viewer, takeover, panel }; } private closeApprovalPreview(): void { const preview = this.approvalPreview; if (preview === undefined) return; this.approvalPreview = undefined; - this.state.ui.clear(); - for (const child of preview.savedChildren) { - this.state.ui.addChild(child); - } + endScreenTakeover(this.state.ui, preview.takeover); this.state.ui.setFocus(preview.panel); this.state.ui.requestRender(true); } diff --git a/apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts index 3db2cf70..c142901d 100644 --- a/apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/pythinker-code/src/tui/reverse-rpc/approval/adapter.ts @@ -1,6 +1,7 @@ import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@pymodel/pythinker-code-sdk'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; +import { goalStartOptions } from '#/tui/components/dialogs/goal-start-permission-prompt'; import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ @@ -176,6 +177,8 @@ function describeApproval(display: ToolInputDisplay, action: string): string { switch (display.kind) { case 'plan_review': return ''; + case 'goal_start': + return 'Start a goal?'; case 'generic': if (typeof display.detail === 'string' && display.detail.length > 0) { return display.detail; @@ -199,7 +202,7 @@ function describeApproval(display: ToolInputDisplay, action: string): string { return `search: ${display.query ?? ''}`.trim(); case 'todo_list': return `update todo list (${String(display.items?.length ?? 0)} items)`; - case 'background_task': + case 'task': return `${display.status ?? 'background'} task ${display.task_id ?? ''}: ${ display.description ?? '' }`.trim(); @@ -210,13 +213,6 @@ function describeApproval(display: ToolInputDisplay, action: string): string { const DANGER_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ { pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, label: 'recursive delete' }, - { pattern: /\bgit\s+reset\s+--hard\b/i, label: 'discard uncommitted changes' }, - { pattern: /\bgit\s+push\b[^;&|\n]*(--force|--force-with-lease|-f)\b/i, label: 'overwrite remote history' }, - { pattern: /\bgit\s+clean\b[^;&|\n]*-[a-zA-Z]*f/i, label: 'delete untracked files' }, - { pattern: /\bgit\s+stash\s+(drop|clear)\b/i, label: 'delete stashed changes' }, - { pattern: /\bterraform\s+destroy\b/i, label: 'destroy infrastructure' }, - { pattern: /\bkubectl\s+delete\b/i, label: 'delete Kubernetes resources' }, - { pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i, label: 'drop database objects' }, { pattern: /\bsudo\b/i, label: 'sudo' }, { pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, label: 'pipe to shell' }, { pattern: /\bdd\b[^|]*\bof=/i, label: 'dd write' }, @@ -300,8 +296,8 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { scope: display.scope, }, ]; - case 'agent_call': { - const blocks: DisplayBlock[] = [ + case 'agent_call': + return [ { type: 'invocation', kind: 'agent', @@ -309,18 +305,6 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { description: display.prompt, }, ]; - if (display.workflow !== undefined) { - blocks.push({ - type: 'workflow_plan', - agent_count: display.workflow.agent_count, - items: [...display.workflow.items], - prompt_tokens: display.workflow.prompt_tokens, - prompt_template: display.workflow.prompt_template, - model: display.workflow.model, - }); - } - return blocks; - } case 'skill_call': return [ { @@ -339,11 +323,18 @@ function adaptDisplay(display: ToolInputDisplay): DisplayBlock[] { ]; case 'plan_review': return []; + case 'goal_start': { + const lines = [`Start goal: ${display.objective}`]; + if (typeof display.completionCriterion === 'string' && display.completionCriterion.length > 0) { + lines.push(`Done when: ${display.completionCriterion}`); + } + return [{ type: 'brief', text: lines.join('\n') }]; + } case 'generic': return []; case 'todo_list': return []; - case 'background_task': + case 'task': return []; default: return []; @@ -354,10 +345,36 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane if (toolName === 'ExitPlanMode' || display.kind === 'plan_review') { return adaptPlanReviewChoices(display); } + if (display.kind === 'goal_start') { + return adaptGoalStartChoices(display); + } return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); } +function adaptGoalStartChoices( + display: Extract<ToolInputDisplay, { kind: 'goal_start' }>, +): ApprovalPanelChoice[] { + // Reuse the exact options the /goal start menu shows. Each mode option starts + // the goal under that permission mode (the policy reads selected_label); "Do + // not start" declines so no goal is created. + return goalStartOptions(display.mode).map((option) => + option.value === 'cancel' + ? { + label: option.label, + response: 'cancelled', + selected_label: 'cancel', + description: option.description, + } + : { + label: option.label, + response: 'approved', + selected_label: option.value, + description: option.description, + }, + ); +} + function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { const optionChoices = display.kind === 'plan_review' && display.options !== undefined && display.options.length >= 2 diff --git a/apps/pythinker-code/src/tui/reverse-rpc/question/handler.ts b/apps/pythinker-code/src/tui/reverse-rpc/question/handler.ts index e56bdb2b..fa8eb8dc 100644 --- a/apps/pythinker-code/src/tui/reverse-rpc/question/handler.ts +++ b/apps/pythinker-code/src/tui/reverse-rpc/question/handler.ts @@ -7,20 +7,10 @@ import type { import type { QuestionController } from './controller'; -export function createQuestionAskHandler( - controller: QuestionController, - openUrl?: (url: string) => void, -): QuestionHandler { +export function createQuestionAskHandler(controller: QuestionController): QuestionHandler { return async (event): Promise<QuestionResult> => { try { const answers = await controller.show(adaptQuestionRequest(event)); - for (let index = 0; index < event.questions.length; index++) { - const selected = answers.answers[index]; - const option = event.questions[index]?.options.find( - (candidate) => candidate.label === selected, - ); - if (option?.url !== undefined) openUrl?.(option.url); - } return adaptQuestionAnswers(event, answers); } catch { return null; @@ -40,14 +30,11 @@ export function adaptQuestionRequest(event: QuestionRequest): QuestionPanelData header: question.header, body: question.body, multi_select: question.multiSelect ?? false, - allow_other: question.allowOther, other_label: question.otherLabel, other_description: question.otherDescription, options: question.options.map((option) => ({ label: option.label, description: option.description, - preview: option.preview, - url: option.url, })), })), }; @@ -65,10 +52,6 @@ export function adaptQuestionAnswers( result[question.question] = answer; } return Object.keys(result).length > 0 - ? { - answers: result, - method: response.method, - annotations: response.annotations, - } + ? { answers: result, method: response.method } : null; } diff --git a/apps/pythinker-code/src/tui/reverse-rpc/types.ts b/apps/pythinker-code/src/tui/reverse-rpc/types.ts index 02ef57f9..8548aca7 100644 --- a/apps/pythinker-code/src/tui/reverse-rpc/types.ts +++ b/apps/pythinker-code/src/tui/reverse-rpc/types.ts @@ -68,19 +68,6 @@ export interface InvocationDisplayBlock { description?: string | undefined; } -/** - * The fan-out a Dynamic Workflow is about to launch. Shown at approval time so - * the decision is made against the actual task list rather than a count. - */ -export interface WorkflowPlanDisplayBlock { - type: 'workflow_plan'; - agent_count: number; - items: string[]; - prompt_tokens: number; - prompt_template?: string; - model?: string; -} - export interface TodoDisplayItem { title: string; status: 'pending' | 'in_progress' | 'done'; @@ -108,7 +95,6 @@ export type DisplayBlock = | UrlFetchDisplayBlock | SearchDisplayBlock | InvocationDisplayBlock - | WorkflowPlanDisplayBlock | TodoDisplayBlock | BackgroundTaskDisplayBlock; @@ -117,6 +103,9 @@ export interface ApprovalPanelChoice { response: 'approved' | 'approved_for_session' | 'rejected' | 'cancelled'; selected_label?: string | undefined; requires_feedback?: boolean | undefined; + // Optional helper text shown dim beneath the label. Omitted/empty renders + // exactly as a plain label-only choice. + description?: string | undefined; } // ── Approval / Question view payloads ──────────────────────────────── @@ -136,15 +125,9 @@ export interface QuestionPanelItem { header?: string; body?: string; multi_select: boolean; - allow_other?: boolean; other_label?: string; other_description?: string; - options: Array<{ - label: string; - description?: string; - preview?: string; - url?: string; - }>; + options: Array<{ label: string; description?: string }>; } export interface QuestionPanelData { @@ -158,7 +141,6 @@ export type QuestionSubmissionMethod = QuestionAnswerMethod; export interface QuestionPanelResponse { readonly answers: string[]; readonly method?: QuestionSubmissionMethod | undefined; - readonly annotations?: Record<string, { readonly preview?: string; readonly notes?: string }>; } // ── Pending state wrappers ─────────────────────────────────────────── diff --git a/apps/pythinker-code/src/tui/runtime/REACTIVITY.md b/apps/pythinker-code/src/tui/runtime/REACTIVITY.md deleted file mode 100644 index 1ce617a0..00000000 --- a/apps/pythinker-code/src/tui/runtime/REACTIVITY.md +++ /dev/null @@ -1,38 +0,0 @@ -# OpenTUI Solid reactivity - -`@opentui/solid` imports `solid-js/dist/solid.js`. Every bare `solid-js` -import in this app must resolve to that same file so signals, effects, owners, -and contexts share one reactive graph. - -`scripts/solid-runtime.mjs` is the single source of truth: - -```js -export const solidRuntimeAlias = { - find: /^solid-js$/u, - replacement: solidRuntimePath, -}; -``` - -Keep this exact alias applied in all four execution paths: - -- Vitest: `vitest.config.ts` -- development Vite runtime: `scripts/dev-vite-runtime.mjs` -- distributable tsdown build: `tsdown.config.ts` -- native tsdown build: `tsdown.native.config.ts` - -The development Vite runtime must also include both `@opentui/solid` and -`solid-js` in `ssr.noExternal`. Otherwise Vite rewrites the app's bare -`solid-js` import through the alias while Node resolves the dependency's -externalized `solid-js/dist/solid.js` import outside Vite's module graph, -splitting the reactive runtime. - -The match must stay exact. Subpaths such as `solid-js/store`, `solid-js/web`, -and `solid-js/jsx-runtime` must retain their normal resolution. - -Do not replace the alias with default condition resolution. Vitest selects -`dist/dev.js`, while Node and SSR select `dist/server.js`; both split the -runtime from OpenTUI's client `dist/solid.js` and silently stop reactive -updates. - -`test/tui/runtime/opentui-reactivity.test.tsx` guards function identity and -signal-driven terminal updates using ordinary imports from `solid-js`. diff --git a/apps/pythinker-code/src/tui/runtime/contracts.ts b/apps/pythinker-code/src/tui/runtime/contracts.ts deleted file mode 100644 index 72e8e9e7..00000000 --- a/apps/pythinker-code/src/tui/runtime/contracts.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FooterViewModel } from './footer/footer-model'; - -export interface TuiPresentation { - start(onResize: () => void): void; - stop(): void; - drainInput(): Promise<void>; - setTerminalTitle(title: string): void; - setTerminalProgress(active: boolean): void; - writeTerminalControl(sequence: string): void; - getComposerText(): string; - setComposerText(text: string): void; - focusComposer(): void; - addComposerHistory(text: string): void; - updateFooter(viewModel: FooterViewModel): void; - notifyIdle(): void; -} diff --git a/apps/pythinker-code/src/tui/runtime/dialogs/choice-picker-view.tsx b/apps/pythinker-code/src/tui/runtime/dialogs/choice-picker-view.tsx deleted file mode 100644 index 620e0416..00000000 --- a/apps/pythinker-code/src/tui/runtime/dialogs/choice-picker-view.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { KeyEvent } from '@opentui/core'; -import { useKeyboard } from '@opentui/solid'; -import { createSignal } from 'solid-js'; - -import { - defaultKeybindings, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; -import { - DialogListModel, - type DialogListKeyEvent, - type DialogListOptions, - type DialogRow, -} from '#/tui/presentation/dialog-list-model'; -import { openTuiKeyId } from '#/tui/runtime/footer/open-tui-composer-port'; -import { DialogListView } from './dialog-list-view'; - -export interface ChoicePickerViewProps { - readonly options: DialogListOptions; - readonly width: number; - readonly bindings?: readonly ParsedKeybinding[]; - readonly context?: 'Select'; - readonly onSelect: (row: DialogRow) => void; - readonly onCancel: () => void; -} - -const SELECT_ACTIONS: ReadonlySet<string> = new Set([ - 'select:previous', - 'select:next', - 'select:accept', - 'select:cancel', -]); - -function selectBindings( - bindings: readonly ParsedKeybinding[], -): readonly ParsedKeybinding[] { - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - return [...winners.values()].filter( - (binding) => - (binding.context === 'Select' || binding.context === 'Global') && - (binding.action === null || SELECT_ACTIONS.has(binding.action)), - ); -} - -function dialogKeyEvent(key: Readonly<KeyEvent>): DialogListKeyEvent | undefined { - switch (key.name) { - case 'up': - return { kind: 'up' }; - case 'down': - return { kind: 'down' }; - case 'home': - return { kind: 'home' }; - case 'end': - return { kind: 'end' }; - case 'pageup': - return { kind: 'page-up' }; - case 'pagedown': - return { kind: 'page-down' }; - case 'return': - return { kind: 'enter' }; - case 'escape': - return { kind: 'escape' }; - case 'backspace': - return { kind: 'backspace' }; - default: - return !key.ctrl && !key.meta && !key.super && key.sequence.length === 1 - ? { kind: 'char', char: key.sequence } - : undefined; - } -} - -export function ChoicePickerView(props: Readonly<ChoicePickerViewProps>) { - const model = new DialogListModel(props.options); - const keybindings = new KeybindingResolver( - selectBindings(props.bindings ?? defaultKeybindings()), - ); - const context = props.context ?? 'Select'; - const [viewModel, setViewModel] = createSignal(model.toViewModel()); - - const handleEvent = (event: DialogListKeyEvent): void => { - const result = model.handleKey(event); - setViewModel(model.toViewModel()); - - if (result.type === 'select') props.onSelect(result.row); - if (result.type === 'cancel') props.onCancel(); - }; - - useKeyboard((key: Readonly<KeyEvent>) => { - const handled = keybindings.dispatchKeyId( - openTuiKeyId({ - key: key.name, - ctrl: key.ctrl, - alt: key.meta, - shift: key.shift, - super: key.super, - }), - [context], - { - 'select:previous': () => handleEvent({ kind: 'up' }), - 'select:next': () => handleEvent({ kind: 'down' }), - 'select:accept': () => handleEvent({ kind: 'enter' }), - 'select:cancel': () => handleEvent({ kind: 'escape' }), - }, - ); - if (handled) { - key.preventDefault(); - key.stopPropagation(); - return; - } - - const event = dialogKeyEvent(key); - if (!event) return; - - key.preventDefault(); - key.stopPropagation(); - handleEvent(event); - }); - - return <DialogListView viewModel={viewModel()} width={props.width} />; -} diff --git a/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-rows.ts b/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-rows.ts deleted file mode 100644 index 57e12572..00000000 --- a/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-rows.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import type { DialogViewModel } from '#/tui/presentation/dialog-list-model'; -import { currentTheme } from '#/tui/theme/theme'; -import { truncateToWidth } from '../footer/text-layout'; - -const SEARCH_SUFFIX = ' (type to search)'; -const DIALOG_HINT = '↑↓ navigate · Enter select · Esc cancel'; -const CLEAR_SEARCH_HINT = `${DIALOG_HINT} · Backspace clear`; - -export function renderDialogListRows(viewModel: DialogViewModel, width: number): readonly string[] { - const query = viewModel.query ?? ''; - const border = currentTheme.fg('primary', '─'.repeat(Math.max(0, width))); - const titleText = - query.length > 0 - ? currentTheme.boldFg('primary', viewModel.title) - : `${currentTheme.boldFg('primary', viewModel.title)}${currentTheme.fg('textMuted', SEARCH_SUFFIX)}`; - const lines: string[] = [ - truncateToWidth(border, width), - truncateToWidth(titleText, width), - truncateToWidth( - currentTheme.fg('textMuted', query.length > 0 ? CLEAR_SEARCH_HINT : DIALOG_HINT), - width, - ), - '', - ]; - - if (query.length > 0) { - lines.push( - truncateToWidth( - `${currentTheme.fg('primary', 'Search: ')}${currentTheme.fg('text', query)}`, - width, - ), - ); - } - - if (viewModel.rows.length === 0) { - lines.push( - truncateToWidth(currentTheme.fg('textMuted', viewModel.hint ?? ''), width), - truncateToWidth(border, width)); - return lines; - } - - for (const [index, row] of viewModel.rows.entries()) { - const selected = index === viewModel.selectedIndex; - const disabled = row.disabled === true; - const prefix = currentTheme.fg( - disabled ? 'textDim' : selected ? 'primary' : 'textDim', - selected ? `${SELECT_POINTER} ` : ' ', - ); - const label = disabled - ? currentTheme.fg('textDim', row.label) - : selected - ? currentTheme.boldFg('primary', row.label) - : currentTheme.fg('text', row.label); - const currentMark = - row.current === true ? currentTheme.fg('success', ` ${CURRENT_MARK}`) : ''; - const disabledMark = - disabled ? currentTheme.fg('textDim', ' (disabled)') : ''; - lines.push( - truncateToWidth(`${prefix}${label}${currentMark}${disabledMark}`, width), - ); - } - - lines.push(truncateToWidth(border, width)); - return lines; -} diff --git a/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-view.tsx b/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-view.tsx deleted file mode 100644 index ef0a077f..00000000 --- a/apps/pythinker-code/src/tui/runtime/dialogs/dialog-list-view.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { For, Show } from 'solid-js'; - -import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; -import { currentTheme } from '#/tui/theme/theme'; -import type { DialogViewModel } from '../../presentation/dialog-list-model'; - -const SEARCH_SUFFIX = ' (type to search)'; -const DIALOG_HINT = '↑↓ navigate · Enter select · Esc cancel'; -const CLEAR_SEARCH_HINT = `${DIALOG_HINT} · Backspace clear`; - -export interface DialogListViewProps { - readonly viewModel: DialogViewModel; - readonly width: number; -} - -export function DialogListView(props: DialogListViewProps) { - const query = () => props.viewModel.query ?? ''; - const border = () => '─'.repeat(Math.max(0, props.width)); - - return ( - <box flexDirection='column' width={props.width}> - <text fg={currentTheme.palette.primary} height={1}> - {border()} - </text> - <text height={1}> - <b style={{ fg: currentTheme.palette.primary }}>{props.viewModel.title}</b> - <Show when={query().length === 0}> - <span style={{ fg: currentTheme.palette.textMuted }}>{SEARCH_SUFFIX}</span> - </Show> - </text> - <text fg={currentTheme.palette.textMuted} height={1}> - {query().length > 0 ? CLEAR_SEARCH_HINT : DIALOG_HINT} - </text> - <text height={1}> </text> - <Show when={query().length > 0}> - <text height={1}> - <span style={{ fg: currentTheme.palette.primary }}>Search: </span> - <span style={{ fg: currentTheme.palette.text }}>{query()}</span> - </text> - </Show> - <Show - when={props.viewModel.rows.length > 0} - fallback={ - <text fg={currentTheme.palette.textMuted} height={1}> - {props.viewModel.hint ?? ''} - </text> - } - > - <For each={props.viewModel.rows}> - {(row, index) => { - const selected = () => index() === props.viewModel.selectedIndex; - const disabled = row.disabled === true; - const rowColor = () => - disabled - ? currentTheme.palette.textDim - : selected() - ? currentTheme.palette.primary - : currentTheme.palette.text; - const prefixColor = () => - disabled || !selected() - ? currentTheme.palette.textDim - : currentTheme.palette.primary; - - return ( - <text fg={rowColor()} height={1}> - <span style={{ fg: prefixColor() }}> - {selected() ? `${SELECT_POINTER} ` : ' '} - </span> - {selected() && !disabled ? ( - <b style={{ fg: currentTheme.palette.primary }}>{row.label}</b> - ) : ( - <span style={{ fg: rowColor() }}>{row.label}</span> - )} - {row.current === true ? ( - <span style={{ fg: currentTheme.palette.success }}> - {` ${CURRENT_MARK}`} - </span> - ) : null} - {disabled ? ( - <span style={{ fg: currentTheme.palette.textDim }}> (disabled)</span> - ) : null} - </text> - ); - }} - </For> - </Show> - <text fg={currentTheme.palette.primary} height={1}> - {border()} - </text> - </box> - ); -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/activity-row.tsx b/apps/pythinker-code/src/tui/runtime/footer/activity-row.tsx deleted file mode 100644 index ad0f4aae..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/activity-row.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import type { FooterActivityRowViewModel } from './footer-model'; - -export interface ActivityRowProps { - readonly model: FooterActivityRowViewModel; - /** Produced by renderFooterRows, the single source of bounded row layout. */ - readonly renderedText: string; -} - -export function ActivityRow(props: ActivityRowProps) { - return <text height={1}>{props.renderedText}</text>; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/composer-state.ts b/apps/pythinker-code/src/tui/runtime/footer/composer-state.ts deleted file mode 100644 index b38d43f0..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/composer-state.ts +++ /dev/null @@ -1,548 +0,0 @@ -export interface ComposerState { - readonly lines: readonly string[]; - readonly cursorLine: number; - readonly cursorCol: number; - readonly pastes: ReadonlyMap<number, string>; - readonly pasteCounter: number; -} - -export interface HistoryNavigationResult { - readonly state: ComposerState; - readonly historyIndex: number | null; -} - -export interface PasteExpansionResult { - readonly state: ComposerState; - readonly expanded: boolean; -} - -export interface ComposerPrefix { - readonly kind: 'slash' | 'mention'; - readonly query: string; - readonly start: number; -} - -const graphemeSegmenter = new Intl.Segmenter(undefined, { - granularity: 'grapheme', -}); - -function currentLine(state: ComposerState): string { - return state.lines[state.cursorLine] ?? ''; -} - -function withCursor( - state: ComposerState, - cursorLine: number, - cursorCol: number, -): ComposerState { - return { - ...state, - cursorLine, - cursorCol, - }; -} - -function withText( - state: ComposerState, - text: string, -): ComposerState { - const next = createComposerState(text); - return { - ...next, - pastes: state.pastes, - pasteCounter: state.pasteCounter, - }; -} - -function previousGraphemeBoundary(text: string, cursor: number): number { - let boundary = 0; - for (const segment of graphemeSegmenter.segment(text)) { - if (segment.index >= cursor) { - break; - } - boundary = segment.index; - } - return boundary; -} - -function nextGraphemeBoundary(text: string, cursor: number): number { - for (const segment of graphemeSegmenter.segment(text)) { - const end = segment.index + segment.segment.length; - if (end > cursor) { - return end; - } - } - return text.length; -} - -function isWhitespace(character: string): boolean { - return /\s/.test(character); -} - -function previousWordBoundary(text: string, cursor: number): number { - if (cursor <= 0) { - return 0; - } - - let boundary = cursor - 1; - const whitespace = isWhitespace(text[boundary] ?? ''); - while ( - boundary > 0 && - isWhitespace(text[boundary - 1] ?? '') === whitespace - ) { - boundary -= 1; - } - return boundary; -} - -function nextWordBoundary(text: string, cursor: number): number { - if (cursor >= text.length) { - return text.length; - } - - let boundary = cursor + 1; - const whitespace = isWhitespace(text[cursor] ?? ''); - while ( - boundary < text.length && - isWhitespace(text[boundary] ?? '') === whitespace - ) { - boundary += 1; - } - return boundary; -} - -export function createComposerState(text = ''): ComposerState { - const lines = text.split('\n'); - const cursorLine = lines.length - 1; - return { - lines, - cursorLine, - cursorCol: lines[cursorLine]?.length ?? 0, - pastes: new Map(), - pasteCounter: 0, - }; -} - -export function getComposerText(state: ComposerState): string { - return state.lines.join('\n'); -} - -export function insertText( - state: ComposerState, - text: string, -): ComposerState { - const line = currentLine(state); - const before = line.slice(0, state.cursorCol); - const after = line.slice(state.cursorCol); - const insertedLines = text.split('\n'); - const firstInsertedLine = insertedLines[0] ?? ''; - - if (insertedLines.length === 1) { - const lines = [...state.lines]; - lines[state.cursorLine] = before + firstInsertedLine + after; - return { - ...state, - lines, - cursorCol: state.cursorCol + firstInsertedLine.length, - }; - } - - const lastInsertedLine = insertedLines.at(-1) ?? ''; - const replacement = [ - before + firstInsertedLine, - ...insertedLines.slice(1, -1), - lastInsertedLine + after, - ]; - const lines = [ - ...state.lines.slice(0, state.cursorLine), - ...replacement, - ...state.lines.slice(state.cursorLine + 1), - ]; - return { - ...state, - lines, - cursorLine: state.cursorLine + insertedLines.length - 1, - cursorCol: lastInsertedLine.length, - }; -} - -export function insertNewline(state: ComposerState): ComposerState { - return insertText(state, '\n'); -} - -export function deleteBackwardGrapheme( - state: ComposerState, -): ComposerState { - if (state.cursorCol === 0) { - if (state.cursorLine === 0) { - return state; - } - const previousLine = state.lines[state.cursorLine - 1] ?? ''; - const line = currentLine(state); - const lines = [...state.lines]; - lines.splice(state.cursorLine - 1, 2, previousLine + line); - return { - ...state, - lines, - cursorLine: state.cursorLine - 1, - cursorCol: previousLine.length, - }; - } - - const line = currentLine(state); - const boundary = previousGraphemeBoundary(line, state.cursorCol); - const lines = [...state.lines]; - lines[state.cursorLine] = - line.slice(0, boundary) + line.slice(state.cursorCol); - return { - ...state, - lines, - cursorCol: boundary, - }; -} - -export function deleteForwardGrapheme( - state: ComposerState, -): ComposerState { - const line = currentLine(state); - if (state.cursorCol === line.length) { - if (state.cursorLine === state.lines.length - 1) { - return state; - } - const nextLine = state.lines[state.cursorLine + 1] ?? ''; - const lines = [...state.lines]; - lines.splice(state.cursorLine, 2, line + nextLine); - return { - ...state, - lines, - }; - } - - const boundary = nextGraphemeBoundary(line, state.cursorCol); - const lines = [...state.lines]; - lines[state.cursorLine] = - line.slice(0, state.cursorCol) + line.slice(boundary); - return { - ...state, - lines, - }; -} - -export function deleteBackwardWord(state: ComposerState): ComposerState { - if (state.cursorCol === 0) { - return deleteBackwardGrapheme(state); - } - - const line = currentLine(state); - const boundary = previousWordBoundary(line, state.cursorCol); - const lines = [...state.lines]; - lines[state.cursorLine] = - line.slice(0, boundary) + line.slice(state.cursorCol); - return { - ...state, - lines, - cursorCol: boundary, - }; -} - -export function deleteForwardWord(state: ComposerState): ComposerState { - const line = currentLine(state); - if (state.cursorCol === line.length) { - return deleteForwardGrapheme(state); - } - - const boundary = nextWordBoundary(line, state.cursorCol); - const lines = [...state.lines]; - lines[state.cursorLine] = - line.slice(0, state.cursorCol) + line.slice(boundary); - return { - ...state, - lines, - }; -} - -export function moveCursorLeft(state: ComposerState): ComposerState { - if (state.cursorCol > 0) { - return withCursor( - state, - state.cursorLine, - previousGraphemeBoundary(currentLine(state), state.cursorCol), - ); - } - if (state.cursorLine === 0) { - return state; - } - const previousLine = state.lines[state.cursorLine - 1] ?? ''; - return withCursor(state, state.cursorLine - 1, previousLine.length); -} - -export function moveCursorRight(state: ComposerState): ComposerState { - const line = currentLine(state); - if (state.cursorCol < line.length) { - return withCursor( - state, - state.cursorLine, - nextGraphemeBoundary(line, state.cursorCol), - ); - } - if (state.cursorLine === state.lines.length - 1) { - return state; - } - return withCursor(state, state.cursorLine + 1, 0); -} - -export function moveCursorWordLeft(state: ComposerState): ComposerState { - if (state.cursorCol > 0) { - return withCursor( - state, - state.cursorLine, - previousWordBoundary(currentLine(state), state.cursorCol), - ); - } - if (state.cursorLine === 0) { - return state; - } - const previousLine = state.lines[state.cursorLine - 1] ?? ''; - return withCursor(state, state.cursorLine - 1, previousLine.length); -} - -export function moveCursorWordRight(state: ComposerState): ComposerState { - const line = currentLine(state); - if (state.cursorCol < line.length) { - return withCursor( - state, - state.cursorLine, - nextWordBoundary(line, state.cursorCol), - ); - } - if (state.cursorLine === state.lines.length - 1) { - return state; - } - return withCursor(state, state.cursorLine + 1, 0); -} - -export function moveCursorUp(state: ComposerState): ComposerState { - if (state.cursorLine === 0) { - return state; - } - const targetLine = state.lines[state.cursorLine - 1] ?? ''; - return withCursor( - state, - state.cursorLine - 1, - Math.min(state.cursorCol, targetLine.length), - ); -} - -export function moveCursorDown(state: ComposerState): ComposerState { - if (state.cursorLine === state.lines.length - 1) { - return state; - } - const targetLine = state.lines[state.cursorLine + 1] ?? ''; - return withCursor( - state, - state.cursorLine + 1, - Math.min(state.cursorCol, targetLine.length), - ); -} - -export function moveCursorLineStart(state: ComposerState): ComposerState { - return withCursor(state, state.cursorLine, 0); -} - -export function moveCursorLineEnd(state: ComposerState): ComposerState { - return withCursor(state, state.cursorLine, currentLine(state).length); -} - -export function moveCursorTextStart(state: ComposerState): ComposerState { - return withCursor(state, 0, 0); -} - -export function moveCursorTextEnd(state: ComposerState): ComposerState { - const cursorLine = state.lines.length - 1; - return withCursor( - state, - cursorLine, - state.lines[cursorLine]?.length ?? 0, - ); -} - -export function clearComposer(state: ComposerState): ComposerState { - return { - lines: [''], - cursorLine: 0, - cursorCol: 0, - pastes: state.pastes, - pasteCounter: state.pasteCounter, - }; -} - -export function historyUp( - state: ComposerState, - history: readonly string[], - historyIndex: number | null, -): HistoryNavigationResult { - if (state.cursorLine !== 0 || history.length === 0) { - return { state, historyIndex }; - } - - const nextIndex = - historyIndex === null - ? history.length - 1 - : Math.max(0, historyIndex - 1); - const entry = history[nextIndex]; - if (entry === undefined) { - return { state, historyIndex }; - } - return { - state: withText(state, entry), - historyIndex: nextIndex, - }; -} - -export function historyDown( - state: ComposerState, - history: readonly string[], - historyIndex: number | null, -): HistoryNavigationResult { - if ( - state.cursorLine !== state.lines.length - 1 || - historyIndex === null - ) { - return { state, historyIndex }; - } - - if (historyIndex >= history.length - 1) { - return { - state: clearComposer(state), - historyIndex: null, - }; - } - - const nextIndex = historyIndex + 1; - const entry = history[nextIndex]; - if (entry === undefined) { - return { state, historyIndex }; - } - return { - state: withText(state, entry), - historyIndex: nextIndex, - }; -} - -export function capturePaste( - state: ComposerState, - text: string, -): ComposerState { - const lineCount = text.split('\n').length; - if (lineCount <= 10 && text.length <= 1000) { - return insertText(state, text); - } - - const id = state.pasteCounter + 1; - const marker = - lineCount > 10 - ? `[paste #${id} +${lineCount} lines]` - : `[paste #${id} ${text.length} chars]`; - const pastes = new Map(state.pastes); - pastes.set(id, text); - return insertText( - { - ...state, - pastes, - pasteCounter: id, - }, - marker, - ); -} - -export function expandPasteMarkerAtCursor( - state: ComposerState, -): PasteExpansionResult { - const line = currentLine(state); - - for (const match of line.matchAll( - /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g, - )) { - const marker = match[0]; - const idText = match[1]; - const start = match.index; - if ( - idText === undefined || - start === undefined || - state.cursorCol < start || - state.cursorCol > start + marker.length - ) { - continue; - } - - const id = Number(idText); - const pastedText = state.pastes.get(id); - if (pastedText === undefined) { - continue; - } - - const lines = [...state.lines]; - lines[state.cursorLine] = - line.slice(0, start) + line.slice(start + marker.length); - const pastes = new Map(state.pastes); - pastes.delete(id); - const nextState = insertText( - { - ...state, - lines, - cursorCol: start, - pastes, - }, - pastedText, - ); - return { - state: nextState, - expanded: true, - }; - } - - return { - state, - expanded: false, - }; -} - -export function detectComposerPrefix( - state: ComposerState, -): ComposerPrefix | null { - const line = currentLine(state); - const beforeCursor = line.slice(0, state.cursorCol); - - if (state.cursorLine === 0) { - const slashStart = line.search(/\S/); - if ( - slashStart >= 0 && - line[slashStart] === '/' && - state.cursorCol > slashStart - ) { - const query = line.slice(slashStart + 1, state.cursorCol); - if (!/\s/.test(query)) { - return { - kind: 'slash', - query, - start: slashStart, - }; - } - } - } - - const tokenStart = beforeCursor.search(/\S+$/); - if ( - tokenStart >= 0 && - beforeCursor[tokenStart] === '@' && - (tokenStart === 0 || /\s/.test(line[tokenStart - 1] ?? '')) - ) { - return { - kind: 'mention', - query: beforeCursor.slice(tokenStart + 1), - start: tokenStart, - }; - } - - return null; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/composer.tsx b/apps/pythinker-code/src/tui/runtime/footer/composer.tsx deleted file mode 100644 index ae264721..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/composer.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { Accessor } from 'solid-js'; - -import type { - ComposerPort, - ComposerViewState, -} from '#/tui/runtime/footer/open-tui-composer-port'; - -import { truncateToWidth } from './text-layout'; - -export interface ComposerProps { - readonly port: ComposerPort; - readonly revision: Accessor<number>; - readonly width: number; -} - -export function renderComposerRow( - view: ComposerViewState, - width: number, -): string { - const safeWidth = Math.max(0, Math.trunc(Number.isFinite(width) ? width : 0)); - const content = view.isEmpty ? view.placeholder : view.text; - return truncateToWidth(`${view.marker} ${content}`, safeWidth); -} - -export function Composer(props: ComposerProps) { - const row = () => { - props.revision(); - return renderComposerRow(props.port.getViewState(), props.width); - }; - return <text height={1}>{row()}</text>; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts deleted file mode 100644 index ae567f57..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ /dev/null @@ -1,847 +0,0 @@ -/** - * Renderer-neutral split-footer model. - * - * The footer is a short operational hierarchy: - * 1. an optional validation or visible activity row - * 2. a typed composer slot (the input renderer is intentionally supplied later) - * 3. model, runtime context, git, modes, elapsed time, and lower-priority counters - * 4. an optional YOLO indicator beneath the model row - * - * This module has no renderer, terminal, theme, I/O, or ambient-clock dependency. - * Callers pass the current clock value to `selectFooterViewModel`. - */ - -import type { StatusLineConfig } from '#/tui/config'; -import { shortEffortLabel } from '#/tui/utils/thinking-levels'; - -export type FooterActivityPhase = - | 'hidden' - | 'waiting' - | 'thinking' - | 'composing' - | 'tool'; - -export interface FooterActivity { - readonly phase: FooterActivityPhase; - readonly label: string | null; - readonly spinnerActive: boolean; - readonly spinnerFrame: string; -} - -export type FooterValidationLevel = 'info' | 'warning' | 'error'; - -export interface FooterValidation { - readonly level: FooterValidationLevel; - readonly message: string; -} - -export interface FooterQueue { - readonly count: number; - readonly canSteerImmediately: boolean; -} - -export type FooterTodoStatus = 'pending' | 'in_progress' | 'done'; - -export interface FooterTodoItem { - readonly title: string; - readonly status: FooterTodoStatus; -} - -export type FooterGoalStatus = 'active' | 'paused' | 'blocked' | 'complete'; - -export interface FooterGoal { - readonly status: FooterGoalStatus; - readonly turnsUsed: number; - readonly turnBudget: number | null; - readonly wallClockMs: number; - /** - * Clock value at which `wallClockMs` was observed. Active goals add elapsed - * time since this value; paused and blocked goals do not. - */ - readonly observedAtMs: number; -} - -export interface FooterBackgroundCounts { - readonly bashTasks: number; - readonly agentTasks: number; -} - -export interface FooterSubagentCounts { - readonly active: number; - readonly queued: number; - readonly completed: number; - readonly failed: number; -} - -export interface FooterCompaction { - readonly active: boolean; - readonly label: string | null; -} - -export type FooterUpdateState = - | 'available' - | 'required' - | 'downloading' - | 'waiting' - | 'ready' - | 'failed'; - -export interface FooterUpdate { - readonly version: string | null; - readonly state: FooterUpdateState | null; - /** Null means indeterminate — render without a bar rather than inventing a percentage. */ - readonly percent: number | null; -} - -export type FooterBtwPhase = 'closed' | 'running' | 'done' | 'failed'; - -export interface FooterBtwState { - readonly phase: FooterBtwPhase; - readonly turnCount: number; -} - -export type FooterPermissionMode = 'manual' | 'auto' | 'yolo'; - -export interface FooterPullRequest { - readonly number: number; -} - -export interface FooterGitStatus { - readonly branch: string; - readonly dirty: boolean; - readonly ahead: number; - readonly behind: number; - readonly diffAdded: number; - readonly diffDeleted: number; - readonly pullRequest: FooterPullRequest | null; -} - -export interface FooterStatus { - readonly model: string; - /** Accumulated session cost reported by the agent. */ - readonly sessionSpendUsd: number | undefined; - /** Resolved thinking effort level; 'off' means thinking is disabled. */ - readonly thinkingLevel: string; - readonly cwd: string; - /** Supplied explicitly so cwd shortening remains independent of process.env. */ - readonly homeDir: string | null; - readonly git: FooterGitStatus | null; - readonly permissionMode: FooterPermissionMode; - readonly planMode: boolean; - readonly dynamicWorkflowMode: boolean; - /** Fast mode requested and supported; shown as `↯ fast` beside the model. */ - readonly fastMode: boolean; - readonly contextUsage: number; - readonly contextTokens: number | null; - readonly maxContextTokens: number | null; - readonly tokenSpeed: number | null; - readonly tokenSpeedEstimated: boolean; - /** Null when the runtime has no active streaming start. */ - readonly elapsedMs: number | null; -} - -export interface FooterComposerState { - readonly textLength: number; - readonly placeholder: string; -} - -export interface FooterState { - readonly activity: FooterActivity; - readonly validation: FooterValidation | null; - readonly queue: FooterQueue; - readonly todos: readonly FooterTodoItem[]; - readonly goal: FooterGoal | null; - readonly background: FooterBackgroundCounts; - readonly subagents: FooterSubagentCounts; - readonly compaction: FooterCompaction; - readonly transientHint: string | null; - readonly update: FooterUpdate; - readonly btw: FooterBtwState; - readonly status: FooterStatus; - readonly composer: FooterComposerState; -} - -export type FooterEvent = - | { readonly type: 'activity.updated'; readonly activity: FooterActivity } - | { - readonly type: 'validation.updated'; - readonly validation: FooterValidation | null; - } - | { readonly type: 'queue.updated'; readonly queue: FooterQueue } - | { readonly type: 'todo.updated'; readonly todos: readonly FooterTodoItem[] } - | { readonly type: 'goal.updated'; readonly goal: FooterGoal | null } - | { - readonly type: 'background-counts.updated'; - readonly counts: FooterBackgroundCounts; - } - | { - readonly type: 'subagents.updated'; - readonly counts: FooterSubagentCounts; - } - | { - readonly type: 'compaction.updated'; - readonly compaction: FooterCompaction; - } - | { readonly type: 'transient-hint.updated'; readonly hint: string | null } - | { readonly type: 'update.updated'; readonly update: FooterUpdate } - | { readonly type: 'btw.updated'; readonly btw: FooterBtwState } - | { readonly type: 'status.updated'; readonly changes: Partial<FooterStatus> } - | { - readonly type: 'composer.updated'; - readonly composer: FooterComposerState; - }; - -export interface FooterActivityRowViewModel { - readonly kind: 'activity'; - readonly primary: string; - readonly spinnerActive: boolean; - readonly indicators: readonly string[]; -} - -export interface FooterComposerSlotViewModel { - readonly kind: 'composer-slot'; - readonly marker: string; - readonly placeholder: string; - readonly textLength: number; -} - -export interface FooterComposerRowViewModel { - readonly kind: 'composer'; - readonly slot: FooterComposerSlotViewModel; -} - -export interface FooterStatusRowViewModel { - readonly kind: 'status'; - readonly items: readonly string[]; - /** Highlights a high-risk status row without coupling the model to a renderer. */ - readonly emphasis?: 'danger'; - /** - * Leading segment of the model item, or null when this row has no model item. - * Named separately so a renderer can tint it without re-parsing the row. - */ - readonly modelName: string | null; -} - -export interface FooterValidationRowViewModel { - readonly kind: 'validation'; - readonly level: FooterValidationLevel; - readonly message: string; -} - -export type FooterViewModelRow = - | FooterActivityRowViewModel - | FooterComposerRowViewModel - | FooterStatusRowViewModel - | FooterValidationRowViewModel; - -export type FooterViewModelRows = readonly FooterViewModelRow[]; - -export interface FooterViewModel { - /** Ordered top-to-bottom rows: optional activity/validation, composer, then status rows. */ - readonly rows: FooterViewModelRows; -} - -const DEFAULT_STATUS: FooterStatus = Object.freeze({ - model: '', - sessionSpendUsd: undefined, - thinkingLevel: 'off', - cwd: '', - homeDir: null, - git: null, - permissionMode: 'manual', - planMode: false, - dynamicWorkflowMode: false, - fastMode: false, - contextUsage: 0, - contextTokens: null, - maxContextTokens: null, - tokenSpeed: null, - tokenSpeedEstimated: false, - elapsedMs: null, -}); - -export function createFooterState( - status: Partial<FooterStatus> = {}, -): FooterState { - return freezeState({ - activity: { - phase: 'hidden', - label: null, - spinnerActive: false, - spinnerFrame: '⠋', - }, - validation: null, - queue: { count: 0, canSteerImmediately: true }, - todos: [], - goal: null, - background: { bashTasks: 0, agentTasks: 0 }, - subagents: { active: 0, queued: 0, completed: 0, failed: 0 }, - compaction: { active: false, label: null }, - transientHint: null, - update: { version: null, state: null, percent: null }, - btw: { phase: 'closed', turnCount: 0 }, - status: { ...DEFAULT_STATUS, ...status }, - composer: { textLength: 0, placeholder: 'Composer' }, - }); -} - -export const createInitialFooterState = createFooterState; - -export function reduceFooterState( - state: FooterState, - event: FooterEvent, -): FooterState { - switch (event.type) { - case 'activity.updated': - return freezeState({ ...state, activity: event.activity }); - case 'validation.updated': - return freezeState({ ...state, validation: event.validation }); - case 'queue.updated': - return freezeState({ ...state, queue: event.queue }); - case 'todo.updated': - return freezeState({ ...state, todos: event.todos }); - case 'goal.updated': - return freezeState({ ...state, goal: event.goal }); - case 'background-counts.updated': - return freezeState({ ...state, background: event.counts }); - case 'subagents.updated': - return freezeState({ ...state, subagents: event.counts }); - case 'compaction.updated': - return freezeState({ ...state, compaction: event.compaction }); - case 'transient-hint.updated': - return freezeState({ ...state, transientHint: event.hint }); - case 'update.updated': - return freezeState({ ...state, update: event.update }); - case 'btw.updated': - return freezeState({ ...state, btw: event.btw }); - case 'status.updated': - return freezeState({ - ...state, - status: { ...state.status, ...event.changes }, - }); - case 'composer.updated': - return freezeState({ ...state, composer: event.composer }); - } -} - -export const footerReducer = reduceFooterState; - -export function foldFooterEvents( - initialState: FooterState, - events: readonly FooterEvent[], -): FooterState { - return events.reduce(reduceFooterState, initialState); -} - -export function selectFooterViewModel( - state: FooterState, - clockMs: number, - statusLine: StatusLineConfig, -): FooterViewModel { - const optional = selectOptionalRow(state); - const composer = Object.freeze<FooterComposerRowViewModel>({ - kind: 'composer', - slot: Object.freeze({ - kind: 'composer-slot', - marker: '❯', - placeholder: state.composer.placeholder, - textLength: nonNegativeInteger(state.composer.textLength), - }), - }); - const modelName = normalizeSingleLine(state.status.model); - const status = Object.freeze<FooterStatusRowViewModel>({ - kind: 'status', - items: Object.freeze(selectStatusItems(state, clockMs, statusLine)), - modelName: statusLine.showModel && modelName.length > 0 ? modelName : null, - }); - const yoloStatus = - statusLine.showModes && state.status.permissionMode === 'yolo' - ? Object.freeze<FooterStatusRowViewModel>({ - kind: 'status', - items: Object.freeze(['yolo']), - emphasis: 'danger', - modelName: null, - }) - : null; - const statusRows: FooterViewModelRows = - yoloStatus === null ? [status] : [status, yoloStatus]; - const rows: FooterViewModelRows = Object.freeze( - optional === null - ? [composer, ...statusRows] - : [optional, composer, ...statusRows], - ); - return Object.freeze({ rows }); -} - -/** Columns the status row is inset by so it sits under the prompt text, not the `❯`. */ -const STATUS_ROW_INDENT = ' '; - -/** - * Single source of status-row text for both the pi-tui footer and the Solid - * view; they must stay identical because either renderer can be live. - */ -export function formatStatusRow(items: readonly string[]): string { - const [primary, ...rest] = items; - if (primary === undefined) return ''; - const body = rest.length === 0 ? primary : `${primary} ${rest.join(' · ')}`; - return STATUS_ROW_INDENT + body; -} - -function selectOptionalRow( - state: FooterState, -): FooterActivityRowViewModel | FooterValidationRowViewModel | null { - const validation = state.validation; - if (validation !== null) { - const message = normalizeSingleLine(validation.message); - if (message.length > 0) { - return Object.freeze({ - kind: 'validation', - level: validation.level, - message, - }); - } - } - - const transient = normalizeSingleLine(state.transientHint ?? ''); - if (transient.length > 0) { - return Object.freeze({ kind: 'validation', level: 'info', message: transient }); - } - - const activity = selectActivityRow(state); - return activity.primary.length > 0 || activity.indicators.length > 0 ? activity : null; -} - -function selectActivityRow(state: FooterState): FooterActivityRowViewModel { - let primary = ''; - let spinnerActive = false; - if (state.compaction.active) { - primary = state.compaction.label?.trim() || 'Compacting context…'; - spinnerActive = true; - } else if (state.activity.phase !== 'hidden') { - primary = - state.activity.label?.trim() || - defaultActivityLabel(state.activity.phase); - spinnerActive = state.activity.spinnerActive; - } - - if (spinnerActive && primary.length > 0) { - const frame = normalizeSingleLine(state.activity.spinnerFrame) || '⠋'; - primary = `${frame} ${primary}`; - } - - const indicators: string[] = []; - if (state.queue.count > 0) { - indicators.push( - `[${String(nonNegativeInteger(state.queue.count))} queued]`, - ); - } - - const todoBadge = formatTodoBadge(state.todos); - if (todoBadge !== null) indicators.push(todoBadge); - - const liveSubagents = - nonNegativeInteger(state.subagents.active) + - nonNegativeInteger(state.subagents.queued); - if (liveSubagents > 0) { - indicators.push( - `[${String(liveSubagents)} ${plural(liveSubagents, 'subagent')}]`, - ); - } - if (state.subagents.failed > 0) { - indicators.push( - `[${String(nonNegativeInteger(state.subagents.failed))} failed]`, - ); - } - if (state.btw.phase !== 'closed') { - indicators.push(`[btw ${state.btw.phase}]`); - } - - return Object.freeze({ - kind: 'activity', - primary: normalizeSingleLine(primary), - spinnerActive, - indicators: Object.freeze(indicators), - }); -} - -export function selectStatusItemParts( - state: FooterState, - clockMs: number, - statusLine: StatusLineConfig, -): { - readonly update: string | null; - readonly model: string | null; - readonly speed: string | null; - readonly spend: string | null; - readonly context: string | null; - readonly git: string | null; - readonly modes: string | null; - readonly elapsed: string | null; - readonly goal: string | null; - readonly background: readonly string[]; -} { - const update = formatUpdate(state.update); - const modelName = normalizeSingleLine(state.status.model); - const speed = statusLine.showTokenSpeed ? formatTokenSpeed(state.status) : null; - let model: string | null = null; - if (statusLine.showModel && modelName.length > 0) { - const effortSuffix = - statusLine.showEffort && state.status.thinkingLevel !== 'off' - ? ` · ${shortEffortLabel(state.status.thinkingLevel)}` - : ''; - // Fast rides on the model item and only while mode badges are visible, - // so it can never appear twice in the row. - const fastSuffix = statusLine.showModes && state.status.fastMode ? ' · ↯ fast' : ''; - model = `${modelName}${effortSuffix}${fastSuffix}${speed === null ? '' : ` · ${speed}`}`; - } - - let modes: string | null = null; - if (statusLine.showModes) { - const modeItems: string[] = []; - if (state.status.dynamicWorkflowMode) modeItems.push('workflow'); - if (state.status.permissionMode === 'auto') modeItems.push('auto'); - if (state.status.planMode) modeItems.push('plan'); - if (modeItems.length > 0) modes = modeItems.join(' '); - } - - const background: string[] = []; - if (statusLine.showBackgroundTasks) { - const bashTasks = nonNegativeInteger(state.background.bashTasks); - if (bashTasks > 0) { - background.push(`[${String(bashTasks)} ${plural(bashTasks, 'task')} running]`); - } - const agentTasks = nonNegativeInteger(state.background.agentTasks); - if (agentTasks > 0) { - background.push( - `[${String(agentTasks)} ${plural(agentTasks, 'agent')} running]`, - ); - } - } - - return { - update, - model, - speed, - spend: statusLine.showModel ? formatSessionSpend(state.status.sessionSpendUsd) : null, - context: statusLine.showContextBar ? formatContext(state.status) : null, - git: statusLine.showGit ? formatGitStatus(state.status.git) : null, - modes, - elapsed: - statusLine.showElapsed && state.status.elapsedMs !== null - ? `elapsed ${formatStatusElapsed(state.status.elapsedMs)}` - : null, - goal: statusLine.showGoal ? formatGoal(state.goal, clockMs) : null, - background, - }; -} - -function selectStatusItems( - state: FooterState, - clockMs: number, - statusLine: StatusLineConfig, -): string[] { - const parts = selectStatusItemParts(state, clockMs, statusLine); - const items = [ - parts.update, - parts.model, - parts.spend, - parts.context, - parts.git, - parts.modes, - parts.elapsed, - parts.goal, - ].filter((item): item is string => item !== null); - items.push(...parts.background); - return items; -} - -export function selectStatusBarExtras( - state: FooterState, - clockMs: number, - statusLine: StatusLineConfig, -): string[] { - const parts = selectStatusItemParts(state, clockMs, statusLine); - const items = [ - parts.context, - parts.git, - parts.update, - parts.spend, - parts.elapsed, - parts.goal, - ].filter((item): item is string => item !== null); - items.push(...parts.background); - return items; -} - -function defaultActivityLabel(phase: FooterActivityPhase): string { - switch (phase) { - case 'waiting': - return 'Waiting…'; - case 'thinking': - return 'Thinking…'; - case 'composing': - return 'Composing…'; - case 'tool': - return 'Using tool…'; - case 'hidden': - return ''; - } -} - -function formatTodoBadge(todos: readonly FooterTodoItem[]): string | null { - if (todos.length === 0) return null; - const total = todos.length; - const done = todos.filter((todo) => todo.status === 'done').length; - const active = todos.filter((todo) => todo.status === 'in_progress').length; - if (active > 0) - return `[todo ${String(done)}/${String(total)} · ${String(active)} active]`; - return `[todo ${String(done)}/${String(total)}]`; -} - -function formatGoal(goal: FooterGoal | null, clockMs: number): string | null { - if ( - goal === null || - (goal.status !== 'active' && - goal.status !== 'paused' && - goal.status !== 'blocked') - ) { - return null; - } - const elapsed = - goal.wallClockMs + - (goal.status === 'active' - ? Math.max(0, finiteOrZero(clockMs - goal.observedAtMs)) - : 0); - const turns = - goal.turnBudget === null - ? `${String(nonNegativeInteger(goal.turnsUsed))} ${plural(goal.turnsUsed, 'turn')}` - : `${String(nonNegativeInteger(goal.turnsUsed))}/${String(nonNegativeInteger(goal.turnBudget))} turns`; - return `[goal ● ${goal.status} · ${formatElapsed(elapsed)} · ${turns}]`; -} - -function formatElapsed(ms: number): string { - const totalSeconds = Math.round(Math.max(0, finiteOrZero(ms)) / 1_000); - if (totalSeconds < 60) return `${String(totalSeconds)}s`; - const minutes = Math.floor(totalSeconds / 60); - if (minutes < 60) return `${String(minutes)}m`; - const hours = Math.floor(minutes / 60); - return `${String(hours)}h${String(minutes % 60)}m`; -} - -function formatStatusElapsed(ms: number): string { - const totalSeconds = Math.floor(Math.max(0, finiteOrZero(ms)) / 1_000); - const seconds = totalSeconds % 60; - const totalMinutes = Math.floor(totalSeconds / 60); - const minutes = totalMinutes % 60; - const clock = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; - return totalMinutes < 60 ? clock : `${String(Math.floor(totalMinutes / 60))}:${clock}`; -} - -export function formatTokenSpeed( - status: Pick<FooterStatus, 'tokenSpeed' | 'tokenSpeedEstimated'>, -): string | null { - const speed = status.tokenSpeed; - if (speed === null || !Number.isFinite(speed) || speed < 0) return null; - return `${status.tokenSpeedEstimated ? '~' : ''}${speed.toFixed(1)} t/s`; -} - -function formatSessionSpend(spend: number | undefined): string | null { - if (spend === undefined || !Number.isFinite(spend) || spend <= 0) return null; - if (spend < 0.000001) return '<$0.000001'; - const roundedCents = spend.toFixed(2); - return Number(roundedCents) >= 0.01 - ? `$${roundedCents}` - : `$${spend.toFixed(6).replace(/\.?0+$/, '')}`; -} - -/** `↑ v0.11.0` — or `↓` while the download is in flight. */ -function formatUpdate(update: FooterUpdate): string | null { - const version = update.version; - const state = update.state; - if (version === null || state === null) return null; - const base = `${state === 'available' || state === 'required' || state === 'ready' || state === 'failed' ? '↑' : '↓'} v${version}`; - switch (state) { - case 'available': - return base; - case 'required': - return `${base} required`; - case 'downloading': { - const percent = update.percent; - if (percent === null || !Number.isFinite(percent)) return base; - const rounded = Math.round(Math.min(100, Math.max(0, percent))); - const filled = Math.min( - CONTEXT_BAR_CELLS, - Math.round((rounded / 100) * CONTEXT_BAR_CELLS), - ); - const bar = - CONTEXT_BAR_FILLED.repeat(filled) + - CONTEXT_BAR_EMPTY.repeat(CONTEXT_BAR_CELLS - filled); - return `${base} ${bar} ${String(rounded)}%`; - } - case 'waiting': - return `${base} waiting`; - case 'ready': - return `${base} restart to apply`; - case 'failed': - return `${base} failed`; - } -} - -/** - * Context gauge glyphs, matching the compaction progress bar so the two read as - * one design. The renderer paints them; this module stays theme-free. - */ -export const CONTEXT_BAR_FILLED = '▰'; -export const CONTEXT_BAR_EMPTY = '▱'; -const CONTEXT_BAR_CELLS = 8; - -/** `▰▰▱▱▱▱▱▱ 18% · 36k/200k` — bar, percentage, then absolute context size. */ -function formatContext(status: FooterStatus): string { - const tokens = status.contextTokens; - const maxTokens = status.maxContextTokens; - const known = - tokens !== null && - maxTokens !== null && - Number.isFinite(tokens) && - Number.isFinite(maxTokens) && - maxTokens > 0; - - const percent = clampPercent( - known - ? Math.ceil((tokens / maxTokens) * 100) - : Math.ceil(finiteOrZero(status.contextUsage) * 100), - ); - const filled = Math.min(CONTEXT_BAR_CELLS, Math.round((percent / 100) * CONTEXT_BAR_CELLS)); - const bar = - CONTEXT_BAR_FILLED.repeat(filled) + CONTEXT_BAR_EMPTY.repeat(CONTEXT_BAR_CELLS - filled); - const size = known ? ` · ${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)}` : ''; - return `${bar} ${String(percent)}%${size}`; -} - -/** Compact token counts: 900, 36k, 1.2M. */ -function formatTokenCount(tokens: number): string { - const value = Math.max(0, Math.round(tokens)); - if (value >= 1_000_000) return `${trimTrailingZero((value / 1_000_000).toFixed(1))}M`; - if (value >= 1_000) return `${trimTrailingZero((value / 1_000).toFixed(1))}k`; - return String(value); -} - -function trimTrailingZero(value: string): string { - return value.endsWith('.0') ? value.slice(0, -2) : value; -} - -function formatGitStatus(status: FooterGitStatus | null): string | null { - if (status === null) return null; - const branch = normalizeSingleLine(status.branch); - if (branch.length === 0) return null; - const details: string[] = []; - const diff: string[] = []; - if (status.diffAdded > 0) - diff.push(`+${String(nonNegativeInteger(status.diffAdded))}`); - if (status.diffDeleted > 0) - diff.push(`-${String(nonNegativeInteger(status.diffDeleted))}`); - if (diff.length > 0) details.push(diff.join(' ')); - else if (status.dirty) details.push('±'); - let sync = ''; - if (status.ahead > 0) sync += `↑${String(nonNegativeInteger(status.ahead))}`; - if (status.behind > 0) - sync += `↓${String(nonNegativeInteger(status.behind))}`; - if (sync.length > 0) details.push(sync); - const base = details.length === 0 ? branch : `${branch} ${details.join(' ')}`; - return status.pullRequest === null - ? base - : `${base} [PR#${String(nonNegativeInteger(status.pullRequest.number))}]`; -} - -function freezeState(state: FooterState): FooterState { - const todos = Object.freeze( - state.todos.map((todo) => - Object.freeze({ ...todo, title: normalizeSingleLine(todo.title) }), - ), - ); - return Object.freeze({ - ...state, - activity: Object.freeze({ ...state.activity }), - validation: - state.validation === null - ? null - : Object.freeze({ - ...state.validation, - message: normalizeSingleLine(state.validation.message), - }), - queue: Object.freeze({ - ...state.queue, - count: nonNegativeInteger(state.queue.count), - }), - todos, - goal: state.goal === null ? null : Object.freeze({ ...state.goal }), - background: Object.freeze({ - bashTasks: nonNegativeInteger(state.background.bashTasks), - agentTasks: nonNegativeInteger(state.background.agentTasks), - }), - subagents: Object.freeze({ - active: nonNegativeInteger(state.subagents.active), - queued: nonNegativeInteger(state.subagents.queued), - completed: nonNegativeInteger(state.subagents.completed), - failed: nonNegativeInteger(state.subagents.failed), - }), - compaction: Object.freeze({ ...state.compaction }), - update: Object.freeze({ ...state.update }), - btw: Object.freeze({ - ...state.btw, - turnCount: nonNegativeInteger(state.btw.turnCount), - }), - status: Object.freeze({ - ...state.status, - tokenSpeed: - state.status.tokenSpeed !== null && - Number.isFinite(state.status.tokenSpeed) && - state.status.tokenSpeed >= 0 - ? state.status.tokenSpeed - : null, - tokenSpeedEstimated: - state.status.tokenSpeed !== null && - Number.isFinite(state.status.tokenSpeed) && - state.status.tokenSpeed >= 0 && - state.status.tokenSpeedEstimated, - elapsedMs: - state.status.elapsedMs !== null && Number.isFinite(state.status.elapsedMs) - ? Math.max(0, state.status.elapsedMs) - : null, - git: - state.status.git === null - ? null - : Object.freeze({ - ...state.status.git, - pullRequest: - state.status.git.pullRequest === null - ? null - : Object.freeze({ ...state.status.git.pullRequest }), - }), - }), - composer: Object.freeze({ - ...state.composer, - textLength: nonNegativeInteger(state.composer.textLength), - }), - }); -} - -function normalizeSingleLine(value: string): string { - return value.replaceAll(/\s+/gu, ' ').trim(); -} - -function finiteOrZero(value: number): number { - return Number.isFinite(value) ? value : 0; -} - -function nonNegativeInteger(value: number): number { - return Math.max(0, Math.trunc(finiteOrZero(value))); -} - -function clampPercent(value: number): number { - return Math.min(100, Math.max(0, finiteOrZero(value))); -} - -function plural(count: number, noun: string): string { - return nonNegativeInteger(count) === 1 ? noun : `${noun}s`; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/open-tui-composer-port.ts b/apps/pythinker-code/src/tui/runtime/footer/open-tui-composer-port.ts deleted file mode 100644 index 75a9741d..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/open-tui-composer-port.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { - clearComposer, - createComposerState, - deleteBackwardGrapheme, - deleteBackwardWord, - deleteForwardGrapheme, - deleteForwardWord, - detectComposerPrefix, - getComposerText, - historyDown, - historyUp, - insertNewline, - insertText, - moveCursorLeft, - moveCursorLineEnd, - moveCursorLineStart, - moveCursorRight, - moveCursorTextEnd, - moveCursorTextStart, - moveCursorWordLeft, - moveCursorWordRight, - type ComposerPrefix, - type ComposerState, -} from '#/tui/runtime/footer/composer-state'; -import { - defaultKeybindings, - KeybindingResolver, - type ParsedKeybinding, -} from '#/tui/keybindings'; - -export interface ComposerKey { - readonly key: string; - readonly ctrl?: boolean; - readonly shift?: boolean; - readonly alt?: boolean; - readonly super?: boolean; -} - -export type ComposerIntent = - | { readonly kind: 'submit'; readonly text: string } - | { - readonly kind: 'exit-intent'; - readonly source: 'ctrl-c' | 'ctrl-d'; - } - | { readonly kind: 'cancel' } - | { readonly kind: 'steer' } - | { readonly kind: 'cycle-thinking-effort' } - | { readonly kind: 'toggle-expansion' } - | { readonly kind: 'open-external-editor'; readonly text: string } - | { readonly kind: 'changed'; readonly text: string } - | { - readonly kind: 'autocomplete'; - readonly prefix: ComposerPrefix | null; - }; - -export interface ComposerViewState { - readonly marker: string; - readonly text: string; - readonly placeholder: string; - readonly cursorLine: number; - readonly cursorCol: number; - readonly isEmpty: boolean; -} - -export interface ComposerPort { - getText(): string; - setText(text: string): void; - isFocused(): boolean; - focus(): void; - blur(): void; - addToHistory(text: string): void; - handleKey(key: ComposerKey): readonly ComposerIntent[]; - getViewState(): ComposerViewState; -} - -export interface OpenTuiComposerPortOptions { - readonly text?: string; - readonly marker?: string; - readonly placeholder?: string; - readonly bindings?: readonly ParsedKeybinding[]; -} - -export function openTuiKeyId(key: Readonly<ComposerKey>): string { - const modifiers = [ - key.ctrl ? 'ctrl' : undefined, - key.alt ? 'alt' : undefined, - key.shift ? 'shift' : undefined, - key.super ? 'super' : undefined, - ].filter((modifier) => modifier !== undefined); - return [...modifiers, key.key === 'return' ? 'enter' : key.key].join('+'); -} - -const NAMED_KEYS = new Set([ - 'enter', - 'escape', - 'tab', - 'backspace', - 'delete', - 'up', - 'down', - 'left', - 'right', - 'home', - 'end', -]); - -const COMPOSER_ACTIONS: ReadonlySet<string> = new Set([ - 'app:interrupt', - 'app:exit', - 'app:toggleTranscript', - 'chat:cancel', - // 'chat:cycleMode' is deprecated (kept for schema back-compat only; see - // keybindings.ts) but still routed here so a legacy user rebind doesn't - // silently stop working. - 'chat:cycleMode', - 'chat:thinkingToggle', - 'chat:externalEditor', - 'chat:newline', - 'chat:stash', - 'chat:submit', - 'history:next', - 'history:previous', -]); - -function composerBindings( - bindings: readonly ParsedKeybinding[], -): readonly ParsedKeybinding[] { - const winners = new Map<string, ParsedKeybinding>(); - for (const binding of bindings) { - winners.set(`${binding.context}\0${binding.chord.join(' ')}`, binding); - } - return [...winners.values()].filter( - (binding) => - (binding.context === 'Chat' || binding.context === 'Global') && - (binding.action === null || COMPOSER_ACTIONS.has(binding.action)), - ); -} - -export class OpenTuiComposerPort implements ComposerPort { - public pendingExit: 'ctrl-c' | 'ctrl-d' | null = null; - - private state: ComposerState; - private readonly history: string[] = []; - private historyIndex: number | null = null; - private focused = false; - private readonly marker: string; - private readonly placeholder: string; - private readonly keybindings: KeybindingResolver; - - public constructor(options: OpenTuiComposerPortOptions = {}) { - this.state = createComposerState(options.text); - this.marker = options.marker ?? '❯'; - this.placeholder = options.placeholder ?? 'Type a message'; - this.keybindings = new KeybindingResolver( - composerBindings(options.bindings ?? defaultKeybindings()), - ); - } - - public getText(): string { - return getComposerText(this.state); - } - - public setText(text: string): void { - this.state = createComposerState(text); - this.historyIndex = null; - } - - public isFocused(): boolean { - return this.focused; - } - - public focus(): void { - this.focused = true; - } - - public blur(): void { - this.focused = false; - } - - public addToHistory(text: string): void { - this.history.push(text); - this.historyIndex = null; - } - - public clearPendingExit(): void { - this.pendingExit = null; - } - - public getViewState(): ComposerViewState { - const text = this.getText(); - return { - marker: this.marker, - text, - placeholder: this.placeholder, - cursorLine: this.state.cursorLine, - cursorCol: this.state.cursorCol, - isEmpty: text.length === 0, - }; - } - - public handleKey(key: ComposerKey): readonly ComposerIntent[] { - const normalizedKey = key.key === 'return' ? { ...key, key: 'enter' } : key; - const exitSource = this.getExitSource(normalizedKey); - const matchingSecondExit = - exitSource !== null && this.pendingExit === exitSource; - if (!matchingSecondExit) { - this.clearPendingExit(); - } - - const configured = this.handleConfiguredKey(normalizedKey); - if (configured !== undefined) return configured; - - if (exitSource !== null) { - return this.handleExit(exitSource); - } - - if (normalizedKey.key === 'escape') { - return [{ kind: 'cancel' }]; - } - if (normalizedKey.ctrl && normalizedKey.key === 's') { - return [{ kind: 'steer' }]; - } - if ( - normalizedKey.shift && - !normalizedKey.ctrl && - !normalizedKey.alt && - normalizedKey.key === 'tab' - ) { - return [{ kind: 'cycle-thinking-effort' }]; - } - if (normalizedKey.ctrl && normalizedKey.key === 'o') { - return [{ kind: 'toggle-expansion' }]; - } - if (normalizedKey.ctrl && normalizedKey.key === 'g') { - return [{ kind: 'open-external-editor', text: this.getText() }]; - } - if (normalizedKey.key === 'enter') { - return this.handleEnter(normalizedKey); - } - if (normalizedKey.key === 'up' || normalizedKey.key === 'down') { - return this.handleHistory(normalizedKey.key); - } - - const previousText = this.getText(); - this.handleEditingKey(normalizedKey); - return this.textChangeIntents(previousText); - } - - private handleConfiguredKey( - key: ComposerKey, - ): readonly ComposerIntent[] | undefined { - let intents: readonly ComposerIntent[] | undefined; - const handled = this.keybindings.dispatchKeyId( - openTuiKeyId(key), - ['Chat', 'Global'], - { - 'app:interrupt': () => { - intents = this.handleExit('ctrl-c'); - }, - 'app:exit': () => { - intents = this.handleExit('ctrl-d'); - }, - 'app:toggleTranscript': () => { - intents = [{ kind: 'toggle-expansion' }]; - }, - 'chat:cancel': () => { - intents = [{ kind: 'cancel' }]; - }, - // Deprecated action kept for schema back-compat; treat as thinkingToggle. - 'chat:cycleMode': () => { - intents = [{ kind: 'cycle-thinking-effort' }]; - }, - 'chat:thinkingToggle': () => { - intents = [{ kind: 'cycle-thinking-effort' }]; - }, - 'chat:externalEditor': () => { - intents = [{ kind: 'open-external-editor', text: this.getText() }]; - }, - 'chat:newline': () => { - intents = this.applyTextEdit(insertNewline); - }, - 'chat:stash': () => { - intents = [{ kind: 'steer' }]; - }, - 'chat:submit': () => { - intents = this.handleEnter({ ...key, shift: false }); - }, - 'history:next': () => { - intents = this.handleHistory('down'); - }, - 'history:previous': () => { - intents = this.handleHistory('up'); - }, - }, - ); - return handled ? intents ?? [] : undefined; - } - - private getExitSource( - key: ComposerKey, - ): 'ctrl-c' | 'ctrl-d' | null { - if (!key.ctrl) return null; - if (key.key === 'c') return 'ctrl-c'; - if (key.key === 'd') return 'ctrl-d'; - return null; - } - - private handleExit( - source: 'ctrl-c' | 'ctrl-d', - ): readonly ComposerIntent[] { - const intents: ComposerIntent[] = []; - if (source === 'ctrl-c' && this.pendingExit !== source) { - const previousText = this.getText(); - this.state = clearComposer(this.state); - intents.push(...this.textChangeIntents(previousText)); - } - this.pendingExit = source; - intents.push({ kind: 'exit-intent', source }); - return intents; - } - - private handleEnter(key: ComposerKey): readonly ComposerIntent[] { - if (key.shift) { - return this.applyTextEdit(insertNewline); - } - - const line = this.state.lines[this.state.cursorLine] ?? ''; - if (line[this.state.cursorCol - 1] === '\\') { - const previousText = this.getText(); - this.state = deleteBackwardGrapheme(this.state); - this.state = insertNewline(this.state); - return this.textChangeIntents(previousText); - } - - const text = this.getText(); - if (text.length === 0) return []; - this.state = clearComposer(this.state); - this.historyIndex = null; - return [{ kind: 'submit', text }, ...this.changeIntents()]; - } - - private handleHistory( - direction: 'up' | 'down', - ): readonly ComposerIntent[] { - const previousState = this.state; - const result = - direction === 'up' - ? historyUp(this.state, this.history, this.historyIndex) - : historyDown(this.state, this.history, this.historyIndex); - this.state = result.state; - this.historyIndex = result.historyIndex; - if (this.state === previousState) return []; - return this.changeIntents(); - } - - private handleEditingKey(key: ComposerKey): void { - switch (key.key) { - case 'left': - this.state = - key.ctrl || key.alt - ? moveCursorWordLeft(this.state) - : moveCursorLeft(this.state); - return; - case 'right': - this.state = - key.ctrl || key.alt - ? moveCursorWordRight(this.state) - : moveCursorRight(this.state); - return; - case 'home': - this.state = - key.ctrl || key.alt - ? moveCursorTextStart(this.state) - : moveCursorLineStart(this.state); - return; - case 'end': - this.state = - key.ctrl || key.alt - ? moveCursorTextEnd(this.state) - : moveCursorLineEnd(this.state); - return; - case 'backspace': - this.state = - key.ctrl || key.alt - ? deleteBackwardWord(this.state) - : deleteBackwardGrapheme(this.state); - return; - case 'delete': - this.state = - key.ctrl || key.alt - ? deleteForwardWord(this.state) - : deleteForwardGrapheme(this.state); - return; - default: - if (this.isPrintable(key)) { - this.state = insertText(this.state, key.key); - } - } - } - - private isPrintable(key: ComposerKey): boolean { - return ( - !key.ctrl && - !key.alt && - !key.super && - key.key.length > 0 && - !NAMED_KEYS.has(key.key) && - !/[\r\n]/u.test(key.key) - ); - } - - private applyTextEdit( - edit: (state: ComposerState) => ComposerState, - ): readonly ComposerIntent[] { - const previousText = this.getText(); - this.state = edit(this.state); - return this.textChangeIntents(previousText); - } - - private textChangeIntents( - previousText: string, - ): readonly ComposerIntent[] { - if (this.getText() === previousText) return []; - return this.changeIntents(); - } - - private changeIntents(): readonly ComposerIntent[] { - return [ - { kind: 'changed', text: this.getText() }, - { - kind: 'autocomplete', - prefix: detectComposerPrefix(this.state), - }, - ]; - } -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/split-footer-view.tsx b/apps/pythinker-code/src/tui/runtime/footer/split-footer-view.tsx deleted file mode 100644 index 8d821eae..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/split-footer-view.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { For, type Accessor } from 'solid-js'; - -import { ActivityRow } from './activity-row'; -import { Composer } from './composer'; -import { truncateToWidth } from './text-layout'; -import { formatStatusRow, type FooterViewModel, type FooterViewModelRow } from './footer-model'; -import type { ComposerPort } from './open-tui-composer-port'; -import { StatusRow } from './status-row'; - -export interface SplitFooterViewProps { - readonly composerPort: ComposerPort; - readonly composerRevision: Accessor<number>; - readonly viewModel: FooterViewModel; - readonly width: number; -} - -/** - * Pure non-interactive rows are used for headless tests because the CLI - * renderer owns terminal streams and lifecycle state. The Solid view consumes - * the same activity, validation, and status rows while mounting the live - * composer from its port. - */ -export function renderFooterRows( - viewModel: FooterViewModel, - width: number, -): readonly string[] { - const safeWidth = Math.max(0, Math.trunc(Number.isFinite(width) ? width : 0)); - return Object.freeze( - viewModel.rows.map((row) => truncateToWidth(renderFooterRow(row), safeWidth)), - ); -} - -export function SplitFooterView(props: SplitFooterViewProps) { - const rows = () => renderFooterRows(props.viewModel, props.width); - return ( - <box - flexDirection='column' - flexShrink={0} - height={rows().length} - width={props.width} - overflow='hidden' - > - <For each={props.viewModel.rows}> - {(row, index) => ( - <FooterRow - composerPort={props.composerPort} - composerRevision={props.composerRevision} - renderedText={rows()[index()] ?? ''} - row={row} - width={props.width} - /> - )} - </For> - </box> - ); -} - -function FooterRow(props: { - readonly composerPort: ComposerPort; - readonly composerRevision: Accessor<number>; - readonly row: FooterViewModelRow; - readonly renderedText: string; - readonly width: number; -}) { - switch (props.row.kind) { - case 'activity': - return <ActivityRow model={props.row} renderedText={props.renderedText} />; - case 'composer': - return ( - <Composer - port={props.composerPort} - revision={props.composerRevision} - width={props.width} - /> - ); - case 'status': - return <StatusRow model={props.row} renderedText={props.renderedText} />; - case 'validation': - return <text height={1}>{props.renderedText}</text>; - } -} - -function renderFooterRow(row: FooterViewModelRow): string { - switch (row.kind) { - case 'activity': - return joinSections(row.primary, row.indicators); - case 'composer': - return `${row.slot.marker} [${row.slot.placeholder}]`; - case 'status': - return formatStatusRow(row.items); - case 'validation': - return row.level === 'info' ? row.message : `${row.level}: ${row.message}`; - } -} - -function joinSections(primary: string, indicators: readonly string[]): string { - if (primary.length === 0) return indicators.join(' '); - if (indicators.length === 0) return primary; - return `${primary} ${indicators.join(' ')}`; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/status-row.tsx b/apps/pythinker-code/src/tui/runtime/footer/status-row.tsx deleted file mode 100644 index 7730b4e2..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/status-row.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { currentTheme } from '#/tui/theme'; - -import type { FooterStatusRowViewModel } from './footer-model'; - -export interface StatusRowProps { - readonly model: FooterStatusRowViewModel; - /** Produced by renderFooterRows, the single source of bounded row layout. */ - readonly renderedText: string; -} - -export function StatusRow(props: StatusRowProps) { - const color = - props.model.emphasis === 'danger' - ? currentTheme.palette.error - : currentTheme.palette.textDim; - return <text fg={color} height={1}>{props.renderedText}</text>; -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/text-layout.ts b/apps/pythinker-code/src/tui/runtime/footer/text-layout.ts deleted file mode 100644 index e4569ef2..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/text-layout.ts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Display-width helpers shared by the split-footer rows, composer, and dialogs. - * - * The footer's fixed four-row height depends on truncation measuring terminal - * cells rather than code units. A differential test keeps this OpenTUI copy in - * parity with the legacy pi-tui renderer while that renderer still exists. - */ - -const ANSI_RESET = '\u001B[0m'; -const ELLIPSIS = '…'; -const TAB_WIDTH = 3; -const VISIBLE_WIDTH_MEMO_CAP = 1_000; - -const graphemeSegmenter = new Intl.Segmenter(undefined, { - granularity: 'grapheme', -}); -const visibleWidthMemo = new Map<string, number>(); - -const ANSI_PATTERN = - /\u001B\[[0-?]*[ -/]*[@-~]|\u001B\][^\u0007]*?(?:\u0007|\u001B\\)/gu; - -export function truncateToWidth(value: string, width: number): string { - if (width <= 0) return ''; - const normalized = value.replaceAll(/[\r\n]+/gu, ' '); - if (normalized.length === 0) return ''; - - const valueWidth = visibleWidth(normalized); - const ellipsisWidth = visibleWidth(ELLIPSIS); - const containsAnsi = stripAnsi(normalized) !== normalized; - if (ellipsisWidth >= width) { - if (valueWidth <= width) return normalized; - return containsAnsi - ? `${ANSI_RESET}${ELLIPSIS}${ANSI_RESET}` - : ELLIPSIS; - } - if (valueWidth <= width) return normalized; - - const prefix = takeWidth(normalized, width - ellipsisWidth); - return containsAnsi - ? `${prefix}${ANSI_RESET}${ELLIPSIS}${ANSI_RESET}` - : prefix + ELLIPSIS; -} - -function takeWidth(value: string, width: number): string { - let result = ''; - let used = 0; - let index = 0; - let pendingAnsi = ''; - - while (index < value.length) { - const ansi = readAnsi(value, index); - if (ansi !== undefined) { - pendingAnsi += ansi.sequence; - index = ansi.end; - continue; - } - - let plainEnd = index + 1; - while (plainEnd < value.length && value[plainEnd] !== '\u001B') { - plainEnd += 1; - } - - for (const { segment } of graphemeSegmenter.segment( - value.slice(index, plainEnd), - )) { - const segmentWidth = graphemeWidth(segment); - if (used + segmentWidth > width) return result; - result += pendingAnsi + segment; - pendingAnsi = ''; - used += segmentWidth; - } - index = plainEnd; - } - - return result; -} - -export function visibleWidth(value: string): number { - const cached = visibleWidthMemo.get(value); - if (cached !== undefined) return cached; - - const stripped = stripAnsi(value); - let width = 0; - for (const { segment } of graphemeSegmenter.segment(stripped)) { - width += graphemeWidth(segment); - } - - visibleWidthMemo.set(value, width); - if (visibleWidthMemo.size > VISIBLE_WIDTH_MEMO_CAP) { - visibleWidthMemo.clear(); - } - - return width; -} - -function stripAnsi(value: string): string { - return value.replaceAll(ANSI_PATTERN, ''); -} - -function readAnsi( - value: string, - index: number, -): { sequence: string; end: number } | undefined { - if (value[index] !== '\u001B') return undefined; - - if (value[index + 1] === '[') { - for (let end = index + 2; end < value.length; end += 1) { - const codePoint = value.codePointAt(end); - if ( - codePoint !== undefined && - codePoint >= 0x40 && - codePoint <= 0x7e - ) { - return { - sequence: value.slice(index, end + 1), - end: end + 1, - }; - } - } - return undefined; - } - - if (value[index + 1] === ']') { - for (let end = index + 2; end < value.length; end += 1) { - if (value[end] === '\u0007') { - return { - sequence: value.slice(index, end + 1), - end: end + 1, - }; - } - if (value[end] === '\u001B' && value[end + 1] === '\\') { - return { - sequence: value.slice(index, end + 2), - end: end + 2, - }; - } - } - } - - return undefined; -} - -function graphemeWidth(segment: string): number { - // pi-tui expands every visible tab to three cells regardless of its column. - if (segment === '\t') return TAB_WIDTH; - - let width = 0; - let baseWidth = 0; - let regionalIndicators = 0; - let hasJoinedEmoji = false; - - for (const char of segment) { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) continue; - - if (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) { - regionalIndicators += 1; - } - if ( - codePoint === 0x200d || - (codePoint >= 0x1f3fb && codePoint <= 0x1f3ff) - ) { - hasJoinedEmoji = true; - continue; - } - - const charWidth = codePointWidth(char); - if (baseWidth === 0 && charWidth > 0) baseWidth = charWidth; - width += charWidth; - } - - if (regionalIndicators === 2) return 2; - return hasJoinedEmoji ? baseWidth : width; -} - -function codePointWidth(char: string): number { - const codePoint = char.codePointAt(0); - if (codePoint === undefined) return 0; - if (codePoint === 0x200d || (codePoint >= 0x300 && codePoint <= 0x36f)) - return 0; - return isWideCodePoint(codePoint) ? 2 : 1; -} - -function isWideCodePoint(codePoint: number): boolean { - return ( - codePoint >= 0x1100 && - (codePoint <= 0x115f || - codePoint === 0x2329 || - codePoint === 0x232a || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) || - (codePoint >= 0x1f300 && codePoint <= 0x1faff)) - ); -} diff --git a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts deleted file mode 100644 index 6ad8a491..00000000 --- a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { gt, valid } from 'semver'; - -import { isBelowMinRequiredVersion } from '#/cli/update/cdn'; -import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; -import type { - InstallSource, - UpdateCache, - UpdateInstallState, -} from '#/cli/update/types'; -import type { FooterUpdate } from './footer-model'; - -/** - * Map the persisted update state onto the footer update slice. - * - * Precedence, highest first: - * 1. an active install for a newer version that is downloading or waiting - * 2. a recorded success for a newer version (needs a restart to apply) - * 3. a recorded failure for a newer version - * 4. an installable target advertised by the update cache - * 5. nothing - * - * Pure: no file reads, no clock, no `process.*` — everything comes in as - * arguments so the poller stays off the render path. - */ -export function footerUpdateFromState( - currentVersion: string, - source: InstallSource, - cache: UpdateCache | null, - installState: UpdateInstallState, -): FooterUpdate { - const active = installState.active; - const progress = active?.progress; - if ( - active !== null && - progress !== undefined && - isNewer(active.version, currentVersion) && - (progress.state === 'downloading' || progress.state === 'waiting') - ) { - return { - version: active.version, - state: progress.state, - percent: progress.percent ?? null, - }; - } - - const success = installState.lastSuccess; - if (success !== null && isNewer(success.version, currentVersion)) { - return { version: success.version, state: 'ready', percent: null }; - } - - const failure = installState.lastFailure; - if (failure !== null && isNewer(failure.version, currentVersion)) { - return { version: failure.version, state: 'failed', percent: null }; - } - - const target = selectUpdateTarget(currentVersion, cache?.latest ?? null); - if (target !== null && isTargetInstallable(source, cache?.manifest ?? null)) { - // A manifest floor above the running version labels the offer as - // required; the precedence order above still lets a download in flight - // outrank it. - return { - version: target.version, - state: isBelowMinRequiredVersion(cache?.manifest ?? null, currentVersion) - ? 'required' - : 'available', - percent: null, - }; - } - - return { version: null, state: null, percent: null }; -} - -function isNewer(version: string, currentVersion: string): boolean { - if (valid(version) === null || valid(currentVersion) === null) return false; - return gt(version, currentVersion); -} diff --git a/apps/pythinker-code/src/tui/runtime/legacy-pi-presentation.ts b/apps/pythinker-code/src/tui/runtime/legacy-pi-presentation.ts deleted file mode 100644 index 20d8ec03..00000000 --- a/apps/pythinker-code/src/tui/runtime/legacy-pi-presentation.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { TUIState } from '../tui-state'; -import type { TuiPresentation } from './contracts'; -import type { FooterViewModel } from './footer/footer-model'; - -export class LegacyPiPresentation implements TuiPresentation { - constructor(private readonly state: TUIState) {} - - start(_onResize: () => void): void { - this.state.ui.start(); - } - - stop(): void { - this.state.ui.stop(); - } - - drainInput(): Promise<void> { - return this.state.terminal.drainInput(); - } - - setTerminalTitle(title: string): void { - this.state.terminal.setTitle(title); - } - - setTerminalProgress(active: boolean): void { - this.state.terminal.setProgress(active); - } - - writeTerminalControl(sequence: string): void { - this.state.terminal.write(sequence); - } - - getComposerText(): string { - return this.state.editor.getText(); - } - - setComposerText(text: string): void { - this.state.editor.setText(text); - } - - focusComposer(): void { - this.state.ui.setFocus(this.state.editor); - } - - addComposerHistory(text: string): void { - this.state.editor.addToHistory(text); - } - - updateFooter(viewModel: FooterViewModel): void { - this.state.footer.setViewModel(viewModel); - this.state.ui.requestRender(); - } - - notifyIdle(): void { - this.state.ui.requestRender(); - } -} diff --git a/apps/pythinker-code/src/tui/runtime/open-tui-lifecycle.ts b/apps/pythinker-code/src/tui/runtime/open-tui-lifecycle.ts deleted file mode 100644 index ff6f0a5d..00000000 --- a/apps/pythinker-code/src/tui/runtime/open-tui-lifecycle.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { - createCliRenderer, - type CliRendererConfig, - type ExternalOutputMode, - type ScreenMode, -} from '@opentui/core'; - -type OutputWrite = NodeJS.WriteStream['write']; -type WriteCallback = (error?: Error | null) => void; - -interface CapturedWrite { - chunk: string | Uint8Array; - encoding: BufferEncoding | undefined; - callback: WriteCallback | undefined; -} - -export interface OpenTuiLifecycleRenderer { - readonly width: number; - externalOutputMode: ExternalOutputMode; - screenMode: ScreenMode; - footerHeight: number; - on(event: 'resize', listener: () => void): unknown; - off(event: 'resize', listener: () => void): unknown; - requestRender(): void; - idle(): Promise<void>; - destroy(): void; -} - -export interface OpenTuiRetainedSurface { - invalidate(): void; - close(): void; -} - -export type OpenTuiRendererFactory = ( - config: CliRendererConfig, -) => OpenTuiLifecycleRenderer | Promise<OpenTuiLifecycleRenderer>; - -export interface OpenTuiLifecycleOptions { - stdin?: NodeJS.ReadStream; - stdout?: NodeJS.WriteStream; - stderr?: NodeJS.WriteStream; - rendererFactory?: OpenTuiRendererFactory; - footerFactory?: ( - renderer: OpenTuiLifecycleRenderer, - ) => OpenTuiRetainedSurface | Promise<OpenTuiRetainedSurface>; -} - -type LifecycleState = 'idle' | 'starting' | 'running' | 'stopped'; - -const OPEN_TUI_RENDERER_CONFIG = { - screenMode: 'split-footer', - footerHeight: 2, - externalOutputMode: 'capture-stdout', - targetFps: 30, - maxFps: 60, - useMouse: false, - enableMouseMovement: false, - exitOnCtrlC: false, - exitSignals: [], - consoleMode: 'disabled', - openConsoleOnError: false, - clearOnShutdown: false, -} as const satisfies CliRendererConfig; - -function defaultFooter(renderer: OpenTuiLifecycleRenderer): OpenTuiRetainedSurface { - return { - invalidate: () => { - renderer.requestRender(); - }, - close: () => {}, - }; -} - -export class OpenTuiLifecycle { - private readonly stdin: NodeJS.ReadStream; - private readonly stdout: NodeJS.WriteStream; - private readonly stderr: NodeJS.WriteStream; - private readonly rendererFactory: OpenTuiRendererFactory; - private readonly footerFactory: NonNullable<OpenTuiLifecycleOptions['footerFactory']>; - - private state: LifecycleState = 'idle'; - private stopRequested = false; - private renderer: OpenTuiLifecycleRenderer | undefined; - private footer: OpenTuiRetainedSurface | undefined; - private activeSurface: OpenTuiRetainedSurface | undefined; - private resizeHandler: (() => void) | undefined; - private capturedWrites: CapturedWrite[] = []; - private stdoutWrite: OutputWrite | undefined; - private stderrWrite: OutputWrite | undefined; - private outputCaptureInstalled = false; - - constructor(options: OpenTuiLifecycleOptions = {}) { - this.stdin = options.stdin ?? process.stdin; - this.stdout = options.stdout ?? process.stdout; - this.stderr = options.stderr ?? process.stderr; - this.rendererFactory = options.rendererFactory ?? createCliRenderer; - this.footerFactory = options.footerFactory ?? defaultFooter; - } - - async start(onResize: () => void): Promise<void> { - if (this.state === 'running') return; - if (this.state !== 'idle') { - throw new Error(`OpenTUI lifecycle cannot start from ${this.state} state.`); - } - - this.state = 'starting'; - try { - const renderer = await this.rendererFactory({ - ...OPEN_TUI_RENDERER_CONFIG, - stdin: this.stdin, - stdout: this.stdout, - }); - this.renderer = renderer; - - if (this.stopRequested) { - this.shutdown(); - return; - } - - this.installOutputCapture(); - this.footer = await this.footerFactory(renderer); - - if (this.stopRequested) { - this.shutdown(); - return; - } - - this.resizeHandler = () => { - this.footer?.invalidate(); - this.activeSurface?.invalidate(); - onResize(); - }; - renderer.on('resize', this.resizeHandler); - this.state = 'running'; - } catch (error) { - this.rollbackStart(); - throw error; - } - } - - stop(): void { - if (this.state === 'starting') { - this.stopRequested = true; - return; - } - if (this.state !== 'running') return; - this.shutdown(); - } - - setActiveSurface(surface: OpenTuiRetainedSurface | undefined): void { - this.activeSurface = surface; - } - - commitCapturedOutput(): void { - const writes = this.capturedWrites; - this.capturedWrites = []; - for (const entry of writes) { - this.flushWrite(entry); - } - } - - writeStdout(sequence: string): void { - this.stdout.write(sequence); - this.commitCapturedOutput(); - } - - writeStderr(sequence: string): void { - this.stderr.write(sequence); - this.commitCapturedOutput(); - } - - requestRender(): void { - this.renderer?.requestRender(); - } - - setFooterHeight(height: number): void { - const renderer = this.renderer; - if (renderer === undefined) return; - renderer.footerHeight = Math.max(0, Math.trunc(Number.isFinite(height) ? height : 0)); - this.footer?.invalidate(); - } - - async drainInput(): Promise<void> { - await this.renderer?.idle(); - } - - private installOutputCapture(): void { - if (this.outputCaptureInstalled) return; - this.stdoutWrite = this.stdout.write.bind(this.stdout); - this.stderrWrite = this.stderr.write.bind(this.stderr); - this.stdout.write = this.captureWrite(); - this.stderr.write = this.captureWrite(); - this.outputCaptureInstalled = true; - } - - private captureWrite(): OutputWrite { - return (( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | WriteCallback, - callback?: WriteCallback, - ): boolean => { - const encoding = - typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined; - const resolvedCallback = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - this.capturedWrites.push({ - chunk, - encoding, - callback: resolvedCallback, - }); - return true; - }) as OutputWrite; - } - - private flushWrite(entry: CapturedWrite): void { - const write = this.stdoutWrite; - if (write === undefined) return; - if (entry.encoding !== undefined) { - write(entry.chunk, entry.encoding, entry.callback); - return; - } - if (entry.callback !== undefined) { - write(entry.chunk, entry.callback); - return; - } - write(entry.chunk); - } - - private rollbackStart(): void { - try { - this.shutdown(); - } catch { - // Preserve the startup error after attempting every restoration step. - } - } - - private shutdown(): void { - if (this.state === 'stopped') return; - this.state = 'stopped'; - - const renderer = this.renderer; - const errors: unknown[] = []; - const attempt = (operation: () => void): void => { - try { - operation(); - } catch (error) { - errors.push(error); - } - }; - - const resizeHandler = this.resizeHandler; - if (renderer !== undefined && resizeHandler !== undefined) { - attempt(() => { - renderer.off('resize', resizeHandler); - }); - } - this.resizeHandler = undefined; - - // The order below is the terminal restoration contract. Do not reorder it. - attempt(() => { - this.activeSurface?.close(); - }); - this.activeSurface = undefined; - - attempt(() => { - this.commitCapturedOutput(); - }); - - attempt(() => { - this.footer?.close(); - }); - this.footer = undefined; - - attempt(() => { - this.restoreOutputPassthrough(); - }); - attempt(() => { - if (renderer !== undefined) renderer.externalOutputMode = 'passthrough'; - }); - - attempt(() => { - if (renderer !== undefined) { - renderer.screenMode = 'main-screen'; - } - }); - - attempt(() => { - renderer?.destroy(); - }); - this.renderer = undefined; - - if (errors.length > 0) { - throw errors[0]; - } - } - - private restoreOutputPassthrough(): void { - if (!this.outputCaptureInstalled) return; - if (this.stdoutWrite !== undefined) { - this.stdout.write = this.stdoutWrite; - } - if (this.stderrWrite !== undefined) { - this.stderr.write = this.stderrWrite; - } - this.stdoutWrite = undefined; - this.stderrWrite = undefined; - this.outputCaptureInstalled = false; - } -} diff --git a/apps/pythinker-code/src/tui/runtime/open-tui-presentation.tsx b/apps/pythinker-code/src/tui/runtime/open-tui-presentation.tsx deleted file mode 100644 index 3cea43dc..00000000 --- a/apps/pythinker-code/src/tui/runtime/open-tui-presentation.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import type { CliRenderer } from '@opentui/core'; -import { _render, RendererContext } from '@opentui/solid'; -import { createSignal, type Accessor, type Setter } from 'solid-js'; - -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - selectFooterViewModel, - type FooterViewModel, -} from './footer/footer-model'; -import { OpenTuiComposerPort } from './footer/open-tui-composer-port'; -import { SplitFooterView } from './footer/split-footer-view'; -import type { - OpenTuiLifecycleOptions, - OpenTuiLifecycleRenderer, - OpenTuiRetainedSurface, -} from './open-tui-lifecycle'; -import { OpenTuiLifecycle } from './open-tui-lifecycle'; -import type { TuiPresentation } from './contracts'; - -const TERMINAL_PROGRESS_ACTIVE = '\u001B]9;4;3\u0007'; -const TERMINAL_PROGRESS_CLEAR = '\u001B]9;4;0;\u0007'; - -export class OpenTuiPresentation implements TuiPresentation { - readonly composerHistory: string[] = []; - composerFocused = false; - - private readonly lifecycle: OpenTuiLifecycle; - private readonly composerPort = new OpenTuiComposerPort(); - private readonly composerRevision: Accessor<number>; - private readonly setComposerRevision: Setter<number>; - private readonly footerViewModel: Accessor<FooterViewModel>; - private readonly setFooterViewModel: Setter<FooterViewModel>; - private startPromise: Promise<void> = Promise.resolve(); - - constructor(options: OpenTuiLifecycleOptions = {}) { - const [composerRevision, setComposerRevision] = createSignal(0); - const [footerViewModel, setFooterViewModel] = createSignal( - selectFooterViewModel( - createFooterState(), - Date.now(), - DEFAULT_STATUS_LINE_CONFIG, - ), - ); - this.composerRevision = composerRevision; - this.setComposerRevision = setComposerRevision; - this.footerViewModel = footerViewModel; - this.setFooterViewModel = setFooterViewModel; - const { footerFactory, ...lifecycleOptions } = options; - this.lifecycle = new OpenTuiLifecycle({ - ...lifecycleOptions, - footerFactory: footerFactory ?? ((renderer) => this.createFooterSurface(renderer)), - }); - } - - start(onResize: () => void): void { - this.startPromise = this.lifecycle.start(onResize); - void this.startPromise.catch(() => { - // The synchronous presentation seam cannot surface async renderer setup. - // Callers that need startup status use ready(). - }); - } - - ready(): Promise<void> { - return this.startPromise; - } - - stop(): void { - this.lifecycle.stop(); - } - - drainInput(): Promise<void> { - return this.lifecycle.drainInput(); - } - - setTerminalTitle(title: string): void { - this.lifecycle.writeStdout(`\u001B]0;${title}\u0007`); - } - - setTerminalProgress(active: boolean): void { - this.lifecycle.writeStdout(active ? TERMINAL_PROGRESS_ACTIVE : TERMINAL_PROGRESS_CLEAR); - } - - writeTerminalControl(sequence: string): void { - this.lifecycle.writeStdout(sequence); - } - - getComposerText(): string { - return this.composerPort.getText(); - } - - setComposerText(text: string): void { - this.composerPort.setText(text); - this.invalidateComposer(); - } - - focusComposer(): void { - this.composerFocused = true; - this.composerPort.focus(); - this.invalidateComposer(); - } - - addComposerHistory(text: string): void { - this.composerHistory.push(text); - this.composerPort.addToHistory(text); - this.invalidateComposer(); - } - - updateFooter(viewModel: FooterViewModel): void { - this.setFooterViewModel(() => viewModel); - this.lifecycle.setFooterHeight(viewModel.rows.length); - } - - notifyIdle(): void { - this.lifecycle.requestRender(); - } - - setActiveSurface(surface: OpenTuiRetainedSurface | undefined): void { - this.lifecycle.setActiveSurface(surface); - } - - private createFooterSurface(renderer: OpenTuiLifecycleRenderer): OpenTuiRetainedSurface { - const solidRenderer = renderer as unknown as CliRenderer; - const [width, setWidth] = createSignal(renderer.width); - let closed = false; - const dispose = _render( - () => ( - <RendererContext.Provider value={solidRenderer}> - <SplitFooterView - composerPort={this.composerPort} - composerRevision={this.composerRevision} - viewModel={this.footerViewModel()} - width={width()} - /> - </RendererContext.Provider> - ), - solidRenderer.root, - ); - const invalidate = (): void => { - if (closed) return; - setWidth(renderer.width); - renderer.footerHeight = this.footerViewModel().rows.length; - renderer.requestRender(); - }; - - invalidate(); - return { - invalidate, - close: () => { - if (closed) return; - closed = true; - dispose(); - }, - }; - } - - private invalidateComposer(): void { - this.setComposerRevision((revision) => revision + 1); - } -} diff --git a/apps/pythinker-code/src/tui/runtime/open-tui-probe.tsx b/apps/pythinker-code/src/tui/runtime/open-tui-probe.tsx deleted file mode 100644 index 91930638..00000000 --- a/apps/pythinker-code/src/tui/runtime/open-tui-probe.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { createCliRenderer } from '@opentui/core'; -import { render } from '@opentui/solid'; -import { createSignal } from 'solid-js'; - -const decoder = new TextDecoder(); - -export async function runOpenTuiProbe(): Promise<void> { - const [value, setValue] = createSignal('BEFORE'); - const renderer = await createCliRenderer({ - remote: !process.stdout.isTTY, - exitOnCtrlC: false, - exitSignals: [], - useMouse: false, - useKittyKeyboard: null, - clearOnShutdown: true, - width: 40, - height: 4, - }); - - try { - await render( - () => ( - <box flexDirection="column"> - <text>Pythinker Code</text> - <text>OpenTUI renderer</text> - <text>Solid JSX</text> - <text>{value()}</text> - </box> - ), - renderer, - ); - await renderer.idle(); - const initialFrame = decoder.decode(renderer.currentRenderBuffer.getRealCharBytes(true)); - if (!initialFrame.includes('BEFORE')) { - throw new Error('initial frame did not include BEFORE'); - } - - setValue('AFTER'); - await renderer.idle(); - const updatedFrame = decoder.decode(renderer.currentRenderBuffer.getRealCharBytes(true)); - if (!updatedFrame.includes('AFTER') || updatedFrame.includes('BEFORE')) { - throw new Error('updated frame did not replace BEFORE with AFTER'); - } - - process.stdout.write('OpenTUI reactive smoke passed: BEFORE -> AFTER\n'); - } finally { - renderer.destroy(); - } -} diff --git a/apps/pythinker-code/src/tui/runtime/scrollback/retained-surface.ts b/apps/pythinker-code/src/tui/runtime/scrollback/retained-surface.ts deleted file mode 100644 index b5d9cdfc..00000000 --- a/apps/pythinker-code/src/tui/runtime/scrollback/retained-surface.ts +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Splits accumulated streamed text into immutable chunks and a retained tail. - */ - -export type SurfaceMode = 'plain' | 'markdown'; - -/** - * Tracks monotonically growing text and exposes only complete, stable chunks. - */ -export class RetainedSurface { - private readonly mode: SurfaceMode; - private committed = ''; - private pending = ''; - - /** - * Creates a retained surface using line or Markdown block chunking. - * - * @param mode - The rules used to identify stable chunks. - */ - constructor(mode: SurfaceMode) { - this.mode = mode; - } - - /** - * Accepts a strict prefix-extension of the accumulated text. - * - * @param fullText - The complete accumulated text, rather than a delta. - * @returns Newly stable chunks in oldest-first order. - */ - accept(fullText: string): readonly string[] { - const currentText = this.committed + this.pending; - if (!fullText.startsWith(currentText) || fullText.length <= currentText.length) { - return []; - } - - this.pending = fullText.slice(this.committed.length); - return this.mode === 'plain' ? this.extractPlainChunks() : this.extractMarkdownChunks(); - } - - /** - * Commits and returns the retained tail, if one exists. - * - * Call this at completion only. Flushing mid-stream commits a partial block, - * so the next `accept` resumes scanning from inside it and would emit the - * remainder as if it were a block of its own. - * - * @returns The retained tail once, or an empty array when no text remains. - */ - flush(): readonly string[] { - if (this.pending.length === 0) { - return []; - } - - const retained = this.pending; - this.committed += retained; - this.pending = ''; - return [retained]; - } - - /** - * Returns text that has not yet formed a stable chunk. - * - * @returns The currently retained trailing text. - */ - retained(): string { - return this.pending; - } - - /** - * Returns all source text consumed by emitted chunks and flushes. - * - * @returns The committed source text, including consumed separators. - */ - committedText(): string { - return this.committed; - } - - private extractPlainChunks(): readonly string[] { - const lastNewline = this.pending.lastIndexOf('\n'); - if (lastNewline < 0) { - return []; - } - - const stable = this.pending.slice(0, lastNewline + 1); - const chunks = stable.slice(0, -1).split('\n'); - this.committed += stable; - this.pending = this.pending.slice(lastNewline + 1); - return chunks; - } - - private extractMarkdownChunks(): readonly string[] { - const chunks: string[] = []; - let blockStart = 0; - let lineStart = 0; - let fenceMarker: '`' | '~' | undefined; - let fenceLength: number | undefined; - - while (lineStart < this.pending.length) { - const newline = this.pending.indexOf('\n', lineStart); - if (newline < 0) { - break; - } - - const line = this.pending.slice(lineStart, newline); - const fence = this.leadingFence(line); - if (fenceLength === undefined) { - if (fence !== undefined && fence.length >= 3) { - fenceMarker = fence.marker; - fenceLength = fence.length; - lineStart = newline + 1; - continue; - } - } else { - if ( - fence !== undefined - && fence.marker === fenceMarker - && fence.length >= fenceLength - ) { - fenceMarker = undefined; - fenceLength = undefined; - } else { - lineStart = newline + 1; - continue; - } - } - - let separatorEnd = newline; - while ( - separatorEnd < this.pending.length - && this.pending.charAt(separatorEnd) === '\n' - ) { - separatorEnd += 1; - } - - if (separatorEnd - newline >= 2) { - chunks.push(this.pending.slice(blockStart, newline)); - blockStart = separatorEnd; - lineStart = separatorEnd; - } else { - lineStart = newline + 1; - } - } - - if (blockStart > 0) { - this.committed += this.pending.slice(0, blockStart); - this.pending = this.pending.slice(blockStart); - } - return chunks; - } - - private leadingFence( - line: string, - ): { marker: '`' | '~'; length: number } | undefined { - let index = 0; - while (index < line.length && line.charAt(index) === ' ') { - index += 1; - } - - const marker = line.charAt(index); - if (marker !== '`' && marker !== '~') { - return undefined; - } - - const fenceStart = index; - while (index < line.length && line.charAt(index) === marker) { - index += 1; - } - return { marker, length: index - fenceStart }; - } -} diff --git a/apps/pythinker-code/src/tui/runtime/scrollback/scrollback-bridge.ts b/apps/pythinker-code/src/tui/runtime/scrollback/scrollback-bridge.ts deleted file mode 100644 index 448c4c07..00000000 --- a/apps/pythinker-code/src/tui/runtime/scrollback/scrollback-bridge.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Joins the transcript lifecycle to terminal scrollback. - * - * Static entries go straight out through the presenter, which guarantees one - * final commit each. Streaming entries accumulate in a retained surface so only - * whole markdown blocks are committed while the text is still growing; the - * incomplete tail stays out of scrollback until the entry completes. - */ - -import { TranscriptPresenter } from '../transcript-presenter'; -import { RetainedSurface, type SurfaceMode } from './retained-surface'; -import { StaticWriter, type ScrollbackSink } from './static-writer'; - -interface StreamingEntry { - readonly surface: RetainedSurface; - chunk: number; -} - -export interface ScrollbackBridgeOptions { - readonly sink: ScrollbackSink; - /** Chunking rules for streamed entries. Defaults to markdown blocks. */ - readonly mode?: SurfaceMode; -} - -export class ScrollbackBridge { - private readonly presenter = new TranscriptPresenter<string>(); - private readonly writer: StaticWriter<string>; - private readonly mode: SurfaceMode; - private readonly streaming = new Map<string, StreamingEntry>(); - - constructor(options: ScrollbackBridgeOptions) { - this.writer = new StaticWriter<string>({ - sink: options.sink, - render: (body) => body, - }); - this.mode = options.mode ?? 'markdown'; - } - - /** Commits a complete entry, such as a user message, in one write. */ - append(entryId: string, text: string, turnId?: string): void { - this.writer.writeAll(this.presenter.append(entryId, text, turnId)); - } - - /** Opens a streaming entry. Emits nothing on its own. */ - begin(entryId: string, turnId?: string): void { - if (this.streaming.has(entryId)) return; - this.presenter.begin(entryId, '', turnId); - this.streaming.set(entryId, { surface: new RetainedSurface(this.mode), chunk: 0 }); - } - - /** - * Feeds the full accumulated text of a streaming entry, committing whichever - * leading blocks have become stable. A stale or shrinking update writes - * nothing. - */ - update(entryId: string, fullText: string): void { - const entry = this.streaming.get(entryId); - if (entry === undefined) return; - this.writeChunks(entryId, entry, entry.surface.accept(fullText)); - } - - /** Flushes a streaming entry's remaining tail and closes it. */ - complete(entryId: string): void { - const entry = this.streaming.get(entryId); - if (entry === undefined) return; - this.writeChunks(entryId, entry, entry.surface.flush()); - this.streaming.delete(entryId); - this.presenter.complete(entryId, ''); - } - - /** Drops all state, for a session reset that clears the screen. */ - reset(): void { - this.streaming.clear(); - this.presenter.reset(); - this.writer.reset(); - } - - private writeChunks(entryId: string, entry: StreamingEntry, chunks: readonly string[]): void { - for (const chunk of chunks) { - this.writer.write({ - key: `${entryId}:chunk:${entry.chunk}`, - entryId, - phase: 'progress', - body: chunk, - }); - entry.chunk += 1; - } - } -} diff --git a/apps/pythinker-code/src/tui/runtime/scrollback/static-writer.ts b/apps/pythinker-code/src/tui/runtime/scrollback/static-writer.ts deleted file mode 100644 index 0c0d5de9..00000000 --- a/apps/pythinker-code/src/tui/runtime/scrollback/static-writer.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Writes transcript commits into terminal scrollback, above the live footer. - * - * With the renderer in `capture-stdout` mode, plain writes to the sink are - * captured and placed above the footer as immutable history. The writer holds - * no view state on purpose: once a commit's text has been written it can never - * be revised, which is the property the whole split-footer design depends on. - */ - -import type { TranscriptCommit } from '../transcript-presenter'; - -/** Receives fully-rendered scrollback text. Normally `process.stdout.write`. */ -export type ScrollbackSink = (text: string) => void; - -export interface StaticWriterOptions<Body> { - readonly sink: ScrollbackSink; - /** Renders a commit body to its final text. Called once per written commit. */ - readonly render: (body: Body) => string; -} - -export class StaticWriter<Body> { - private readonly sink: ScrollbackSink; - private readonly render: (body: Body) => string; - private readonly written = new Set<string>(); - - constructor(options: StaticWriterOptions<Body>) { - this.sink = options.sink; - this.render = options.render; - } - - /** - * Writes a commit to scrollback, ignoring one whose key was already written. - * - * The presenter is already single-emission per key; this guard makes the - * writer safe on its own, so a replayed or re-delivered commit cannot - * duplicate history. - * - * @returns True when the commit was written, false when it was a duplicate. - */ - write(commit: TranscriptCommit<Body>): boolean { - if (this.written.has(commit.key)) { - return false; - } - - this.written.add(commit.key); - this.sink(`${this.render(commit.body)}\n`); - return true; - } - - /** Writes every commit in order, skipping duplicates. */ - writeAll(commits: readonly TranscriptCommit<Body>[]): number { - let count = 0; - for (const commit of commits) { - if (this.write(commit)) { - count += 1; - } - } - - return count; - } - - /** Forgets written keys, for a session reset that clears the screen. */ - reset(): void { - this.written.clear(); - } -} diff --git a/apps/pythinker-code/src/tui/runtime/transcript-presenter.ts b/apps/pythinker-code/src/tui/runtime/transcript-presenter.ts deleted file mode 100644 index 28a1d6c2..00000000 --- a/apps/pythinker-code/src/tui/runtime/transcript-presenter.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Converts transcript entry lifecycle events into deterministic scrollback commits. - */ - -/** The lifecycle phase represented by a transcript commit. */ -export type ScrollbackPhase = 'start' | 'progress' | 'final'; - -/** A single ordered transcript record ready to be committed to scrollback. */ -export interface TranscriptCommit<Body> { - readonly key: string; - readonly entryId: string; - readonly turnId?: string; - readonly phase: ScrollbackPhase; - readonly body: Body; -} - -interface LiveEntry { - readonly status: 'live'; - readonly turnId?: string; - acceptedText: string; - progressCount: number; -} - -interface FinalizedEntry { - readonly status: 'finalized'; -} - -type EntryState = LiveEntry | FinalizedEntry; - -/** - * Tracks transcript entry lifecycles and emits at most one deterministic commit per call. - */ -export class TranscriptPresenter<Body> { - private readonly entries = new Map<string, EntryState>(); - - /** - * Finalizes a previously unseen entry in a single commit. - */ - append( - entryId: string, - body: Body, - turnId?: string, - ): readonly TranscriptCommit<Body>[] { - if (this.entries.has(entryId)) { - return []; - } - - this.entries.set(entryId, { status: 'finalized' }); - return [ - this.createCommit(entryId, `${entryId}:final`, 'final', body, turnId), - ]; - } - - /** - * Starts a previously unseen live entry. - */ - begin( - entryId: string, - body: Body, - turnId?: string, - ): readonly TranscriptCommit<Body>[] { - if (this.entries.has(entryId)) { - return []; - } - - const entry: LiveEntry = turnId === undefined - ? { - status: 'live', - acceptedText: '', - progressCount: 0, - } - : { - status: 'live', - turnId, - acceptedText: '', - progressCount: 0, - }; - this.entries.set(entryId, entry); - return [ - this.createCommit(entryId, `${entryId}:start`, 'start', body, turnId), - ]; - } - - /** - * Accepts a strict prefix-extension and emits only its newly appended suffix. - */ - update( - entryId: string, - fullText: string, - makeBody: (delta: string) => Body, - ): readonly TranscriptCommit<Body>[] { - const entry = this.entries.get(entryId); - if ( - entry?.status !== 'live' - || !fullText.startsWith(entry.acceptedText) - || fullText.length <= entry.acceptedText.length - ) { - return []; - } - - const delta = fullText.slice(entry.acceptedText.length); - const progressCount = entry.progressCount; - entry.acceptedText = fullText; - entry.progressCount += 1; - - return [ - this.createCommit( - entryId, - `${entryId}:progress:${progressCount}`, - 'progress', - makeBody(delta), - entry.turnId, - ), - ]; - } - - /** - * Finalizes a live entry exactly once. - */ - complete(entryId: string, body: Body): readonly TranscriptCommit<Body>[] { - const entry = this.entries.get(entryId); - if (entry?.status !== 'live') { - return []; - } - - this.entries.set(entryId, { status: 'finalized' }); - return [ - this.createCommit(entryId, `${entryId}:final`, 'final', body, entry.turnId), - ]; - } - - /** - * Drops all entry state and per-entry progress counters. - */ - reset(): void { - this.entries.clear(); - } - - /** - * Reports whether no entries are currently live. - */ - idle(): boolean { - for (const entry of this.entries.values()) { - if (entry.status === 'live') { - return false; - } - } - - return true; - } - - private createCommit( - entryId: string, - key: string, - phase: ScrollbackPhase, - body: Body, - turnId?: string, - ): TranscriptCommit<Body> { - if (turnId === undefined) { - return { key, entryId, phase, body }; - } - - return { key, entryId, turnId, phase, body }; - } -} diff --git a/apps/pythinker-code/src/tui/theme/colorize.ts b/apps/pythinker-code/src/tui/theme/colorize.ts deleted file mode 100644 index f08654f3..00000000 --- a/apps/pythinker-code/src/tui/theme/colorize.ts +++ /dev/null @@ -1,26 +0,0 @@ -import chalk from 'chalk'; - -import { darkColors } from './colors'; -import { currentTheme, type ColorToken } from './theme'; - -export type ColorSpec = ColorToken | `#${string}`; - -/** Curried theme-aware colourizer: resolves a palette token or a raw hex to a - * reusable text transformer. `undefined` returns the identity function. */ -export function colorize( - spec: ColorSpec | undefined, - variant: 'foreground' | 'background' = 'foreground', -): (text: string) => string { - if (spec === undefined) return (text) => text; - - if (spec.startsWith('#')) { - return variant === 'background' ? chalk.bgHex(spec) : chalk.hex(spec); - } - - return (text) => { - if (!Object.hasOwn(darkColors, spec)) return text; - - const color = currentTheme.palette[spec as ColorToken]; - return variant === 'background' ? chalk.bgHex(color)(text) : chalk.hex(color)(text); - }; -} diff --git a/apps/pythinker-code/src/tui/theme/colors.ts b/apps/pythinker-code/src/tui/theme/colors.ts index 82a89505..7a0b137b 100644 --- a/apps/pythinker-code/src/tui/theme/colors.ts +++ b/apps/pythinker-code/src/tui/theme/colors.ts @@ -24,24 +24,12 @@ export interface ColorPalette { * placeholder, BTW / queue panes, custom-registry import. */ accent: string; - // ── Shimmer ── - /** Brighter primary pulse for future spinner and running-state animations. */ - primaryShimmer: string; - /** Brighter accent pulse for future device-code and queue-pane animations. */ - accentShimmer: string; - /** Brighter warning pulse for future stale-state and attention animations. */ - warningShimmer: string; - /** Brighter border pulse for future focused-panel border animations. */ - borderShimmer: string; - /** Brighter dim-text pulse for future thinking and status animations. */ - textDimShimmer: string; - // ── Text ── /** Default body text: dialog bodies, todo titles, footer model label, * markdown headings, tool/read output, and assistant-side message bullets * (assistant / tool / agent / read) plus markdown list bullets. */ text: string; - /** Emphasised text: input dialogs, status messages, high-signal tool names, user transcript text. */ + /** Emphasised / bold text: input dialogs, status messages. */ textStrong: string; /** Secondary, dimmed text (the most widely used dim shade): thinking blocks, * hints, descriptions, completed todos, markdown quotes, and the footer @@ -64,18 +52,12 @@ export interface ColorPalette { warning: string; /** Error: error messages, failed tool output. */ error: string; - - // ── Effort heat ── - /** Low thinking effort; colors the editor effort dot. */ - effortLow: string; - /** Medium thinking effort; colors the editor effort dot. */ - effortMedium: string; - /** High thinking effort; colors the editor effort dot. */ - effortHigh: string; - /** Extra-high thinking effort; colors the editor effort dot. */ - effortXHigh: string; - /** Maximum thinking effort; colors the editor effort dot. */ - effortMax: string; + /** Background tint for a running tool card. */ + toolPendingBg: string; + /** Background tint for a successful tool card. */ + toolSuccessBg: string; + /** Background tint for a failed tool card. */ + toolErrorBg: string; // ── Diff (all consumed by components/media/diff-preview.ts) ── /** Added lines. */ @@ -90,106 +72,22 @@ export interface ColorPalette { diffGutter: string; /** Meta / hunk headers. */ diffMeta: string; - /** De-emphasised added context lines in future expanded diff hunks. */ - diffAddedDimmed: string; - /** De-emphasised removed context lines in future expanded diff hunks. */ - diffRemovedDimmed: string; // ── Roles ── - /** User-accent hue for skill-activation names and future user-specific accents. - * Assistant/thinking/status bullets reuse text/textDim. */ + /** User message: bullet & text, skill-activation name. The one role colour + * with its own hue — assistant/thinking/status bullets reuse text/textDim. */ roleUser: string; - // ── Workflow ── - /** Coral title used by the Dynamic Workflow mission-control frame. */ - workflowTitle: string; - - // ── Agent identity ── - /** Red identity used by the first future agent in Dynamic Workflow progress and grouped output. */ - agentRed: string; - /** Orange identity used by the second future agent in Dynamic Workflow progress and grouped output. */ - agentOrange: string; - /** Yellow identity used by the third future agent in Dynamic Workflow progress and grouped output. */ - agentYellow: string; - /** Green identity used by the fourth future agent in Dynamic Workflow progress and grouped output. */ - agentGreen: string; - /** Cyan identity used by the fifth future agent in Dynamic Workflow progress and grouped output. */ - agentCyan: string; - /** Blue identity used by the sixth future agent in Dynamic Workflow progress and grouped output. */ - agentBlue: string; - /** Purple identity used by the seventh future agent in Dynamic Workflow progress and grouped output. */ - agentPurple: string; - /** Pink identity used by the eighth future agent in Dynamic Workflow progress and grouped output. */ - agentPink: string; - - // ── Rainbow ── - /** Red spectrum stop for future keyword and gradient highlighting. */ - rainbowRed: string; - /** Orange spectrum stop for future keyword and gradient highlighting. */ - rainbowOrange: string; - /** Yellow spectrum stop for future keyword and gradient highlighting. */ - rainbowYellow: string; - /** Green spectrum stop for future keyword and gradient highlighting. */ - rainbowGreen: string; - /** Blue spectrum stop for future keyword and gradient highlighting. */ - rainbowBlue: string; - /** Indigo spectrum stop for future keyword and gradient highlighting. */ - rainbowIndigo: string; - /** Violet spectrum stop for future keyword and gradient highlighting. */ - rainbowViolet: string; - - // ── Mode identity ── - /** Auto-accept badge colour for the future mode-specific status treatment. */ - modeAutoAccept: string; - /** Plan badge colour for the future mode-specific status treatment. */ - modePlan: string; - /** Permission badge colour for the future mode-specific status treatment. */ - modePermission: string; - /** Fast badge colour for the future mode-specific status treatment. */ - modeFast: string; - - // ── Background surfaces ── - // Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the - // background and `inverseText` for the foreground. Keep this pair at 4.5:1 - // contrast or higher. - // The runtime validates six-digit hex syntax for each color, but it does not - // enforce or repair color contrast. - /** Assumed terminal background against which future themed surfaces are tuned. */ - background: string; - /** Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with - * `selectionBg` at 4.5:1 contrast or higher. */ - inverseText: string; - /** Background for active `/model` provider and `AskUserQuestion` tabs; pair with - * `inverseText` at 4.5:1 contrast or higher. */ - selectionBg: string; - /** Subtle fill for highlighted rows and message surfaces, including user transcript rows. */ - surfaceHighlight: string; - /** Background tint for a tool card while the call is running. */ - toolPendingBg: string; - /** Background tint for a tool card after a successful result. */ - toolSuccessBg: string; - /** Background tint for a tool card after an error result. */ - toolErrorBg: string; - - // ── Progress ── - /** Filled segment of the Dynamic Workflow aggregate progress line. */ - progressFill: string; - /** Static head of the Dynamic Workflow aggregate progress track. */ - progressHead: string; - /** Empty segment of the Dynamic Workflow aggregate progress line. */ - progressEmpty: string; + // ── Shell mode ── + /** Shell mode (`!`): the `!` prompt symbol, bash-mode editor border, and the + * echoed `$ command` line. Its own hue (violet), distinct from + * plan-mode (primary) and the user role (roleUser). */ + shellMode: string; } export const darkColors: ColorPalette = { - /* Slightly darker periwinkle used for selection, menus, and focus on dark terminals. */ - primary: '#BBC6FF', - accent: '#7B8CE8', - - primaryShimmer: '#F4F5FF', - accentShimmer: '#AAB7FF', - warningShimmer: '#FFD474', - borderShimmer: '#848CA8', - textDimShimmer: '#B6B9C7', + primary: '#4FA8FF', + accent: '#5BC0BE', text: '#E0E0E0', textStrong: '#F5F5F5', @@ -202,12 +100,9 @@ export const darkColors: ColorPalette = { success: '#4EC87E', warning: '#E8A838', error: '#E85454', - - effortLow: '#8A8A8A', - effortMedium: '#6FA8DC', - effortHigh: '#D33682', - effortXHigh: '#C0392B', - effortMax: '#F2C744', + toolPendingBg: '#1D2129', + toolSuccessBg: '#14171B', + toolErrorBg: '#291D1D', diffAdded: '#4EC87E', diffRemoved: '#E85454', @@ -215,58 +110,14 @@ export const darkColors: ColorPalette = { diffRemovedStrong: '#F08585', diffGutter: '#6B6B6B', diffMeta: '#888888', - diffAddedDimmed: '#57966F', - diffRemovedDimmed: '#B55E68', roleUser: '#FFCB6B', - - workflowTitle: '#EE9983', - - agentRed: '#E2697D', - agentOrange: '#E2B069', - agentYellow: '#BAE269', - agentGreen: '#69E273', - agentCyan: '#69E2CE', - agentBlue: '#699CE2', - agentPurple: '#9269E2', - agentPink: '#E269D8', - - rainbowRed: '#E96E63', - rainbowOrange: '#E9B163', - rainbowYellow: '#DEE963', - rainbowGreen: '#63E96E', - rainbowBlue: '#639BE9', - rainbowIndigo: '#6E63E9', - rainbowViolet: '#C763E9', - - modeAutoAccept: '#66D49A', - modePlan: '#A9B8FF', - modePermission: '#D99AF0', - modeFast: '#FFB45E', - - background: '#000000', - inverseText: '#FFFFFF', - selectionBg: '#344274', - surfaceHighlight: '#1C2238', - toolPendingBg: '#1D2129', - toolSuccessBg: '#14171B', - toolErrorBg: '#291D1D', - - progressFill: '#25764A', - progressHead: '#4EC87E', - progressEmpty: '#D9DEE8', + shellMode: '#BD93F9', }; export const lightColors: ColorPalette = { - /* Darker periwinkle for ≥3:1 contrast on light terminal backgrounds. */ - primary: '#4A5BC4', - accent: '#5566CC', - - primaryShimmer: '#263BA8', - accentShimmer: '#3F4DB5', - warningShimmer: '#6F4700', - borderShimmer: '#4F567A', - textDimShimmer: '#222A4A', + primary: '#1565C0', + accent: '#00838F', text: '#1A1A1A', textStrong: '#1A1A1A', @@ -279,12 +130,9 @@ export const lightColors: ColorPalette = { success: '#0E7A38', warning: '#92660A', error: '#B91C1C', - - effortLow: '#8A8A8A', - effortMedium: '#2E6FB8', - effortHigh: '#A81D6E', - effortXHigh: '#8B1A1A', - effortMax: '#B8860B', + toolPendingBg: '#E8EEF7', + toolSuccessBg: '#F1F3F5', + toolErrorBg: '#F9E9E9', diffAdded: '#0E7A38', diffRemoved: '#B91C1C', @@ -292,49 +140,11 @@ export const lightColors: ColorPalette = { diffRemovedStrong: '#B91C1C', diffGutter: '#737373', diffMeta: '#5F5F5F', - diffAddedDimmed: '#316A48', - diffRemovedDimmed: '#8D4852', roleUser: '#9A4A00', - - workflowTitle: '#9C261C', - - agentRed: '#9D2539', - agentOrange: '#9D6B25', - agentYellow: '#759D25', - agentGreen: '#259D2F', - agentCyan: '#259D89', - agentBlue: '#25579D', - agentPurple: '#4D259D', - agentPink: '#9D2593', - - rainbowRed: '#9C261C', - rainbowOrange: '#9C671C', - rainbowYellow: '#919C1C', - rainbowGreen: '#1C9C26', - rainbowBlue: '#1C519C', - rainbowIndigo: '#261C9C', - rainbowViolet: '#7C1C9C', - - modeAutoAccept: '#26704C', - modePlan: '#4A5BC4', - modePermission: '#7A3C96', - modeFast: '#9A570F', - - background: '#FFFFFF', - inverseText: '#0B1020', - selectionBg: '#C9D1FA', - surfaceHighlight: '#E8EBFC', - toolPendingBg: '#E8EEF7', - toolSuccessBg: '#F1F3F5', - toolErrorBg: '#F9E9E9', - - progressFill: '#3B9A65', - progressHead: '#0E7A38', - progressEmpty: '#6B7280', + shellMode: '#7C3AED', }; -/** Built-in palette choice, resolved from terminal background detection. */ export type ResolvedTheme = 'dark' | 'light'; /** Synchronous palette lookup for built-in themes only. */ diff --git a/apps/pythinker-code/src/tui/theme/highlight-theme.ts b/apps/pythinker-code/src/tui/theme/highlight-theme.ts index a95ea01d..e16f4b31 100644 --- a/apps/pythinker-code/src/tui/theme/highlight-theme.ts +++ b/apps/pythinker-code/src/tui/theme/highlight-theme.ts @@ -1,10 +1,14 @@ /** - * cli-highlight's default theme paints string, regexp, and deletion tokens - * red. Reset those token classes to plain text so ordinary code never inherits - * the TUI's error color. + * Shared cli-highlight theme for code previews (Write/Edit tool calls, + * approval panels) and markdown code blocks. + * + * cli-highlight's DEFAULT_THEME paints `string`, `regexp` and `deletion` + * tokens red; reset exactly those tokens to `plain` so highlighted code + * contains no red at all. Tokens not listed here fall back to DEFAULT_THEME. */ -import { plain, type Theme } from 'cli-highlight'; +import { plain } from 'cli-highlight'; +import type { Theme } from 'cli-highlight'; export const codeHighlightTheme: Theme = { string: plain, diff --git a/apps/pythinker-code/src/tui/theme/index.ts b/apps/pythinker-code/src/tui/theme/index.ts index c20dd285..b7d580b6 100644 --- a/apps/pythinker-code/src/tui/theme/index.ts +++ b/apps/pythinker-code/src/tui/theme/index.ts @@ -3,23 +3,16 @@ */ import { getBuiltInPalette } from './colors'; -import type { ColorPalette } from './colors'; +import type { ColorPalette, ResolvedTheme } from './colors'; import { loadCustomThemeMerged } from './custom-theme-loader'; import { detectTerminalTheme } from './detect'; export { currentTheme, Theme } from './theme'; export type { ColorToken } from './theme'; -export { colorize } from './colorize'; -export type { ColorSpec } from './colorize'; export { darkColors, lightColors, getBuiltInPalette } from './colors'; export type { ColorPalette, ResolvedTheme } from './colors'; export { detectTerminalTheme } from './detect'; export { loadCustomTheme, loadCustomThemeMerged, listCustomThemes } from './custom-theme-loader'; -export { - createPythinkerEditorTheme, - createPythinkerMarkdownTheme, - createPythinkerThinkingMarkdownTheme, -} from './pythinker-theme'; /** * User-facing theme preference. diff --git a/apps/pythinker-code/src/tui/theme/pi-tui-theme.ts b/apps/pythinker-code/src/tui/theme/pi-tui-theme.ts new file mode 100644 index 00000000..18333fb5 --- /dev/null +++ b/apps/pythinker-code/src/tui/theme/pi-tui-theme.ts @@ -0,0 +1,75 @@ +/** + * Pi-tui theme adapters — MarkdownTheme and EditorTheme backed by the + * global `currentTheme` singleton. + * + * All colour lookups route through `currentTheme.color(token)` so that + * switching themes is instantaneous: old components hold old + * MarkdownTheme/EditorTheme instances, but every method call on those + * instances reads the *current* palette via the singleton. + */ + +import type { MarkdownTheme, EditorTheme } from '@pymodel/pi-tui'; +import chalk from 'chalk'; +import { highlight, supportsLanguage } from 'cli-highlight'; + +import { currentTheme } from './theme'; +import { codeHighlightTheme } from './highlight-theme'; + +// pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 +// headings (h1/h2 are rendered without the `#` prefix). The prefix arrives +// here already wrapped in bold SGR codes, so we strip it — after any leading +// ANSI sequences — before re-styling. Without this, h3+ renders as raw +// "### Title" and reads like unparsed markdown. +// eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. +const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; + +export function createMarkdownTheme(options?: { transient?: boolean }): MarkdownTheme { + const transient = options?.transient === true; + const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); + + return { + heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), + link: (text) => chalk.hex(currentTheme.color('primary'))(text), + linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + code: (text) => chalk.hex(currentTheme.color('primary'))(text), + codeBlock: (text) => text, + codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), + quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), + quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), + hr: (text) => chalk.hex(currentTheme.color('border'))(text), + // Match the assistant-message bullet so list markers read like a reply + // prefix. Ordered lists arrive as "1. " / "2. " and are left + // untouched by the leading-dash anchor. + listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), + bold: (text) => chalk.bold(text), + italic: (text) => chalk.italic(text), + strikethrough: (text) => chalk.strikethrough(text), + underline: (text) => chalk.underline(text), + highlightCode: (code: string, lang?: string) => { + if (transient) return code.split('\n'); + + const normalizedLang = lang?.trim().toLowerCase(); + const language = + normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; + try { + const highlighted = highlight(code, { language, ignoreIllegals: true, theme: codeHighlightTheme }); + return highlighted.split('\n'); + } catch { + return code.split('\n'); + } + }, + }; +} + +export function createEditorTheme(): EditorTheme { + return { + borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), + selectList: { + selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), + selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), + description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), + }, + }; +} diff --git a/apps/pythinker-code/src/tui/theme/pythinker-theme.ts b/apps/pythinker-code/src/tui/theme/pythinker-theme.ts deleted file mode 100644 index 6eb02554..00000000 --- a/apps/pythinker-code/src/tui/theme/pythinker-theme.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Pythinker theme adapters — MarkdownTheme and EditorTheme backed by the - * global `currentTheme` singleton. - * - * All colour lookups route through `currentTheme.color(token)` so that - * switching themes is instantaneous: old components hold old - * MarkdownTheme/EditorTheme instances, but every method call on those - * instances reads the *current* palette via the singleton. - */ - -import type { MarkdownTheme, EditorTheme } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { highlight, supportsLanguage } from 'cli-highlight'; - -import { currentTheme } from './theme'; -import { codeHighlightTheme } from './highlight-theme'; - -// pi-tui's renderer emits literal "### " / "#### " / ... markers for h3-h6 -// headings (h1/h2 are rendered without the `#` prefix). The prefix arrives -// here already wrapped in bold SGR codes, so we strip it — after any leading -// ANSI sequences — before re-styling. Without this, h3+ renders as raw -// "### Title" and reads like unparsed markdown. -// eslint-disable-next-line no-control-regex -- intentionally matches the ESC byte that opens ANSI SGR sequences. -const HEADING_HASH_PREFIX = /^((?:\u001B\[[0-9;]*m)*)#{1,6}[ \t]+/; - -export function createPythinkerMarkdownTheme(): MarkdownTheme { - const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - - return { - heading: (text) => chalk.bold.hex(currentTheme.color('text'))(stripHash(text)), - link: (text) => chalk.hex(currentTheme.color('primary'))(text), - linkUrl: (text) => chalk.hex(currentTheme.color('textMuted'))(text), - code: (text) => chalk.hex(currentTheme.color('primary'))(text), - codeBlock: (text) => text, - codeBlockBorder: (text) => chalk.hex(currentTheme.color('textMuted'))(text), - quote: (text) => chalk.hex(currentTheme.color('textDim'))(text), - quoteBorder: (text) => chalk.hex(currentTheme.color('textDim'))(text), - hr: (text) => chalk.hex(currentTheme.color('border'))(text), - // Match the assistant-message bullet so list markers read like a reply - // prefix. Ordered lists arrive as "1. " / "2. " and are left - // untouched by the leading-dash anchor. - listBullet: (text) => chalk.hex(currentTheme.color('text'))(text.replace(/^-/, '•')), - bold: (text) => chalk.bold(text), - italic: (text) => chalk.italic(text), - strikethrough: (text) => chalk.strikethrough(text), - underline: (text) => chalk.underline(text), - highlightCode: (code: string, lang?: string) => { - const normalizedLang = lang?.trim().toLowerCase(); - const language = - normalizedLang !== undefined && supportsLanguage(normalizedLang) ? normalizedLang : 'text'; - try { - const highlighted = highlight(code, { - language, - ignoreIllegals: true, - theme: codeHighlightTheme, - }); - return highlighted.split('\n'); - } catch { - return code.split('\n'); - } - }, - }; -} - -export function createPythinkerThinkingMarkdownTheme(): MarkdownTheme { - const stripHash = (text: string): string => text.replace(HEADING_HASH_PREFIX, '$1'); - const dim = (text: string): string => chalk.italic.hex(currentTheme.color('textDim'))(text); - - return { - heading: (text) => chalk.bold.italic.hex(currentTheme.color('textDim'))(stripHash(text)), - link: dim, - linkUrl: dim, - code: dim, - codeBlock: dim, - codeBlockBorder: dim, - quote: dim, - quoteBorder: dim, - hr: dim, - listBullet: (text) => dim(text.replace(/^-/, '•')), - bold: (text) => chalk.bold(text), - italic: (text) => chalk.italic(text), - strikethrough: (text) => chalk.strikethrough(text), - underline: (text) => chalk.underline(text), - }; -} - -export function createPythinkerEditorTheme(): EditorTheme { - return { - borderColor: (s) => chalk.hex(currentTheme.color('border'))(s), - selectList: { - selectedPrefix: (s) => chalk.hex(currentTheme.color('primary'))(s), - selectedText: (s) => chalk.hex(currentTheme.color('primary'))(s), - description: (s) => chalk.hex(currentTheme.color('textMuted'))(s), - scrollInfo: (s) => chalk.hex(currentTheme.color('textMuted'))(s), - noMatch: (s) => chalk.hex(currentTheme.color('textMuted'))(s), - }, - }; -} diff --git a/apps/pythinker-code/src/tui/theme/theme-schema.json b/apps/pythinker-code/src/tui/theme/theme-schema.json index 46c84210..eca74c40 100644 --- a/apps/pythinker-code/src/tui/theme/theme-schema.json +++ b/apps/pythinker-code/src/tui/theme/theme-schema.json @@ -26,15 +26,10 @@ }, "colors": { "type": "object", - "description": "Color overrides. Omitted tokens fall back to the selected base palette. Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the background and `inverseText` for the foreground. Keep this pair at 4.5:1 contrast or higher. The runtime validates six-digit hex syntax for each color, but it does not enforce or repair color contrast.", + "description": "Color overrides. Omitted tokens fall back to the dark theme defaults.", "properties": { "primary": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Primary brand color" }, "accent": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Accent / highlight color" }, - "primaryShimmer": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Animated primary pulse color" }, - "accentShimmer": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Animated accent pulse color" }, - "warningShimmer": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Animated warning pulse color" }, - "borderShimmer": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Animated border pulse color" }, - "textDimShimmer": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Animated dim-text pulse color" }, "text": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Default text color" }, "textStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Bold / emphasized text" }, "textDim": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Secondary / muted text" }, @@ -44,50 +39,17 @@ "success": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Success state color" }, "warning": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Warning state color" }, "error": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Error state color" }, - "effortLow": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Low thinking effort dot" }, - "effortMedium": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Medium thinking effort dot" }, - "effortHigh": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "High thinking effort dot" }, - "effortXHigh": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Extra-high thinking effort dot" }, - "effortMax": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Maximum thinking effort dot" }, + "toolPendingBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a running tool card" }, + "toolSuccessBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a successful tool card" }, + "toolErrorBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a failed tool card" }, "diffAdded": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines" }, "diffRemoved": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines" }, "diffAddedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff added lines (strong)" }, "diffRemovedStrong": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff removed lines (strong)" }, "diffGutter": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff gutter color" }, "diffMeta": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Diff meta color" }, - "diffAddedDimmed": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "De-emphasized added diff context" }, - "diffRemovedDimmed": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "De-emphasized removed diff context" }, "roleUser": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "User message accent" }, - "workflowTitle": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Coral title used by the Dynamic Workflow mission-control frame." }, - "agentRed": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Red agent identity color" }, - "agentOrange": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Orange agent identity color" }, - "agentYellow": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Yellow agent identity color" }, - "agentGreen": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Green agent identity color" }, - "agentCyan": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Cyan agent identity color" }, - "agentBlue": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Blue agent identity color" }, - "agentPurple": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Purple agent identity color" }, - "agentPink": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Pink agent identity color" }, - "rainbowRed": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Red rainbow highlight color" }, - "rainbowOrange": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Orange rainbow highlight color" }, - "rainbowYellow": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Yellow rainbow highlight color" }, - "rainbowGreen": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Green rainbow highlight color" }, - "rainbowBlue": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Blue rainbow highlight color" }, - "rainbowIndigo": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Indigo rainbow highlight color" }, - "rainbowViolet": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Violet rainbow highlight color" }, - "modeAutoAccept": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Auto-accept mode badge color" }, - "modePlan": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Plan mode badge color" }, - "modePermission": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Permission mode badge color" }, - "modeFast": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Fast mode badge color" }, - "background": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Assumed terminal background color" }, - "inverseText": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with `selectionBg` at 4.5:1 contrast or higher." }, - "selectionBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background for active `/model` provider and `AskUserQuestion` tabs; pair with `inverseText` at 4.5:1 contrast or higher." }, - "surfaceHighlight": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Highlighted row and message fill" }, - "toolPendingBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a running tool card" }, - "toolSuccessBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a successful tool card" }, - "toolErrorBg": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Background tint for a failed tool card" }, - "progressFill": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Filled segment of the Dynamic Workflow aggregate progress line." }, - "progressHead": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Static head of the Dynamic Workflow aggregate progress track." }, - "progressEmpty": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Empty segment of the Dynamic Workflow aggregate progress line." } + "shellMode": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$", "description": "Shell mode (`!`) prompt, editor border, and the echoed `$ command` line" } }, "additionalProperties": { "type": "string", diff --git a/apps/pythinker-code/src/tui/tui-state.ts b/apps/pythinker-code/src/tui/tui-state.ts index fded2482..4f4ef90a 100644 --- a/apps/pythinker-code/src/tui/tui-state.ts +++ b/apps/pythinker-code/src/tui/tui-state.ts @@ -1,24 +1,26 @@ import { Container, ProcessTerminal, - TUI, -} from '@earendil-works/pi-tui'; + ScrollView, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, +} from '@pymodel/pi-tui'; -import { FooterComponent } from './components/chrome/footer'; -import { StatusBarComponent } from './components/chrome/status-bar'; -import { GutterContainer } from './components/chrome/gutter-container'; -import { TranscriptContainer } from './components/chrome/transcript-container'; -import type { ActivityLoader } from './components/chrome/activity-loader'; +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { openUrl } from '#/utils/open-url'; + +import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container'; +import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; -import { TranscriptViewport } from './components/chrome/transcript-viewport'; -import { ViewportLayoutRoot } from './components/chrome/viewport-layout'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; -import { createFooterState, type FooterState } from './runtime/footer/footer-model'; -import type { TuiLayout } from './config'; +import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; import { currentTheme, type Theme } from './theme'; +import { setMarkdownRenderLatex } from './utils/markdown-options'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -33,23 +35,20 @@ import { export interface TUIState { ui: TUI; terminal: ProcessTerminal; - layout: TuiLayout; - copyFullResponse: boolean; - transcriptContainer: TranscriptContainer; - transcriptViewport: TranscriptViewport; - layoutRoot: ViewportLayoutRoot; - footerWrap: GutterContainer; + transcriptContainer: Container; activityContainer: Container; todoPanelContainer: Container; todoPanel: TodoPanelComponent; queueContainer: Container; btwPanelContainer: Container; - mcpStatusContainer: Container; - statusBarContainer: Container; - statusBar: StatusBarComponent; editorContainer: Container; + /** + * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + + * footer) stacked under the transcript ScrollView. Undefined in regular + * mode, where all chrome is a direct child of the root container. + */ + dockContainer: VStack | undefined; footer: FooterComponent; - footerState: FooterState; editor: CustomEditor; theme: Theme; appState: AppState; @@ -57,22 +56,33 @@ export interface TUIState { livePane: LivePaneState; transcriptEntries: TranscriptEntry[]; terminalState: TerminalState; - activitySpinner: { instance: ActivityLoader } | null; + activitySpinner: { instance: MoonLoader; style: SpinnerStyle } | null; toolOutputExpanded: boolean; sessions: SessionRow[]; loadingSessions: boolean; + /** Keyset cursor for the next older page; `undefined` when the listing is exhausted. */ + sessionsNextCursor: string | undefined; + /** A follow-up session page fetch is in flight. */ + sessionsLoadingMore: boolean; sessionsScope: 'cwd' | 'all'; - activeDialog: 'session-picker' | 'help' | null; + activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; + /** + * True while an editor-replacement panel (help, trust prompt, goal queue + * manager, …) is mounted in place of the editor. Delayed input restores + * must not run in that state — they would displace the newer panel. + */ + editorReplacementMounted: boolean; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; - dynamicWorkflowModeEntry: 'manual' | 'task' | undefined; /** - * Arguments of the most recent DynamicWorkflow tool call, so `/workflow save` - * can turn a run that just worked into a reusable command. Overwritten as the - * call streams in; the last write is the complete one. + * True while a queued user message has been shifted out of + * {@link queuedMessages} but its deferred send has not run yet. The queue + * looks empty during this window, so queued-goal promotion must also check + * this flag to avoid starting a goal ahead of the user's earlier message. */ - lastDynamicWorkflowArgs: Record<string, unknown> | undefined; + queuedMessageDispatchPending: boolean; + dynamicWorkflowModeEntry: 'manual' | 'task' | undefined; } export function createTUIState(options: PythinkerTUIOptions): TUIState { @@ -80,68 +90,87 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); - // Gate rendering until the event loop starts: pi-tui paints on requestRender - // even before ui.start() (stopped defaults to false), so construction-time - // renders would anchor frames to the shell cursor. The field is private in - // pi-tui's types; ui.start() flips it back to false. - (ui as unknown as { stopped: boolean }).stopped = true; + setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); + // Fullscreen is experimental and env-gated for now: PYTHINKER_CODE_TUI_FULL_SCREEN=1. + const fullscreen = process.env['PYTHINKER_CODE_TUI_FULL_SCREEN'] === '1'; + const ui = + fullscreen + ? new TuiAltScreen(terminal, undefined, undefined, { + // Mouse capture takes over the terminal's native link activation, so + // route OSC 8 clicks through our own opener. + openUrl, + // Likewise, on Windows the terminal's native right-click paste is + // intercepted; feed the clipboard to the focused component as a + // bracketed paste instead (renderer only calls this on win32). + onRightClickPaste: () => { + const target = ui.getFocusedComponent(); + if (!target?.handleInput || clipboard?.getText === undefined) return; + void clipboard + .getText() + .then((text) => { + if (!text || ui.getFocusedComponent() !== target) return; + target.handleInput?.(`\x1b[200~${text}\x1b[201~`); + ui.requestRender(); + }) + .catch(() => {}); + }, + }) + : new TuiMainScreen(terminal); - const transcriptContainer = new TranscriptContainer(CHROME_GUTTER, CHROME_GUTTER); + const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanel = new TodoPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const mcpStatusContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const statusBarContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const statusBar = new StatusBarComponent(); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - const editor = new CustomEditor(ui); + const editor = new CustomEditor(ui, { + disablePasteBurst: initialAppState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + }); const footer = new FooterComponent({ ...initialAppState }, () => { ui.requestRender(); }); - const layout = options.layout; - const transcriptViewport = new TranscriptViewport(transcriptContainer); - const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); - footerWrap.addChild(footer); - const layoutRoot = new ViewportLayoutRoot( - terminal, - transcriptViewport, - [ - activityContainer, - todoPanelContainer, - queueContainer, - btwPanelContainer, - mcpStatusContainer, - editorContainer, - statusBarContainer, - ], - footerWrap, - ); + let dockContainer: VStack | undefined; + if (ui instanceof TuiAltScreen) { + // Fullscreen (alternate screen): the transcript scrolls inside the primary + // ScrollView while the rest of the chrome stays docked at the bottom. The + // footer joins the dock later via mountFooter(). + // Sizing contract (mirrors pi's interactive layout): the transcript starts + // from basis 0 and grows; the dock keeps its intrinsic height, with the + // editor never squeezed below its 3 rows (top border / input / bottom + // border) and the footer below 1 — otherwise the box outline gets clipped. + const scrollView = new ScrollView(transcriptContainer, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'auto', + }); + dockContainer = new VStack(); + dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); + const root = new VStack(); + root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); + root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 }); + ui.setLayoutRoot(root); + } return { ui, terminal, - layout, - copyFullResponse: options.copyFullResponse ?? false, transcriptContainer, - transcriptViewport, - layoutRoot, - footerWrap, activityContainer, todoPanelContainer, todoPanel, queueContainer, btwPanelContainer, - mcpStatusContainer, - statusBarContainer, - statusBar, editorContainer, + dockContainer, editor, footer, - footerState: createFooterState(), theme, appState: { ...initialAppState }, startupState: 'pending', @@ -152,12 +181,15 @@ export function createTUIState(options: PythinkerTUIOptions): TUIState { toolOutputExpanded: false, sessions: [], loadingSessions: false, + sessionsNextCursor: undefined, + sessionsLoadingMore: false, sessionsScope: 'cwd', activeDialog: null, + editorReplacementMounted: false, tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], + queuedMessageDispatchPending: false, dynamicWorkflowModeEntry: undefined, - lastDynamicWorkflowArgs: undefined, }; } diff --git a/apps/pythinker-code/src/tui/types.ts b/apps/pythinker-code/src/tui/types.ts index b6549d89..2537ca71 100644 --- a/apps/pythinker-code/src/tui/types.ts +++ b/apps/pythinker-code/src/tui/types.ts @@ -2,19 +2,14 @@ import type { GoalChange, GoalSnapshot, ModelAlias, - ModelCostRates, PermissionMode, ProviderConfig, PromptPart, + ThinkingEffort, ToolInputDisplay, } from '@pymodel/pythinker-code-sdk'; -import type { - NotificationsConfig, - StatusLineConfig, - TuiLayout, - UpgradePreferences, -} from './config'; +import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -31,37 +26,59 @@ export interface BannerState { export interface AppState { model: string; - /** Current model token rates in USD per 1,000,000 tokens. */ - modelCostRates?: ModelCostRates; - /** Accumulated priced usage for the current session. */ - totalCostUsd?: number; workDir: string; + additionalDirs: readonly string[]; sessionId: string; permissionMode: PermissionMode; planMode: boolean; + /** Resolved profile name from --agent/--agent-file, carried to the + * lazy-created first session when the TUI starts session-less. */ + agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + agentFiles?: readonly string[]; + /** 'bash' when the editor is in `!` shell-command mode. */ + inputMode: 'prompt' | 'bash'; dynamicWorkflowMode: boolean; - /** Model alias `/workflow` asks Dynamic Workflow subagents to run on, so workers - * can use a cheaper or faster model than the agent orchestrating them. */ - dynamicWorkflowModel?: string; - /** Whether provider-native Fast mode is requested for this session. */ - fastMode?: boolean; - /** Whether the current model/provider accepts provider-native Fast mode. */ - fastModeSupported?: boolean; - /** Resolved thinking effort level; 'off' means thinking is disabled. */ - thinkingLevel: string; + /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); + * mirrors the runtime. The single source of truth for the thinking state in + * the TUI. */ + thinkingEffort: ThinkingEffort; + /** + * The current `defaultPlanMode` value from config (false when absent), + * refreshed by `hydrateLazyConfigDefaults`. Used to tell a config-driven + * plan-mode entry apart from an explicit CLI `--plan` when lazy-creating + * the first session (the engine applies the config default itself). + */ + configDefaultPlanMode?: boolean; + /** + * Session-only thinking effort chosen (e.g. via the model picker's Alt+S) + * while no session exists yet on the v2 engine. Applied to the first + * lazy-created session and cleared once it exists; the engine's config + * default is used instead when unset. + */ + lazySessionThinking?: ThinkingEffort; contextUsage: number; contextTokens: number; maxContextTokens: number; isCompacting: boolean; isReplaying: boolean; - streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; + streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; + /** Pending step retry backoff (fed by `turn.step.retrying`); null when no retry is in flight. */ + stepRetry: StepRetryState | null; theme: ThemeName; version: string; editorCommand: string | null; + /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ + disablePasteBurst?: boolean; + /** LaTeX math rendering in Markdown; defaults to true when absent from older fixtures. */ + renderLatex?: boolean; + /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ + cacheExpiryHint?: boolean; notifications: NotificationsConfig; upgrade: UpgradePreferences; - statusLine: StatusLineConfig; + /** Footer status line customization from tui.toml; absent means the default layout. */ + statusLine?: StatusLineConfig; availableModels: Record<string, ModelAlias>; availableProviders: Record<string, ProviderConfig>; sessionTitle: string | null; @@ -72,6 +89,24 @@ export interface AppState { banner?: BannerState | null; } +export interface StepRetryState { + /** Upcoming attempt number (1-based). */ + nextAttempt: number; + maxAttempts: number; + /** Backoff wait before the next attempt, in milliseconds. */ + delayMs: number; + errorName: string; + errorMessage: string; + /** HTTP status code for `APIStatusError`; undefined for network/timeout failures. */ + statusCode?: number; + /** + * `backoff` while sleeping before the next attempt (label shows the + * countdown); `attempt` once the `delayMs` backoff has elapsed and the next + * attempt is running — the countdown has expired by then and is dropped. + */ + phase: 'backoff' | 'attempt'; +} + export interface ToolCallBlockData { id: string; name: string; @@ -117,6 +152,10 @@ export interface BackgroundAgentMetadata { readonly parentToolCallId: string; readonly agentName?: string; readonly description?: string; + /** Display name of the model the agent is bound to (resolved at spawn). */ + readonly model?: string; + /** Thinking effort, set only for concrete levels (boolean on/off hidden). */ + readonly effort?: string; } export type BackgroundAgentStatusPhase = 'started' | 'completed' | 'failed'; @@ -129,6 +168,7 @@ export interface BackgroundAgentStatusData { export interface CompactionTranscriptData { readonly result?: 'cancelled'; + readonly summary?: string; readonly tokensBefore?: number; readonly tokensAfter?: number; readonly instruction?: string; @@ -155,20 +195,37 @@ export type TranscriptEntryKind = | 'thinking' | 'status' | 'skill_activation' + | 'plugin_command' | 'cron' | 'goal'; export type SkillActivationTrigger = 'user-slash' | 'model-tool' | 'nested-skill'; +export interface PluginCommandTranscriptData { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly args?: string; + readonly trigger: 'user-slash'; +} + export interface TranscriptEntry { id: string; kind: TranscriptEntryKind; - checkpointId?: string; turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; + /** + * True only for entries holding real model-authored text (created by the + * assistant stream). Derived cards — hook results, goal completions, goal + * reminders — share kind 'assistant' but are not replies, so /copy must + * skip them. + */ + modelText?: boolean; color?: ColorToken; detail?: string; + /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ + bullet?: string; toolCallData?: ToolCallBlockData; backgroundAgentStatus?: BackgroundAgentStatusData; compactionData?: CompactionTranscriptData; @@ -179,6 +236,11 @@ export interface TranscriptEntry { skillName?: string; skillArgs?: string; skillTrigger?: SkillActivationTrigger; + /** Card belongs to the following prompt's bundled submission: undo removes them together. */ + bundledWithPrompt?: boolean; + /** Entry renders a UserPromptSubmit hook result (sits inside its prompt's group window). */ + hookResult?: boolean; + pluginCommandData?: PluginCommandTranscriptData; } export type LivePaneMode = @@ -194,11 +256,45 @@ export interface LivePaneState { pendingQuestion: PendingQuestion | null; } +export interface InlineSkillActivation { + readonly skillName: string; + /** + * Skill arguments. Only set for a leading `/skill:<name> args` command that + * is combined with further inline skills; inline tokens carry no args. + */ + readonly args?: string; +} + export interface QueuedMessage { readonly text: string; readonly agentId?: string; readonly parts?: readonly PromptPart[]; readonly imageAttachmentIds?: readonly number[]; + readonly stagingPaths?: readonly string[]; + /** `bash` for a `!` shell command queued while another command is running; + * `skill` for a slash-skill activation queued while the session is busy; + * undefined (=`prompt`) for a normal message. */ + readonly mode?: 'prompt' | 'bash' | 'skill'; + /** Set when mode === 'skill': the skill to activate when the item drains. + * `text` then holds the display/recall string (`/name args`). */ + readonly skillName?: string; + /** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */ + readonly skillArgs?: string; + /** Skills to activate together with this queued message's prompt. */ + readonly inlineSkillActivations?: readonly InlineSkillActivation[]; +} + +/** + * One unit of Ctrl-S steer input: a queued message or the editor draft, + * with the media parts extracted at submit/paste time so images and video + * tags survive the steer path (which accepts full prompt parts, not just + * text). + */ +export interface SteerInputItem { + readonly text: string; + readonly parts?: readonly PromptPart[]; + readonly imageAttachmentIds?: readonly number[]; + readonly stagingPaths?: readonly string[]; } export const INITIAL_LIVE_PANE: LivePaneState = { @@ -216,11 +312,12 @@ export interface TUIStartupOptions { readonly continueLast: boolean; readonly yolo: boolean; readonly auto: boolean; - readonly init?: boolean; - readonly maintenance?: boolean; readonly plan: boolean; readonly model?: string; - readonly additionalDirs?: readonly string[]; + /** Resolved profile name from --agent/--agent-file; bound to the startup session only. */ + readonly agentProfile?: string; + /** Raw --agent-file paths, passed to session creation alongside `agentProfile`. */ + readonly agentFiles?: readonly string[]; readonly startupNotice?: string; } @@ -229,8 +326,6 @@ export type TUIStartupState = 'pending' | 'ready' | 'picker'; export interface PythinkerTUIOptions { initialAppState: AppState; startup: TUIStartupOptions; - layout: TuiLayout; - copyFullResponse?: boolean; } export interface PendingExit { @@ -240,6 +335,7 @@ export interface PendingExit { export interface LoginProgressSpinnerHandle { stop(opts: { ok: boolean; label: string }): void; + setLabel(label: string): void; } export type ProgressSpinnerHandle = LoginProgressSpinnerHandle; diff --git a/apps/pythinker-code/src/tui/utils/background-agent-status.ts b/apps/pythinker-code/src/tui/utils/background-agent-status.ts index f0769128..aa740fc6 100644 --- a/apps/pythinker-code/src/tui/utils/background-agent-status.ts +++ b/apps/pythinker-code/src/tui/utils/background-agent-status.ts @@ -17,7 +17,7 @@ function normalizeBackgroundField(value: string | undefined): string | undefined export function formatBackgroundAgentTranscript( phase: BackgroundAgentStatusPhase, meta: BackgroundAgentMetadata, - extras?: { resultSummary?: string; error?: string }, + extras: { resultSummary?: string; error?: string } | undefined = undefined, ): BackgroundAgentStatusData { const normalizedAgentName = normalizeBackgroundField(meta.agentName); const subject = normalizedAgentName !== undefined ? `${normalizedAgentName} agent` : 'agent'; @@ -28,9 +28,12 @@ export function formatBackgroundAgentTranscript( ? `${subject} completed in background` : `${subject} failed in background`; const tail = phase === 'failed' ? normalizeBackgroundField(extras?.error) : undefined; - const detailParts = [normalizeBackgroundField(meta.description), tail].filter( - (part): part is string => part !== undefined, - ); + const detailParts = [ + normalizeBackgroundField(meta.model), + normalizeBackgroundField(meta.effort), + normalizeBackgroundField(meta.description), + tail, + ].filter((part): part is string => part !== undefined); return { phase, diff --git a/apps/pythinker-code/src/tui/utils/cache-hint.ts b/apps/pythinker-code/src/tui/utils/cache-hint.ts new file mode 100644 index 00000000..90a0d3e5 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/cache-hint.ts @@ -0,0 +1,52 @@ +import type { CacheHintConfig } from '#/utils/cache-hint-config'; + +export interface CacheHintInput { + /** Current time, epoch ms. */ + readonly now: number; + /** Last session activity, epoch ms. Missing → skip. */ + readonly lastActiveAt?: number; + /** Current context size in tokens. Missing → skip (no local estimation). */ + readonly totalTokens?: number; + /** Upstream model ID used to look up the rule. Missing/unconfigured → skip. */ + readonly modelId?: string; + /** Fetch failure → undefined → skip. */ + readonly config?: CacheHintConfig; + /** User chose "Don't ask me again" (tui.toml). */ + readonly dismissed: boolean; +} + +export type CacheHintDecision = + | { readonly kind: 'skip' } + | { readonly kind: 'hint'; readonly idleSeconds: number; readonly totalTokens: number }; + +/** + * Shared trigger rule for both the resume and the idle scenarios. Every + * missing-data branch skips — false negatives are acceptable, false positives + * are not. + */ +export function evaluateCacheHint(input: CacheHintInput): CacheHintDecision { + if (input.dismissed) return { kind: 'skip' }; + const { config, modelId, lastActiveAt, totalTokens } = input; + if (config === undefined || modelId === undefined) return { kind: 'skip' }; + if (lastActiveAt === undefined || totalTokens === undefined) return { kind: 'skip' }; + const rule = config.config[modelId]; + if (rule === undefined) return { kind: 'skip' }; + const idleMs = input.now - lastActiveAt; + if (idleMs <= rule.cache_duration * 1000) return { kind: 'skip' }; + if (totalTokens < rule.min_tokens_to_hint) return { kind: 'skip' }; + return { kind: 'hint', idleSeconds: Math.floor(idleMs / 1000), totalTokens }; +} + +/** `45m` / `3h 20m` / `26d 22h`. */ +export function formatIdleDuration(idleSeconds: number): string { + const minutes = Math.max(1, Math.floor(idleSeconds / 60)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const restMinutes = minutes % 60; + return restMinutes === 0 ? `${hours}h` : `${hours}h ${restMinutes}m`; + } + const days = Math.floor(hours / 24); + const restHours = hours % 24; + return restHours === 0 ? `${days}d` : `${days}d ${restHours}h`; +} diff --git a/apps/pythinker-code/src/tui/utils/event-payload.ts b/apps/pythinker-code/src/tui/utils/event-payload.ts index a07edaf2..6f0c5fd1 100644 --- a/apps/pythinker-code/src/tui/utils/event-payload.ts +++ b/apps/pythinker-code/src/tui/utils/event-payload.ts @@ -1,12 +1,9 @@ +import { isPythinkerError } from '@pymodel/pythinker-code-sdk'; + import { STREAMING_ARGS_FIELD_RE, STREAMING_ARGS_PREVIEW_MAX_CHARS, } from '#/tui/constant/streaming'; -import type { TodoItem } from '#/tui/components/chrome/todo-panel'; - -// Generic error formatting lives in the SDK so non-TUI surfaces (login flows, -// CLI) share it; re-exported here for the TUI's existing importers. -export { formatErrorMessage, formatErrorPayload } from '@pymodel/pythinker-code-sdk'; export function appendStreamingArgsPreview( current: string | undefined, @@ -83,37 +80,56 @@ export function serializeToolResultOutput(output: unknown): string { return JSON.stringify(output, null, 2); } -export function normalizeTodoList(value: unknown): TodoItem[] { - // Replay/live tool payloads can come from either TodoList or the older - // TodoWrite-style contract; normalize both before the TUI renders them. - if (!Array.isArray(value)) return []; - const todos = value.flatMap((item) => { - if (typeof item !== 'object' || item === null) return []; - const record = item as Record<string, unknown>; - const title = - typeof record['title'] === 'string' && record['title'].length > 0 - ? record['title'] - : typeof record['content'] === 'string' && record['content'].length > 0 - ? record['content'] - : undefined; - if (title === undefined) return []; +export function isTodoItemShape( + value: unknown, +): value is { title: string; status: 'pending' | 'in_progress' | 'done' } { + if (typeof value !== 'object' || value === null) return false; + const rec = value as { title?: unknown; status?: unknown }; + if (typeof rec.title !== 'string' || rec.title.length === 0) return false; + return rec.status === 'pending' || rec.status === 'in_progress' || rec.status === 'done'; +} - const rawStatus = record['status']; - const status: TodoItem['status'] | undefined = - rawStatus === 'completed' - ? 'done' - : rawStatus === 'pending' || rawStatus === 'in_progress' || rawStatus === 'done' - ? rawStatus - : undefined; - if (status === undefined) return []; +export function formatErrorMessage(error: unknown): string { + if (isPythinkerError(error)) { + return formatErrorPayload({ + code: error.code, + message: error.message, + details: error.details, + }); + } + return error instanceof Error ? error.message : String(error); +} - const activeForm = - typeof record['activeForm'] === 'string' && record['activeForm'].length > 0 - ? record['activeForm'] - : undefined; - return [{ title, activeForm, status }]; - }); - return todos.length > 0 && todos.every((todo) => todo.status === 'done') ? [] : todos; +interface ErrorPayloadLike { + readonly code: string; + readonly message: string; + readonly details?: Record<string, unknown>; +} + +export function formatErrorPayload(error: ErrorPayloadLike): string { + const filteredMessage = formatProviderFilteredMessage(error.details); + if (filteredMessage !== undefined) return `[${error.code}] ${filteredMessage}`; + return `[${error.code}] ${error.message}`; +} + +function formatProviderFilteredMessage( + details: Record<string, unknown> | undefined, +): string | undefined { + const finishReason = stringDetail(details, 'finishReason'); + const rawFinishReason = stringDetail(details, 'rawFinishReason'); + if (finishReason !== 'filtered' && rawFinishReason !== 'content_filter') return undefined; + + const normalizedFinishReason = finishReason ?? 'filtered'; + const raw = rawFinishReason === undefined ? '' : `, rawFinishReason=${rawFinishReason}`; + return `Provider filtered the response before visible output (finishReason=${normalizedFinishReason}${raw}).`; +} + +function stringDetail( + details: Record<string, unknown> | undefined, + key: string, +): string | undefined { + const value = details?.[key]; + return typeof value === 'string' ? value : undefined; } export function stringValue(value: unknown): string | undefined { diff --git a/apps/pythinker-code/src/tui/utils/export-markdown.ts b/apps/pythinker-code/src/tui/utils/export-markdown.ts index b1c0ea32..fc8f75a4 100644 --- a/apps/pythinker-code/src/tui/utils/export-markdown.ts +++ b/apps/pythinker-code/src/tui/utils/export-markdown.ts @@ -139,6 +139,9 @@ function formatTurnMd(messages: readonly ContextMessage[], turnNumber: number): if (msg.role === 'user') { lines.push('### User', ''); + // A daemon-ref media part is self-contained and renders as + // `[image]`/`[video]` below; a standalone `<media path>` tag is user + // text and exports verbatim. for (const part of msg.content) { const text = formatContentPartMd(part); if (text.trim()) { @@ -236,7 +239,8 @@ export function buildExportMarkdown(input: BuildExportMarkdownInput): string { ]; const turns = groupIntoTurns(history); - lines.push(buildOverview(history, turns), ''); + lines.push(buildOverview(history, turns)); + lines.push(''); for (let i = 0; i < turns.length; i++) { lines.push(formatTurnMd(turns[i]!, i + 1)); diff --git a/apps/pythinker-code/src/tui/utils/foreground-task.ts b/apps/pythinker-code/src/tui/utils/foreground-task.ts new file mode 100644 index 00000000..cc60b65f --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/foreground-task.ts @@ -0,0 +1,32 @@ +import type { BackgroundTaskInfo } from '@pymodel/pythinker-code-sdk'; + +function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { + return ( + t.detached === false && + t.status === 'running' && + (t.kind === 'process' || t.kind === 'agent') + ); +} + +/** + * Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`, + * currently-running Bash (`process`) or subagent (`agent`) tasks, most recently + * started first. + */ +export function pickForegroundTasks( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo[] { + return tasks + .filter(isDetachableForegroundTask) + .sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * Pick the single most recently started foreground task. Kept for callers that + * only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all. + */ +export function pickForegroundTask( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo | undefined { + return pickForegroundTasks(tasks)[0]; +} diff --git a/apps/pythinker-code/src/tui/utils/image-attachment-store.ts b/apps/pythinker-code/src/tui/utils/image-attachment-store.ts index f837dc1e..9316c360 100644 --- a/apps/pythinker-code/src/tui/utils/image-attachment-store.ts +++ b/apps/pythinker-code/src/tui/utils/image-attachment-store.ts @@ -6,7 +6,10 @@ * (640×480)]` / `[video #2 sample.mov]`). The placeholder is what the * user sees in the input field; on submit, `extractMediaAttachments` * walks the text and expands image placeholders to image content parts - * and video placeholders to file-path tags for `ReadMediaFile`. + * (dispatch-time caption resolution then precedes them with a compression + * caption when paste-time compression shrank the bytes — see + * `ImageAttachment.original`) and video placeholders to file-path tags + * for `ReadMediaFile`. * * Scope is per-`PythinkerTUI` instance. Reloads (`/new`, `/clear`, * session switch) call `clear()` so ids restart from 1 and stale @@ -15,6 +18,28 @@ * `--resume` wouldn't know how to materialize the files anyway. */ +export interface ImageAttachmentOriginal { + /** + * Pre-compression bytes, kept in memory until dispatch-time caption + * resolution (`resolveOriginalCaptions`) persists them — the session whose + * media-originals dir they belong in may not exist yet at paste time. + * Released once persistence succeeds; the on-disk copy is the original + * from then on. + */ + bytes?: Uint8Array; + readonly width: number; + readonly height: number; + /** Pre-compression size, retained for captions after `bytes` is released. */ + readonly byteLength: number; + readonly mime: string; + /** + * Where the original was persisted for readback (ReadMediaFile + region). + * Undefined until dispatch-time persistence succeeds; failures are retried + * at the next dispatch. + */ + path?: string; +} + export interface ImageAttachment { readonly id: number; readonly kind: 'image'; @@ -22,6 +47,30 @@ export interface ImageAttachment { readonly mime: string; readonly width: number; readonly height: number; + /** + * Pre-compression original, recorded when paste-time compression changed + * the bytes. Drives the compression caption authored on dispatch so the + * model knows it received a downsampled copy. Absent for untouched pastes. + */ + readonly original?: ImageAttachmentOriginal | undefined; + /** + * Daemon file-store id, set when the bytes were uploaded at paste time + * (v2 engine only). Submit-time expansion then emits a `pythinker-file://` + * reference plus an `<image path>` tag instead of inline base64; absent + * means the inline form is used. + */ + fileId?: string; + /** Epoch milliseconds when the daemon staging upload expires. */ + fileExpiresAt?: number; + /** + * Background ingestion (compression/daemon upload) still in flight. The + * paste callback settles once the placeholder is in the editor — typing + * never waits on this — but submit holds it briefly + * (`pendingImageIngestions`) so a fast paste-then-Enter still gets the + * compressed/ref form; a slow ingestion submits the inline form instead. + * Cleared when ingestion completes. + */ + pending?: Promise<void>; /** Rendered placeholder string, e.g. `[image #1 (640×480)]`. */ readonly placeholder: string; } @@ -39,11 +88,28 @@ export interface VideoAttachment { export type MediaAttachment = ImageAttachment | VideoAttachment; +type MutableImageAttachment = { + -readonly [Property in keyof ImageAttachment]: ImageAttachment[Property]; +}; + +type MutableVideoAttachment = { + -readonly [Property in keyof VideoAttachment]: VideoAttachment[Property]; +}; + export class ImageAttachmentStore { private nextId = 1; private readonly byId = new Map<number, MediaAttachment>(); + private readonly stagingUses = new Map<number, number>(); - addImage(bytes: Uint8Array, mime: string, width: number, height: number): ImageAttachment { + addImage( + bytes: Uint8Array, + mime: string, + width: number, + height: number, + original?: ImageAttachmentOriginal, + fileId?: string, + fileExpiresAt?: number, + ): ImageAttachment { const id = this.nextId; this.nextId += 1; const attachment: ImageAttachment = { @@ -53,6 +119,9 @@ export class ImageAttachmentStore { mime, width, height, + original, + fileId, + fileExpiresAt, placeholder: formatPlaceholder(id, width, height), }; this.byId.set(id, attachment); @@ -79,13 +148,150 @@ export class ImageAttachmentStore { return attachment; } + /** + * Complete an image that was inserted into the editor before its ingestion + * work (compression/upload) finished. Returns undefined when the attachment + * was cleared while that work was in flight. + */ + completeImage( + attachment: ImageAttachment, + input: { + bytes: Uint8Array; + mime: string; + width: number; + height: number; + original?: ImageAttachmentOriginal; + fileId?: string; + fileExpiresAt?: number; + }, + ): ImageAttachment | undefined { + const current = this.byId.get(attachment.id); + if (current !== attachment || attachment.kind !== 'image') return undefined; + const mutable = attachment as MutableImageAttachment; + mutable.bytes = input.bytes; + mutable.mime = input.mime; + mutable.width = input.width; + mutable.height = input.height; + mutable.original = input.original; + mutable.fileId = input.fileId; + mutable.fileExpiresAt = input.fileExpiresAt; + mutable.pending = undefined; + mutable.placeholder = formatPlaceholder(attachment.id, input.width, input.height); + return attachment; + } + + /** + * Record where an attachment's pre-compression original was persisted and + * release the in-memory buffer — the on-disk copy is the original from + * then on, and the caption only needs the retained metadata. Dispatch-time + * caption resolution calls this after a successful write; failures leave + * the path unset so a later dispatch retries. + */ + setOriginalPath(id: number, path: string): void { + const attachment = this.byId.get(id); + if (attachment?.kind !== 'image' || attachment.original === undefined) return; + attachment.original.path = path; + attachment.original.bytes = undefined; + } + get(id: number): MediaAttachment | undefined { return this.byId.get(id); } - clear(): void { + clear(): readonly string[] { + const fileIds = this.fileIds(); this.byId.clear(); + this.stagingUses.clear(); this.nextId = 1; + return fileIds; + } + + /** + * Drop a single attachment, releasing its bytes. Used to reclaim image + * memory once the transcript entry that references it is trimmed. + */ + remove(id: number): string | undefined { + const attachment = this.byId.get(id); + const fileId = attachment?.kind === 'image' ? attachment.fileId : undefined; + this.byId.delete(id); + this.stagingUses.delete(id); + return fileId; + } + + /** Drop many attachments at once. See {@link remove}. */ + removeMany(ids: Iterable<number>): readonly string[] { + const fileIds: string[] = []; + for (const id of ids) { + const fileId = this.remove(id); + if (fileId !== undefined) fileIds.push(fileId); + } + return fileIds; + } + + retainFileIds(ids: Iterable<number>): void { + const retained = new Set<number>(); + for (const id of ids) { + if (retained.has(id)) continue; + retained.add(id); + const attachment = this.byId.get(id); + if (attachment?.kind !== 'image' || attachment.fileId === undefined) continue; + this.stagingUses.set(id, (this.stagingUses.get(id) ?? 0) + 1); + } + } + + takeFileIds(ids: Iterable<number>): readonly string[] { + const fileIds: string[] = []; + const taken = new Set<number>(); + for (const id of ids) { + if (taken.has(id)) continue; + taken.add(id); + const attachment = this.byId.get(id); + if (attachment?.kind !== 'image' || attachment.fileId === undefined) continue; + const uses = this.stagingUses.get(id) ?? 0; + if (uses > 1) { + this.stagingUses.set(id, uses - 1); + continue; + } + this.stagingUses.delete(id); + fileIds.push(attachment.fileId); + attachment.fileId = undefined; + attachment.fileExpiresAt = undefined; + } + return fileIds; + } + + /** + * Consume the retains a recalled submission held WITHOUT taking the staged + * files: the recalled draft still references the attachments, so their + * daemon uploads stay alive and the next submit re-retains them. Used by + * queue recall; every other release path goes through {@link takeFileIds}. + */ + releaseRetains(ids: Iterable<number>): void { + const released = new Set<number>(); + for (const id of ids) { + if (released.has(id)) continue; + released.add(id); + const uses = this.stagingUses.get(id) ?? 0; + if (uses > 1) this.stagingUses.set(id, uses - 1); + else this.stagingUses.delete(id); + } + } + + /** + * Repoint a recalled video at its staged cache copy: the original source + * (e.g. a clipboard temp file) may be gone by the time the restored draft + * is resubmitted, and re-extraction re-materializes from `sourcePath`. + */ + rebaseVideoSource(id: number, sourcePath: string): void { + const attachment = this.byId.get(id); + if (attachment?.kind !== 'video') return; + (attachment as MutableVideoAttachment).sourcePath = sourcePath; + } + + private fileIds(): readonly string[] { + return [...this.byId.values()] + .filter((attachment): attachment is ImageAttachment => attachment.kind === 'image') + .flatMap((attachment) => attachment.fileId ?? []); } size(): number { diff --git a/apps/pythinker-code/src/tui/utils/image-placeholder.ts b/apps/pythinker-code/src/tui/utils/image-placeholder.ts index 4be9d7db..b7f6a303 100644 --- a/apps/pythinker-code/src/tui/utils/image-placeholder.ts +++ b/apps/pythinker-code/src/tui/utils/image-placeholder.ts @@ -1,22 +1,59 @@ /** - * Scan submitted text for media placeholders and produce - * the `PromptPart[]` we'll send to the SDK prompt endpoint. + * Scan submitted text for media placeholders and produce the prompt content + * we'll send to the SDK prompt endpoint. * - * Rules: + * `extractMediaAttachments` (sync) is the single expansion path for prompts: + * - image placeholders expand to inline image content parts. When the paste + * was uploaded to the daemon file store (`ImageAttachment.fileId`, v2 + * engine only), the placeholder instead expands to a bare + * `pythinker-file://<id>` image part — the engine's prompt intake materializes + * the session copy and rewrites the reference with its `?path=`, making + * the part self-contained (no paired tag is authored); without a `fileId` + * the inline base64 form is emitted unchanged (the only form the v1 + * engine accepts). Compression captions for paste-time-downsampled images + * are NOT authored here: extraction runs before a first session exists, + * so `resolveOriginalCaptions` adds them at dispatch time, persisting the + * in-memory original (`ImageAttachment.original`) into the session's + * media-originals dir first; + * - video placeholders are copied into the shared cache (`getCacheDir()`) + * and expand to a `video_url` part pointing at the cache copy with a + * `file://` url. The v1 engine resolves that local reference inside the + * turn — uploading it (the `ms://` inline form) or degrading to a + * `<video path>` tag the model reads with `ReadMediaFile` — before the + * prompt lands in history. + * + * `rewriteMediaPlaceholders` is the separate text channel for slash-command + * args (`/skill`, plugin commands): those are plain text, so media is rendered + * as a `<video|image path="…">` tag / plain-text reference into cache-dir + * copies the model opens with `ReadMediaFile`. + * + * Rules for both: * - Only placeholders that resolve against `store` get extracted. * A literal `[image #999 ...]` the user typed themselves stays in * the text (we can't hallucinate files for it). - * - Order is preserved for text/image/video segments. Image placeholders - * expand to image content parts so the prompt reaches the provider - * without relying on a model tool call. Video placeholders still expand - * to file-path tags so `ReadMediaFile` can own video upload behavior. + * - Order is preserved for text/image/video segments. * - Adjacent text segments are flattened — empty / whitespace-only * segments drop out so we never emit `{type:'text', text:' '}` * noise between two media parts. */ -import type { PromptPart } from '@pymodel/pythinker-code-sdk'; +import { createHash, randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { PromptPart, Session } from '@pymodel/pythinker-code-sdk'; +import { + buildDaemonFileUrl, + buildImageCompressionCaption, + buildMediaPathTag, + sessionMediaOriginalsDir, +} from '@pymodel/pythinker-code-sdk'; + +import { getCacheDir } from '#/utils/paths'; +import { IMAGE_FILE_REF_MIN_REMAINING_MS } from '../constant/media'; import type { ImageAttachment, ImageAttachmentStore, @@ -37,6 +74,38 @@ export interface ExtractionResult { imageAttachmentIds: number[]; /** Video attachment ids matched, in the order they appeared. */ videoAttachmentIds: number[]; + /** + * Image bytes captured while extracting the prompt. A cache-hint resend can + * outlive the attachment store and daemon file ids, so it uses these + * snapshots to rebuild the image parts as inline data URLs. + */ + imageSnapshots: ImageResendSnapshot[]; + /** + * Cache copies staged by this submission. Lifecycle is owned by the + * StagingLeaseTracker: deleted immediately when the submission is + * abandoned, retired to session lifetime once a turn consumes them + * (persisted history may still reference their paths). + */ + stagingPaths: string[]; +} + +export interface ImageResendSnapshot { + readonly bytes: Uint8Array; + readonly mime: string; + readonly width: number; + readonly height: number; + /** + * Pre-compression original captured at extraction, so a new-session resend + * can still persist it and author the compression caption after the image + * store (and its attachments) was cleared. Absent for untouched pastes and + * for originals already persisted and released. + */ + readonly original?: { + readonly bytes: Uint8Array; + readonly width: number; + readonly height: number; + readonly mime: string; + }; } export function extractMediaAttachments( @@ -46,47 +115,358 @@ export function extractMediaAttachments( const parts: PromptPart[] = []; const imageAttachmentIds: number[] = []; const videoAttachmentIds: number[] = []; + const imageSnapshots: ImageResendSnapshot[] = []; + const stagingPaths: string[] = []; let cursor = 0; let hasMedia = false; + try { + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + const before = text.slice(cursor, match.index); + pushText(parts, before); + if (attachment.kind === 'video') { + // Copy the paste into the shared cache and reference it by a `file://` + // url; the engine resolves (uploads or degrades) it inside the turn. + const cachePath = materializeVideoToCache(attachment); + stagingPaths.push(cachePath); + parts.push(videoPartForCachePath(cachePath)); + videoAttachmentIds.push(id); + } else { + const original = attachment.original; + imageSnapshots.push({ + bytes: attachment.bytes, + mime: attachment.mime, + width: attachment.width, + height: attachment.height, + original: + original?.bytes === undefined + ? undefined + : { + bytes: original.bytes, + width: original.width, + height: original.height, + mime: original.mime, + }, + }); + // No compression caption here: `resolveOriginalCaptions` authors it + // at dispatch time, once the session (and its media-originals dir) + // is known. + if (attachment.fileId !== undefined) { + // The bytes were uploaded to the daemon file store at paste time + // (v2): reference them by a bare `pythinker-file://` url — the engine's + // prompt intake materializes the session copy and rewrites the + // reference with its `?path=`, so the edge stages no local copy. + parts.push({ + type: 'image_url', + imageUrl: { url: buildDaemonFileUrl(attachment.fileId) }, + }); + } else { + parts.push(imagePartForAttachment(attachment)); + } + imageAttachmentIds.push(id); + } + hasMedia = true; + cursor = match.index + literal.length; + } + const tail = text.slice(cursor); + pushText(parts, tail); + + store.retainFileIds(imageAttachmentIds); + const freshParts = refreshExpiringImageFileRefs(parts, imageAttachmentIds, store); + return { + // Text-only submissions drop the synthesised parts array — the + // caller's contract is "parts is meaningful iff hasMedia", and + // emitting a stray TextPart confuses consumers that branch on + // `parts.length > 0`. + parts: hasMedia ? freshParts : [], + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + imageSnapshots, + stagingPaths, + }; + } catch (error) { + cleanupStagingPaths(stagingPaths); + throw error; + } +} + +/** + * The video attachment ids referenced by `text`, in placeholder order — the + * same order extraction staged their cache copies in, so callers can zip the + * result with a submission's `stagingPaths`. + */ +export function videoAttachmentIdsInText(text: string, store: ImageAttachmentStore): number[] { + const ids: number[] = []; PLACEHOLDER_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { - const [literal, kind, idStr] = match; - if (kind !== 'image' && kind !== 'video') continue; - if (idStr === undefined) continue; + const [, kind, idStr] = match; + if (kind !== 'video' || idStr === undefined) continue; const id = Number.parseInt(idStr, 10); - const attachment = store.get(id); - if (attachment === undefined) continue; // stale / user-typed — leave as text - if (attachment.kind !== kind) continue; - const before = text.slice(cursor, match.index); - pushText(parts, before); - if (attachment.kind === 'video') { - const mediaText = tagTextForVideo(attachment); - pushText(parts, mediaText); - videoAttachmentIds.push(id); - } else { - parts.push(imagePartForAttachment(attachment)); - imageAttachmentIds.push(id); + if (store.get(id)?.kind === 'video') ids.push(id); + } + return ids; +} + +/** + * Give images referenced by `text` a bounded moment to finish their + * background paste ingestion (compression/upload — see `ImageAttachment.pending`) + * before extraction, so a paste-then-immediately-submit still expands to the + * compressed/daemon-ref form. The returned promise resolves after `timeoutMs` + * at the latest; whatever has not landed by then simply extracts to the + * inline fallback form. Returns undefined when nothing is pending, so the + * submit path stays synchronous for media-free prompts. + */ +export function pendingImageIngestions( + text: string, + store: ImageAttachmentStore, + timeoutMs: number, +): Promise<void> | undefined { + const pendingPromises: Promise<void>[] = []; + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [, kind, idStr] = match; + if (kind !== 'image' || idStr === undefined) continue; + const attachment = store.get(Number.parseInt(idStr, 10)); + if (attachment?.kind === 'image' && attachment.pending !== undefined) { + pendingPromises.push(attachment.pending); + } + } + if (pendingPromises.length === 0) return undefined; + let timer: ReturnType<typeof setTimeout> | undefined; + return Promise.race([ + Promise.allSettled(pendingPromises).then(() => undefined), + new Promise<void>((resolve) => { + timer = setTimeout(resolve, timeoutMs); + }), + ]).finally(() => { + clearTimeout(timer); + }); +} + +/** + * Replace daemon refs that may expire before validation reaches the server + * with the attachment's retained bytes. Called both at extraction time and + * again when a queued/cache-hint submission is actually dispatched. + */ +export function refreshExpiringImageFileRefs( + parts: readonly PromptPart[], + imageAttachmentIds: readonly number[], + store: ImageAttachmentStore, + now = Date.now(), +): PromptPart[] { + if (imageAttachmentIds.length === 0) return [...parts]; + let imageIndex = 0; + let changed = false; + const next = parts.map((part) => { + if (part.type !== 'image_url') return part; + const attachmentId = imageAttachmentIds[imageIndex++]; + if (attachmentId === undefined || !part.imageUrl.url.startsWith('pythinker-file://')) return part; + const attachment = store.get(attachmentId); + if (attachment?.kind !== 'image') return part; + + const fileId = attachment.fileId; + const expiresAt = attachment.fileExpiresAt; + const usable = + fileId !== undefined && + (expiresAt === undefined || expiresAt - now > IMAGE_FILE_REF_MIN_REMAINING_MS); + if (usable) { + const url = buildDaemonFileUrl(fileId); + if (url === part.imageUrl.url) return part; + changed = true; + return { ...part, imageUrl: { ...part.imageUrl, url } }; + } + + attachment.fileId = undefined; + attachment.fileExpiresAt = undefined; + changed = true; + return imagePartForAttachment(attachment); + }); + return changed ? next : [...parts]; +} + +/** + * Make an extraction safe to resend after a session reset. The reset clears + * the image store and deletes daemon file ids, so uploaded image refs must be + * replaced with the bytes captured during the original extraction. Cache + * paths are intentionally preserved: they are carried by the resend's new + * staging lease and remain available to any path tag in the prompt. + * + * Snapshots of compressed pastes also carry the pre-compression original: the + * cleared store took the attachment with it, so dispatch-time caption + * resolution can no longer find either. `makeExtractionResendable` persists + * that original into `originalsDir` (the NEW session's media-originals dir; + * temp-dir fallback when undefined) and authors the compression caption + * itself, right before the rebuilt image part. + */ +export function makeExtractionResendable( + extraction: ExtractionResult, + originalsDir?: string, +): ExtractionResult { + if (extraction.imageSnapshots.length === 0) return extraction; + + let imageIndex = 0; + const parts: PromptPart[] = []; + for (const part of extraction.parts) { + if (part.type !== 'image_url') { + parts.push(part); + continue; + } + const snapshot = extraction.imageSnapshots[imageIndex++]; + const original = snapshot?.original; + if (snapshot !== undefined && original !== undefined) { + parts.push({ + type: 'text', + text: buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.bytes.length, + mimeType: original.mime, + }, + final: { + width: snapshot.width, + height: snapshot.height, + byteLength: snapshot.bytes.length, + mimeType: snapshot.mime, + }, + originalPath: persistOriginalImageSync(original.bytes, original.mime, originalsDir), + }), + }); } - hasMedia = true; - cursor = match.index + literal.length; + if (snapshot === undefined || !part.imageUrl.url.startsWith('pythinker-file://')) { + parts.push(part); + continue; + } + parts.push({ + ...part, + imageUrl: { + ...part.imageUrl, + url: `data:${snapshot.mime};base64,${Buffer.from(snapshot.bytes).toString('base64')}`, + }, + }); } - const tail = text.slice(cursor); - pushText(parts, tail); return { - // Text-only submissions drop the synthesised parts array — the - // caller's contract is "parts is meaningful iff hasMedia", and - // emitting a stray TextPart confuses consumers that branch on - // `parts.length > 0`. - parts: hasMedia ? parts : [], - hasMedia, - imageAttachmentIds, - videoAttachmentIds, + ...extraction, + parts, + // The new session's store no longer contains these ids. The rebuilt parts + // carry their own bytes, so keeping stale ids would break thumbnail and + // later cleanup lookups. + imageAttachmentIds: [], }; } +export interface MediaTagRewriteResult { + /** Input text with resolved placeholders replaced by media references. */ + text: string; + hasMedia: boolean; + imageAttachmentIds: number[]; + videoAttachmentIds: number[]; + stagingPaths: string[]; +} + +/** + * How a resolved placeholder is rendered into command args: + * - `'tag'`: the `<image|video path="…"></…>` convention, for channels + * that pass args through verbatim (plugin commands). + * - `'plain'`: a plain-text file reference with no XML tag/attribute + * boundary characters, for channels that XML-escape args (`/skill` + * args are escaped by both `renderSkillAttributes` and + * `expandSkillParameters`, which would mangle the tag form). + */ +export type MediaReferenceStyle = 'tag' | 'plain'; + +/** + * Rewrite media placeholders in slash-command args (`/skill:foo …`, + * plugin commands) into references pointing at cache-dir copies. Command + * args are a plain-text channel — unlike `extractMediaAttachments`, which + * inlines image parts for the prompt endpoint — so the model reaches the + * media through `ReadMediaFile` instead, the same way it already handles + * pasted videos. + * + * Surrounding text is preserved verbatim (args are user content, not + * LLM parts), and unresolved placeholders stay literal. + */ +export function rewriteMediaPlaceholders( + text: string, + store: ImageAttachmentStore, + style: MediaReferenceStyle = 'tag', +): MediaTagRewriteResult { + const imageAttachmentIds: number[] = []; + const videoAttachmentIds: number[] = []; + const stagingPaths: string[] = []; + let cursor = 0; + let out = ''; + + try { + PLACEHOLDER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) { + const [literal, kind, idStr] = match; + if (kind !== 'image' && kind !== 'video') continue; + if (idStr === undefined) continue; + const id = Number.parseInt(idStr, 10); + const attachment = store.get(id); + if (attachment === undefined) continue; // stale / user-typed — leave as text + if (attachment.kind !== kind) continue; + out += text.slice(cursor, match.index); + if (attachment.kind === 'video') { + const path = materializeVideoToCache(attachment, style === 'plain'); + stagingPaths.push(path); + out += + style === 'plain' + ? formatMediaReference('video', path) + : buildMediaPathTag('video', path); + videoAttachmentIds.push(id); + } else { + const path = materializeImageToCache(attachment); + stagingPaths.push(path); + out += + style === 'plain' + ? formatMediaReference('image', path) + : buildMediaPathTag('image', path); + imageAttachmentIds.push(id); + } + cursor = match.index + literal.length; + } + + const hasMedia = imageAttachmentIds.length + videoAttachmentIds.length > 0; + store.retainFileIds(imageAttachmentIds); + return { + text: hasMedia ? out + text.slice(cursor) : text, + hasMedia, + imageAttachmentIds, + videoAttachmentIds, + stagingPaths, + }; + } catch (error) { + cleanupStagingPaths(stagingPaths); + throw error; + } +} + +function cleanupStagingPaths(paths: readonly string[]): void { + for (const path of paths) { + try { + unlinkSync(path); + } catch { + // Best effort: a failed copy may not have created the target. + } + } +} + function pushText(parts: PromptPart[], segment: string): void { if (segment.length === 0) return; // Keep whitespace-only segments only when they sit between non-empty @@ -101,7 +481,7 @@ function pushText(parts: PromptPart[], segment: string): void { parts.push({ type: 'text', text: segment }); } -function imagePartForAttachment(att: ImageAttachment): PromptPart { +function imagePartForAttachment(att: ImageAttachment): Extract<PromptPart, { type: 'image_url' }> { const base64 = Buffer.from(att.bytes).toString('base64'); return { type: 'image_url', @@ -109,18 +489,235 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } -function tagTextForVideo(att: VideoAttachment): string { - return formatMediaTag('video', att.sourcePath); +/** + * Is this image part still what the attachment holds? Extraction encodes the + * attachment as of extraction time; a paste whose background ingestion + * (compression/daemon upload) landed afterwards mutated it, leaving the part + * carrying the pre-compression form — which no caption may describe. + */ +function imagePartMatchesAttachment( + part: Extract<PromptPart, { type: 'image_url' }>, + attachment: ImageAttachment, +): boolean { + const url = part.imageUrl.url; + if (url.startsWith('pythinker-file://')) { + return attachment.fileId !== undefined && url === buildDaemonFileUrl(attachment.fileId); + } + return url === imagePartForAttachment(attachment).imageUrl.url; +} + +/** + * A `video_url` prompt part pointing at a cache copy by `file://` url. The v1 + * engine resolves the local reference in-turn (upload → `ms://`, or degrade to + * a `<video path>` tag) before it reaches the model or the persisted history. + */ +function videoPartForCachePath(cachePath: string): PromptPart { + return { + type: 'video_url', + videoUrl: { url: pathToFileURL(cachePath).href }, + }; +} + +function materializeVideoToCache(att: VideoAttachment, escapeProofName = false): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // The label permits XML boundary chars (`<>&"`); plain references go + // through skill-arg escaping, where they would no longer match the file + // on disk, so strip them from the cache name in that mode. + const label = escapeProofName ? att.label.replaceAll(/[<>&"]/g, '_') : att.label; + const target = join(cacheDir, `${randomUUID()}-${label}`); + copyFileSync(att.sourcePath, target); + return target; } -function formatMediaTag(tag: 'image' | 'video', path: string): string { - return `<${tag} path="${escapeAttribute(path)}"></${tag}>`; +const IMAGE_MIME_EXTENSION: Readonly<Record<string, string>> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tif', +}; + +/** + * File-extension hint for an image MIME (`image/png` → `png`). The real + * format is always sniffed from the bytes, so this only names files (cache + * copies, daemon upload labels). + */ +export function imageExtensionForMime(mime: string): string { + return IMAGE_MIME_EXTENSION[mime.trim().toLowerCase()] ?? 'img'; +} + +function materializeImageToCache(att: ImageAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + // ReadMediaFile sniffs the real format from the bytes, so the extension + // only needs to be a reasonable hint. + const target = join(cacheDir, `${randomUUID()}.${imageExtensionForMime(att.mime)}`); + writeFileSync(target, att.bytes); + return target; } -function escapeAttribute(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('<', '<') - .replaceAll('>', '>'); +/** Opening every compression caption starts with (see buildImageCompressionCaption). */ +const CAPTION_OPENING = '<system>Image compressed to fit model limits:'; + +/** + * The session-owned originals store for compression captions, when the + * session's dir is known; undefined falls back to the shared temp dir. + */ +export function originalsDirForSession(session: Session | undefined): string | undefined { + const sessionDir = session?.summary?.sessionDir; + return sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir); +} + +/** + * Author a compression caption before every referenced image whose paste-time + * compression shrank the bytes, persisting not-yet-persisted originals into + * `originalsDir` (the session's media-originals dir; the shared temp-dir + * fallback when undefined) so the caption points at a real readback path. + * + * Extraction deliberately does not do this: it can run before the session + * exists (first submit creates it lazily), and the original belongs with the + * session — owned by it, cleaned up with it, immune to OS temp reaping. The + * dispatch paths call this once the session is known. Synchronous because + * those paths cannot await; the write is a single small file, same as the + * cache copies extraction itself stages. Idempotent: an image already + * preceded by a compression caption gets it refreshed in place, so a + * re-resolved part list never grows a duplicate. + */ +export function resolveOriginalCaptions( + parts: readonly PromptPart[], + imageAttachmentIds: readonly number[], + store: ImageAttachmentStore, + originalsDir: string | undefined, +): PromptPart[] { + let imageIndex = 0; + let changed = false; + const out: PromptPart[] = []; + for (const part of parts) { + if (part.type !== 'image_url') { + out.push(part); + continue; + } + const attachmentId = imageAttachmentIds[imageIndex++]; + const attachment = attachmentId === undefined ? undefined : store.get(attachmentId); + if (attachment?.kind !== 'image' || attachment.original === undefined) { + out.push(part); + continue; + } + // The part was encoded from the attachment at extraction; a paste whose + // background ingestion landed afterwards mutated it (compressed bytes, + // daemon file id), leaving the part carrying the pre-compression form. + // Caption only when the two still agree — otherwise the caption would + // describe an image the model did not receive. + if (!imagePartMatchesAttachment(part, attachment)) { + out.push(part); + continue; + } + const original = attachment.original; + if (original.path === undefined && original.bytes !== undefined) { + // A persistence failure (unwritable dir, full disk) leaves the path + // unset — and the bytes retained — so a later dispatch retries; this + // dispatch captions without a readback path. + const path = persistOriginalImageSync(original.bytes, original.mime, originalsDir); + if (path !== null) store.setOriginalPath(attachment.id, path); + } + const caption = buildImageCompressionCaption({ + original: { + width: original.width, + height: original.height, + byteLength: original.byteLength, + mimeType: original.mime, + }, + final: { + width: attachment.width, + height: attachment.height, + byteLength: attachment.bytes.length, + mimeType: attachment.mime, + }, + originalPath: original.path, + }); + const previous = out.at(-1); + if (previous?.type === 'text' && previous.text.startsWith(CAPTION_OPENING)) { + out[out.length - 1] = { type: 'text', text: caption }; + } else { + out.push({ type: 'text', text: caption }); + } + changed = true; + out.push(part); + } + return changed ? out : [...parts]; +} + +/** + * Synchronous twin of the engine's `persistOriginalImage` — same + * content-addressed naming and the same size-capped eviction: the dispatch + * paths that resolve captions cannot await. Exported for tests; production + * callers go through `resolveOriginalCaptions` / `makeExtractionResendable`. + */ +export function persistOriginalImageSync( + bytes: Uint8Array, + mime: string, + dir: string | undefined, + maxTotalBytes = DEFAULT_MAX_TOTAL_BYTES, +): string | null { + if (bytes.length === 0) return null; + try { + const targetDir = dir ?? originalImageTempDir(); + const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 32); + const target = join(targetDir, `${hash}.${imageExtensionForMime(mime)}`); + mkdirSync(targetDir, { recursive: true }); + const existing = statSync(target, { throwIfNoEntry: false }); + // Content-addressed: an existing entry with the right size IS this image. + if (existing === undefined || existing.size !== bytes.length) { + writeFileSync(target, bytes); + } + sweepCacheSync(targetDir, maxTotalBytes); + // The just-written file may itself have been evicted by the sweep when a + // single original exceeds the cap; report persistence honestly. + return statSync(target, { throwIfNoEntry: false }) === undefined ? null : target; + } catch { + return null; + } +} + +/** Per-store ceiling; mirrors the engine originals store. */ +const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; // 1 GiB + +/** Evict oldest files (by mtime) until the store fits `maxTotalBytes`. */ +function sweepCacheSync(dir: string, maxTotalBytes: number): void { + const entries: { path: string; size: number; mtimeMs: number }[] = []; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const info = statSync(path, { throwIfNoEntry: false }); + if (info === undefined || !info.isFile()) continue; + entries.push({ path, size: info.size, mtimeMs: info.mtimeMs }); + } + let total = entries.reduce((sum, entry) => sum + entry.size, 0); + if (total <= maxTotalBytes) return; + entries.sort((a, b) => a.mtimeMs - b.mtimeMs); + for (const entry of entries) { + if (total <= maxTotalBytes) break; + try { + unlinkSync(entry.path); + total -= entry.size; + } catch { + // Best effort, mirroring the async twin. + } + } +} + +/** Mirrors agent-core's `originalImageCacheDir` (not re-exported through the SDK). */ +function originalImageTempDir(): string { + return join(tmpdir(), 'pythinker-code-original-images'); +} + +/** + * Plain-text media reference for channels that XML-escape args (`/skill`). + * Free of `& < > "` (UUID image names; boundary chars stripped from video + * cache names — see materializeVideoToCache) so it survives + * `escapeXml`/`escapeXmlTags` untouched. + */ +function formatMediaReference(kind: 'image' | 'video', path: string): string { + return `Attached ${kind} file: ${path} (open it with ReadMediaFile)`; } diff --git a/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts b/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts new file mode 100644 index 00000000..5444304a --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/inline-skill-tokens.ts @@ -0,0 +1,97 @@ +/** + * Scanner for inline skill `/tokens` inside a prompt. + * + * Dispatch, editor highlighting, and autocomplete share this so all three + * agree on what counts as an inline skill reference: a `/name` token whose `/` + * is preceded by whitespace (space, tab, or newline), with no internal `/`. + * The leading slash-command area at the very start of the input is handled by + * the regular slash-command path and is skipped here by default. + */ + +import type { InlineSkillActivation } from '../types'; + +export interface InlineSkillToken { + readonly commandName: string; + readonly start: number; + readonly end: number; +} + +export interface FindInlineSkillTokensOptions { + /** Decide whether a syntactically valid token names a known skill. */ + readonly isKnownSkill: (commandName: string) => boolean; + /** Include tokens with an empty command name (a bare trailing `/`). */ + readonly allowEmpty?: boolean; + /** Also treat a `/` at the very start of the input as a token. */ + readonly includeLeading?: boolean; +} + +const WHITESPACE = /\s/; + +export function findInlineSkillTokens( + text: string, + options: FindInlineSkillTokensOptions, +): InlineSkillToken[] { + const tokens: InlineSkillToken[] = []; + + let searchStart = 0; + if (text.startsWith('/') && options.includeLeading !== true) { + const firstWhitespace = text.search(WHITESPACE); + searchStart = firstWhitespace === -1 ? text.length : firstWhitespace + 1; + } + + for (let i = searchStart; i < text.length; i++) { + if (text[i] !== '/') continue; + + const isLeadingSlash = i === 0 && options.includeLeading === true; + const charBefore = i > 0 ? text[i - 1] : undefined; + if (!isLeadingSlash && (charBefore === undefined || !WHITESPACE.test(charBefore))) continue; + + let end = i + 1; + while (end < text.length && !WHITESPACE.test(text[end] ?? '')) { + end++; + } + + const commandName = text.slice(i + 1, end); + if (commandName.includes('/')) continue; + if (commandName.length === 0 && options.allowEmpty !== true) continue; + if (!options.isKnownSkill(commandName)) continue; + + tokens.push({ commandName, start: i, end }); + } + + return tokens; +} + +export interface ExtractInlineSkillActivationsOptions { + /** Also treat a `/` at the very start of the input as a skill token. */ + readonly includeLeading?: boolean; +} + +/** + * Resolve the skill tokens of `text` through `skillCommandMap` (command name → + * skill name, with the same `skill:` prefix fallback as the leading-command + * path) and return the deduplicated activations in first-occurrence order. + * Unknown tokens, paths, URLs, and fractions are ignored. + */ +export function extractInlineSkillActivations( + text: string, + skillCommandMap: ReadonlyMap<string, string>, + options?: ExtractInlineSkillActivationsOptions, +): InlineSkillActivation[] { + const tokens = findInlineSkillTokens(text, { + isKnownSkill: (commandName) => + skillCommandMap.has(commandName) || skillCommandMap.has(`skill:${commandName}`), + includeLeading: options?.includeLeading, + }); + + const seen = new Set<string>(); + const activations: InlineSkillActivation[] = []; + for (const token of tokens) { + const skillName = + skillCommandMap.get(token.commandName) ?? skillCommandMap.get(`skill:${token.commandName}`); + if (skillName === undefined || seen.has(skillName)) continue; + seen.add(skillName); + activations.push({ skillName }); + } + return activations; +} diff --git a/apps/pythinker-code/src/tui/utils/input-latency.ts b/apps/pythinker-code/src/tui/utils/input-latency.ts new file mode 100644 index 00000000..feee727e --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/input-latency.ts @@ -0,0 +1,105 @@ +// src/tui/utils/input-latency.ts +// +// Debug-only input→render latency probe, enabled with PYTHINKER_TUI_INPUT_LATENCY=1. +// Registers a pi-tui input listener (event timestamps) and mounts a +// non-capturing overlay in the top-right corner whose render() drains the +// queue: each pending input event is stamped against the frame that first +// renders after it, and the overlay shows the live stats (last / p50 / p95 / +// p99 / max, plus >100ms / >300ms / >1s counters and the five worst samples). +// Optional JSONL sink: PYTHINKER_TUI_INPUT_LATENCY_LOG=<path> appends one record +// per event for post-hoc analysis. +// +// The measured latency is "input event → start of the first frame rendered +// after it" — it includes input handling and the 16ms render throttle, and +// underestimates by the frame's own diff/write tail (sub-ms to a few ms), +// which is the right granularity for diagnosing >100ms stalls. + +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import type { Component, TUI } from '@pymodel/pi-tui'; + +/** Rolling sample cap for the percentile window. */ +const MAX_SAMPLES = 500; + +export interface LatencySample { + latency: number; + at: string; +} + +/** The pure stats core (exported for tests): feed it input→render latencies + * and it keeps the rolling window, counters, and the five worst samples. */ +export class LatencyStats { + last = 0; + events = 0; + over100 = 0; + over300 = 0; + over1000 = 0; + readonly worst: LatencySample[] = []; + private readonly samples: number[] = []; + + record(latency: number, at: string): void { + this.last = latency; + this.events++; + if (latency > 100) this.over100++; + if (latency > 300) this.over300++; + if (latency > 1000) this.over1000++; + this.samples.push(latency); + if (this.samples.length > MAX_SAMPLES) this.samples.shift(); + const smallestKept = this.worst[this.worst.length - 1]?.latency ?? -1; + if (this.worst.length < 5 || latency >= smallestKept) { + this.worst.push({ latency, at }); + this.worst.sort((a, b) => b.latency - a.latency); + if (this.worst.length > 5) this.worst.length = 5; + } + } + + percentile(p: number): number { + if (this.samples.length === 0) return 0; + const sorted = [...this.samples].sort((a, b) => a - b); + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]!; + } + + max(): number { + return this.samples.length === 0 ? 0 : Math.max(...this.samples); + } + + formatLines(): string[] { + if (this.events === 0) return [' input→render: (type something) ']; + const head = + ` io ${this.last.toFixed(0)}ms | p50 ${this.percentile(50).toFixed(0)} p95 ${this.percentile(95).toFixed(0)}` + + ` p99 ${this.percentile(99).toFixed(0)} max ${this.max().toFixed(0)}ms | n=${this.events}` + + ` >100:${this.over100} >300:${this.over300} >1s:${this.over1000} `; + const worstLine = ` worst: ${this.worst.map((w) => `${w.latency.toFixed(0)}ms@${w.at}`).join(' ')} `; + return [head, worstLine]; + } +} + +/** Install the probe on a running TUI (call only when the env flag is set). */ +export function installInputLatencyProbe(tui: TUI): void { + const stats = new LatencyStats(); + const pending: number[] = []; + const logPath = process.env['PYTHINKER_TUI_INPUT_LATENCY_LOG']; + if (logPath) mkdirSync(path.dirname(logPath), { recursive: true }); + + tui.addInputListener(() => { + pending.push(performance.now()); + return undefined; + }); + + const overlay: Component = { + invalidate: () => {}, + render: () => { + if (pending.length > 0) { + const now = performance.now(); + const at = new Date().toISOString().slice(11, 23); + for (const t of pending.splice(0)) { + const latency = now - t; + stats.record(latency, at); + if (logPath) appendFileSync(logPath, `${JSON.stringify({ t: new Date().toISOString(), latencyMs: Math.round(latency) })}\n`); + } + } + return stats.formatLines(); + }, + }; + tui.showOverlay(overlay, { nonCapturing: true, anchor: 'top-right', margin: 0 }); +} diff --git a/apps/pythinker-code/src/tui/utils/markdown-options.ts b/apps/pythinker-code/src/tui/utils/markdown-options.ts new file mode 100644 index 00000000..63c3a90c --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/markdown-options.ts @@ -0,0 +1,21 @@ +/** + * Shared Markdown behavior options (distinct from the visual theme). + * + * Holds the process-wide LaTeX toggle from tui.toml so transcript components + * don't each need the config threaded through construction. Mirrors the + * render-cache toggle pattern (see utils/render-cache.ts). + */ + +import type { MarkdownOptions } from '@pymodel/pi-tui'; + +// Default on, matching upstream pi-tui; overridden from tui.toml at startup +// and on /reload. +let renderLatex = true; + +export function setMarkdownRenderLatex(value: boolean): void { + renderLatex = value; +} + +export function createMarkdownOptions(): MarkdownOptions { + return { renderLatex }; +} diff --git a/apps/pythinker-code/src/tui/utils/mcp-server-status.ts b/apps/pythinker-code/src/tui/utils/mcp-server-status.ts index 872e8c5a..d46a71dd 100644 --- a/apps/pythinker-code/src/tui/utils/mcp-server-status.ts +++ b/apps/pythinker-code/src/tui/utils/mcp-server-status.ts @@ -1,51 +1,33 @@ import type { McpServerInfo, McpServerStatusEvent } from '@pymodel/pythinker-code-sdk'; -import type { ColorToken } from '#/tui/theme'; - export type McpServerStatusSnapshot = McpServerInfo | McpServerStatusEvent['server']; -export interface McpStartupStatusLine { - readonly label: string; - readonly color: ColorToken; - readonly loading: boolean; - readonly transient: boolean; -} - -export function buildMcpStartupStatusLine( - servers: readonly McpServerStatusSnapshot[], -): McpStartupStatusLine | null { - const enabled = servers.filter((server) => server.status !== 'disabled'); - if (enabled.length === 0) return null; - - const connected = enabled.filter((server) => server.status === 'connected'); - const failed = enabled.filter((server) => server.status === 'failed').length; - const needsAuth = enabled.filter((server) => server.status === 'needs-auth').length; - const loading = enabled.filter((server) => server.status === 'pending').length; - const parts = [`${String(connected.length)}/${String(enabled.length)} connected`]; +export const MCP_STARTUP_STATUS_ROW_LIMIT = 4; - if (failed > 0) parts.push(`${String(failed)} failed`); - if (needsAuth > 0) parts.push(`${String(needsAuth)} needs auth`); - if (loading > 0) parts.push(`${String(loading)} loading…`); - - const hasIssues = failed > 0 || needsAuth > 0; - if (loading === 0 && hasIssues) parts.push('/mcp for details'); - if (loading === 0 && !hasIssues) { - const tools = connected.reduce((sum, server) => sum + server.toolCount, 0); - parts.push(`${String(tools)} tool${tools === 1 ? '' : 's'}`); +function mcpStartupStatusPriority(status: McpServerStatusSnapshot['status']): number { + switch (status) { + case 'failed': + return 0; + case 'needs-auth': + return 1; + case 'pending': + return 2; + case 'connected': + return 3; + case 'disabled': + return 4; + case 'removed': + return 5; } +} - return { - label: `MCP servers · ${parts.join(' · ')}`, - color: failed > 0 - ? 'error' - : needsAuth > 0 - ? 'warning' - : loading > 0 - ? 'primary' - : 'success', - loading: loading > 0, - transient: loading === 0 && !hasIssues, - }; +export function selectMcpStartupStatusRows( + servers: readonly McpServerStatusSnapshot[], +): McpServerStatusSnapshot[] { + return [...servers] + .filter((server) => server.status !== 'disabled' && server.status !== 'removed') + .toSorted((a, b) => mcpStartupStatusPriority(a.status) - mcpStartupStatusPriority(b.status)) + .slice(0, MCP_STARTUP_STATUS_ROW_LIMIT); } export function formatMcpStartupStatusSummary( @@ -56,6 +38,7 @@ export function formatMcpStartupStatusSummary( let connecting = 0; let connected = 0; let disabled = 0; + let removed = 0; for (const server of servers) { switch (server.status) { case 'failed': @@ -73,6 +56,9 @@ export function formatMcpStartupStatusSummary( case 'disabled': disabled++; break; + case 'removed': + removed++; + break; } } @@ -82,6 +68,7 @@ export function formatMcpStartupStatusSummary( if (connecting > 0) parts.push(`${connecting} connecting`); if (connected > 0) parts.push(`${connected} connected`); if (disabled > 0) parts.push(`${disabled} disabled`); + if (removed > 0) parts.push(`${removed} removed`); return parts.join(', '); } diff --git a/apps/pythinker-code/src/tui/utils/media-url.ts b/apps/pythinker-code/src/tui/utils/media-url.ts index f04edeb2..f51c675a 100644 --- a/apps/pythinker-code/src/tui/utils/media-url.ts +++ b/apps/pythinker-code/src/tui/utils/media-url.ts @@ -1,3 +1,5 @@ +import { isDaemonFileUrl } from '@pymodel/pythinker-code-sdk'; + export type MediaUrlKind = 'audio' | 'image' | 'video'; export function mediaUrlPartToText(kind: MediaUrlKind, url: string): string { @@ -6,6 +8,10 @@ export function mediaUrlPartToText(kind: MediaUrlKind, url: string): string { const size = summary.bytes !== undefined ? `, ${formatByteSize(summary.bytes)}` : ''; return `[${kind} ${summary.mime}${size}]`; } + // An internal daemon file reference (`pythinker-file://…?path=…`) never renders + // its wire form: the scheme resolves nowhere for the user and the query + // carries the materialization path. Render the bare placeholder instead. + if (isDaemonFileUrl(url)) return `[${kind}]`; return `<${kind} url="${escapeAttribute(url)}">`; } diff --git a/apps/pythinker-code/src/tui/utils/message-replay.ts b/apps/pythinker-code/src/tui/utils/message-replay.ts index e6ad3f0c..4f089674 100644 --- a/apps/pythinker-code/src/tui/utils/message-replay.ts +++ b/apps/pythinker-code/src/tui/utils/message-replay.ts @@ -1,6 +1,7 @@ import type { AgentReplayRecord, BackgroundTaskInfo, + BackgroundTaskStatus, ContentPart, ContextMessage, PromptOrigin, @@ -17,11 +18,22 @@ import type { TranscriptEntry, } from '#/tui/types'; +import { modelDisplayName } from '../components/dialogs/model-selector'; import { mediaUrlPartToText } from './media-url'; import { nextTranscriptId } from './transcript-id'; export const REPLAY_TURN_LIMIT = 10; +/** + * Resume fetches one extra turn of records: the SDK trims the replay to the + * requested limit before returning it, and a trim that lands between a + * bundled prompt and the hook results recorded immediately before it would + * make them unrecoverable. The extra margin lets the TUI-side limiter + * (session-replay's preserveBundleHookResults) do the final cut without + * losing them. + */ +export const REPLAY_FETCH_TURN_LIMIT = REPLAY_TURN_LIMIT + 1; + export interface ReplayRenderContext { turnIndex: number; stepIndex: number; @@ -33,15 +45,25 @@ export interface ReplayRenderContext { toolCalls: Map<string, ToolCallBlockData>; completedToolCallIds: Set<string>; skillActivationIds: Set<string>; + pluginCommandActivationIds: Set<string>; suppressNextPlanModeOffNotice: boolean; } export interface SkillActivationProjection { readonly activationId: string; readonly skillName: string; - readonly checkpointId?: string; readonly skillArgs?: string; readonly trigger: SkillActivationTrigger; + /** The activation rode a bundled prompt message, not a standalone one. */ + readonly bundled?: boolean; +} + +export interface PluginCommandProjection { + readonly activationId: string; + readonly pluginId: string; + readonly commandName: string; + readonly commandArgs?: string; + readonly trigger: 'user-slash'; } export interface ReplayBackgroundProjection { @@ -92,6 +114,7 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap<string, Background export function replayBackgroundProjection( background: readonly BackgroundTaskInfo[], + availableModels?: AppState['availableModels'], ): ReplayBackgroundProjection { const backgroundAgentMetadata = new Map<string, BackgroundAgentMetadata>(); for (const info of background) { @@ -102,6 +125,20 @@ export function replayBackgroundProjection( agentId, parentToolCallId: info.taskId, description: info.description, + // The persisted task record carries the spawn-time model/effort (v2); + // keep them across a resume so the terminal transcript entry can show + // them. Model maps through the catalog like the live path; boolean + // effort states carry no level and are dropped. + model: + info.model === undefined + ? undefined + : modelDisplayName(info.model, availableModels?.[info.model]), + effort: + info.thinkingEffort === undefined || + info.thinkingEffort === 'off' || + info.thinkingEffort === 'on' + ? undefined + : info.thinkingEffort, }); } return { backgroundAgentMetadata }; @@ -116,6 +153,7 @@ export function createReplayRenderContext(): ReplayRenderContext { toolCalls: new Map(), completedToolCallIds: new Set(), skillActivationIds: new Set(), + pluginCommandActivationIds: new Set(), suppressNextPlanModeOffNotice: false, }; } @@ -124,8 +162,9 @@ export function limitReplayRecordsByTurn( records: readonly AgentReplayRecord[], maxTurns: number, ): readonly AgentReplayRecord[] { - // Defensive local slice: resume callers already ask core to trim before the - // replay crosses the RPC boundary. + // Defensive slice — the core already trims the replay when the caller passes + // `replayTurnLimit` on resume; the boundary predicate lives in agent-core + // (`limitAgentReplayByTurns`) and is re-exported through the SDK. return limitAgentReplayByTurns(records, maxTurns); } @@ -134,7 +173,7 @@ export function replayEntry( kind: TranscriptEntry['kind'], content: string, renderMode: TranscriptEntry['renderMode'], - extras: { detail?: string } = {}, + extras: { detail?: string; bullet?: string } = {}, ): TranscriptEntry { return { id: nextTranscriptId(), @@ -143,6 +182,7 @@ export function replayEntry( renderMode, content, detail: extras.detail, + bullet: extras.bullet, }; } @@ -190,13 +230,33 @@ export function toolResultOutput(content: readonly ContentPart[]): string { } export function contentPartsToText(content: readonly ContentPart[]): string { + // A daemon-ref media part is self-contained and renders as a bare + // `[image]`/`[video]` placeholder downstream — neither the materialization + // path nor the internal `pythinker-file://` url may surface as user text. A + // standalone `<media path>` tag is user text and stays verbatim. return content.map(contentPartToText).join(''); } +/** + * agent-core-v2's task domain persists the terminal notification under the + * 'task' spelling (v1 used 'background_task'); both reach replay verbatim. + */ +export interface TaskNotificationOrigin { + readonly kind: 'task'; + readonly taskId: string; + readonly status: BackgroundTaskStatus; + readonly notificationId: string; +} + +export type BackgroundTaskNotificationOrigin = + | Extract<PromptOrigin, { kind: 'background_task' }> + | TaskNotificationOrigin; + export function backgroundOrigin( message: ContextMessage, -): Extract<PromptOrigin, { kind: 'background_task' }> | undefined { - return message.origin?.kind === 'background_task' ? message.origin : undefined; +): BackgroundTaskNotificationOrigin | undefined { + const origin = message.origin as BackgroundTaskNotificationOrigin | undefined; + return origin?.kind === 'background_task' || origin?.kind === 'task' ? origin : undefined; } export function skillActivationFromOrigin( @@ -206,12 +266,66 @@ export function skillActivationFromOrigin( return { activationId: origin.activationId, skillName: origin.skillName, - checkpointId: origin.checkpointId, skillArgs: origin.skillArgs, trigger: origin.trigger, }; } +/** + * The v2 engine bundles a prompt's inline skill activations into the prompt + * message itself: the rendered skill blocks precede the caller's parts in + * the content, and this origin field carries every activation's metadata so + * replay can rebuild the per-skill cards from the single message. The SDK's + * origin union is typed from the v1 engine, which never sets the field, so + * read it structurally here instead of widening the deprecated v1 package's + * types. + */ +export function bundledSkillsFromOrigin( + origin: PromptOrigin | undefined, +): readonly SkillActivationProjection[] { + if (origin?.kind !== 'user') return []; + const activations = ( + origin as { + readonly skillActivations?: readonly { + readonly activationId: string; + readonly skillName: string; + readonly skillArgs?: string; + }[]; + } + ).skillActivations; + if (activations === undefined) return []; + return activations.map((activation) => ({ + activationId: activation.activationId, + skillName: activation.skillName, + skillArgs: activation.skillArgs, + trigger: 'user-slash' as const, + bundled: true, + })); +} + +/** + * Content parts the caller actually typed: the engine prepends one rendered + * text part per bundled skill, so the caller's own parts start right after + * them. + */ +export function stripBundledSkillParts(message: ContextMessage): readonly ContentPart[] { + const bundledCount = bundledSkillsFromOrigin(message.origin).length; + return bundledCount === 0 ? message.content : message.content.slice(bundledCount); +} + +export function pluginCommandFromOrigin( + origin: PromptOrigin | undefined, +): PluginCommandProjection | undefined { + if (origin?.kind !== 'plugin_command') return undefined; + return { + activationId: origin.activationId, + pluginId: origin.pluginId, + commandName: origin.commandName, + commandArgs: origin.commandArgs, + trigger: origin.trigger, + }; +} + export function formatHookResultMessageForTranscript( text: string, fallbackEvent: string, diff --git a/apps/pythinker-code/src/tui/utils/osc133.ts b/apps/pythinker-code/src/tui/utils/osc133.ts new file mode 100644 index 00000000..3273fe15 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/osc133.ts @@ -0,0 +1,34 @@ +/** + * OSC 133 zone marking for transcript messages. The fullscreen renderer + * anchors previous/next-prompt navigation on lines whose first bytes are an + * OSC 133;A zone marker (and strips the markers at paint), so the marks must + * survive every container between the message component and the ScrollView. + */ + +import { + OSC133_ZONE_END, + OSC133_ZONE_FINAL, + OSC133_ZONE_START, +} from '#/tui/constant/rendering'; + +// One or more consecutive A/B/C zone markers anchored at the line start. +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +/** + * Mark a message's rendered lines as a semantic zone: A on the first line, + * B+C on the last. Mutates and returns the given array — call it on freshly + * built lines before handing them to a render cache (cached lines then + * already carry the marks, so they are never marked twice). + */ +export function markOsc133Zone(lines: string[]): string[] { + if (lines.length === 0) return lines; + lines[0] = OSC133_ZONE_START + lines[0]!; + lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]!; + return lines; +} + +/** Prefix a rendered line while keeping any leading OSC 133 zone at byte 0. */ +export function prefixPreservingOsc133Zone(line: string, prefix: string): string { + const zone = OSC133_ZONE_PREFIX.exec(line)?.[0]; + return zone === undefined ? prefix + line : zone + prefix + line.slice(zone.length); +} diff --git a/apps/pythinker-code/src/tui/utils/persist-effort.ts b/apps/pythinker-code/src/tui/utils/persist-effort.ts deleted file mode 100644 index 06fd4793..00000000 --- a/apps/pythinker-code/src/tui/utils/persist-effort.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { PythinkerHarness } from '@pymodel/pythinker-code-sdk'; - -/** - * Save the model + thinking-effort pair as the startup default. - * Returns false when the config already holds the same selection. - */ -export async function persistDefaultModelSelection( - harness: PythinkerHarness, - alias: string, - effort: string, -): Promise<boolean> { - const defaultThinking = effort !== 'off'; - // setConfig deep-merges, so a stale `mode = "off"` left in config.toml would - // survive an effort-only patch and force thinking off on the next startup. - // Write mode alongside effort to keep the pair consistent. - const mode = defaultThinking ? 'on' : 'off'; - const config = await harness.getConfig({ reload: true }); - if ( - config.defaultModel === alias && - config.defaultThinking === defaultThinking && - config.thinking?.effort === effort && - config.thinking.mode === mode - ) { - return false; - } - await harness.setConfig({ - defaultModel: alias, - defaultThinking, - thinking: { effort, mode }, - }); - return true; -} diff --git a/apps/pythinker-code/src/tui/utils/plugin-source-label.ts b/apps/pythinker-code/src/tui/utils/plugin-source-label.ts index 7eaae153..62a3fa0c 100644 --- a/apps/pythinker-code/src/tui/utils/plugin-source-label.ts +++ b/apps/pythinker-code/src/tui/utils/plugin-source-label.ts @@ -30,21 +30,19 @@ export function formatPluginSourceLabel(plugin: PluginSummary): string { * paths receive official or curated badges. Everything else is third-party. */ export function pluginTrustLabel(plugin: PluginSummary): PluginTrustLabel { - if (plugin.source !== 'zip-url') return 'third-party'; - return pluginSourceTrustLabel(plugin.originalSource); -} - -export function pluginSourceTrustLabel(source: string | undefined): PluginTrustLabel { - if (source === undefined) return 'third-party'; + if (plugin.source !== 'zip-url' || plugin.originalSource === undefined) { + return 'third-party'; + } try { - const url = new URL(source); - if (url.protocol !== 'https:' || url.hostname !== 'code.pythinker.com') { - return 'third-party'; - } - if (url.pathname.startsWith('/pythinker-code/plugins/official/')) { + const url = new URL(plugin.originalSource); + if (isOfficialPluginUrl(url)) { return 'official'; } - if (url.pathname.startsWith('/pythinker-code/plugins/curated/')) { + if ( + url.protocol === 'https:' && + url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/pythinker-code/plugins/curated/') + ) { return 'curated'; } return 'third-party'; @@ -53,6 +51,47 @@ export function pluginSourceTrustLabel(source: string | undefined): PluginTrustL } } +/** + * Returns true only for install sources that are unambiguously Pythinker-built + * official plugins — an https URL under the official Pythinker CDN plugin path. + * Everything else (local paths, GitHub repos, curated or third-party URLs) + * is treated as unofficial and should be confirmed before install. + */ +export function isOfficialPluginSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed.startsWith('https://')) return false; + try { + return isOfficialPluginUrl(new URL(trimmed)); + } catch { + return false; + } +} + +/** + * Returns true when an installed plugin provably came from a trusted official + * source — a zip download under the official CDN plugin path. Local paths, + * GitHub repos, and third-party URLs do not qualify, even when their manifest + * id matches an official plugin. + */ +export function isOfficialPluginInstall(plugin: PluginSummary): boolean { + return ( + plugin.source === 'zip-url' && + plugin.originalSource !== undefined && + isOfficialPluginSource(plugin.originalSource) + ); +} + +function isOfficialPluginUrl(url: URL): boolean { + if (url.protocol !== 'https:') return false; + return ( + (url.hostname === 'code.kimi.com' && + url.pathname.startsWith('/pythinker-code/plugins/official/')) || + (url.hostname === 'cdn.kimi.com' && + (url.pathname.startsWith('/pythinker-computer-use/') || + url.pathname.startsWith('/pythinker-computer-use-windows/'))) + ); +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/pythinker-code/src/tui/utils/printable-key.ts b/apps/pythinker-code/src/tui/utils/printable-key.ts index 1f3306bb..da1c7527 100644 --- a/apps/pythinker-code/src/tui/utils/printable-key.ts +++ b/apps/pythinker-code/src/tui/utils/printable-key.ts @@ -20,7 +20,7 @@ * `tui/components/**` and rejects bare-literal comparisons. */ -import { decodeKittyPrintable } from '@earendil-works/pi-tui'; +import { decodeKittyPrintable } from '@pymodel/pi-tui'; export function printableChar(data: string): string { return decodeKittyPrintable(data) ?? data; @@ -32,7 +32,7 @@ export function printableChar(data: string): string { * multi-codepoint escape sequence. Space is accepted. */ export function isPrintableChar(ch: string): boolean { - if (Array.from(ch).length !== 1) return false; + if (ch.length !== 1) return false; const code = ch.codePointAt(0)!; return code >= 0x20 && code !== 0x7f; } diff --git a/apps/pythinker-code/src/tui/utils/refresh-providers.ts b/apps/pythinker-code/src/tui/utils/refresh-providers.ts index 7da435d9..ffce4b04 100644 --- a/apps/pythinker-code/src/tui/utils/refresh-providers.ts +++ b/apps/pythinker-code/src/tui/utils/refresh-providers.ts @@ -1,725 +1,46 @@ import { - OPENAI_CODEX_PROVIDER_ID, - applyOpenAICodexOAuthConfig, - applyOpenPlatformConfig, - applyCustomRegistryProvider, - fetchCustomRegistry, - fetchOpenAICodexModels, - fetchOpenPlatformModels, - filterModelsByPrefix, - getOpenPlatformById, - isOpenPlatformId, - removeCustomRegistryProvider, - type CustomRegistrySource, - type PlatformConfigShape, + refreshProviderModels, + type ProviderChange, + type RefreshProviderOptions, + type RefreshProviderScope, + type RefreshResult, } from '@pymodel/pythinker-code-oauth'; -import { - applyCatalogProvider, - catalogBaseUrl, - catalogConnectionWire, - catalogProviderModels, - fetchCatalog, - type Catalog, - type PythinkerConfig, - type PythinkerConfigPatch, - type ModelAlias, - type ProviderConfig, -} from '@pymodel/pythinker-code-sdk'; - +import type { PythinkerConfig, PythinkerConfigPatch, OAuthRef } from '@pymodel/pythinker-code-sdk'; +/** + * CLI-side host for provider-model refresh. Kept on the SDK's full config types + * so existing TUI callers (and tests) don't change; the daemon uses the oauth + * package's `ManagedPythinkerConfigShape`-typed host directly. + */ export interface RefreshProviderHost { getConfig(): Promise<PythinkerConfig>; removeProvider(providerId: string): Promise<PythinkerConfig>; setConfig(patch: PythinkerConfigPatch): Promise<PythinkerConfig>; - /** Persists a fully-recomputed config; removals and cleared defaults survive. */ - replaceConfig(config: PythinkerConfig): Promise<PythinkerConfig>; -} - -export interface ProviderChange { - readonly providerId: string; - /** User-facing name when available. */ - readonly providerName: string; - readonly added: number; - readonly removed: number; -} - -export interface RefreshResult { - /** Providers whose model list actually changed. */ - readonly changed: readonly ProviderChange[]; - /** Providers whose model list stayed identical after refresh. */ - readonly unchanged: readonly string[]; - readonly failed: ReadonlyArray<{ readonly provider: string; readonly reason: string }>; -} - -export type RefreshProviderScope = 'all' | 'oauth'; - -export interface RefreshProviderOptions { - readonly scope?: RefreshProviderScope; -} - -function readCustomRegistrySource(provider: ProviderConfig): CustomRegistrySource | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const candidate = source; - if (candidate['kind'] !== 'apiJson') return undefined; - const url = candidate['url']; - const apiKey = candidate['apiKey']; - if (typeof url !== 'string' || url.length === 0) return undefined; - if (typeof apiKey !== 'string') return undefined; - return { kind: 'apiJson', url, apiKey }; -} - -function readCatalogUrl(provider: ProviderConfig): string | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null || source['kind'] !== 'modelsDev') { - return undefined; - } - const url = source['url']; - return typeof url === 'string' && url.length > 0 ? url : undefined; -} - -function customRegistrySourceKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url]); -} - -function customRegistrySourceCredentialKey(source: CustomRegistrySource): string { - return JSON.stringify([source.url, source.apiKey]); -} - -async function fetchCustomRegistryFromSources( - sources: readonly CustomRegistrySource[], -): Promise<{ - readonly entries: Awaited<ReturnType<typeof fetchCustomRegistry>>; - readonly source: CustomRegistrySource; -}> { - let lastError: unknown; - for (const source of sources) { - try { - return { - entries: await fetchCustomRegistry(source), - source, - }; - } catch (error) { - lastError = error; - } - } - if (lastError instanceof Error) throw lastError; - if (typeof lastError === 'string') throw new Error(lastError); - throw new Error('No custom registry sources configured.'); -} - -function asManaged(config: PythinkerConfig): PlatformConfigShape { - return config as unknown as PlatformConfigShape; -} - -function collectModelIdsForAliases(config: PythinkerConfig, aliasKeys: ReadonlySet<string>): Set<string> { - const ids = new Set<string>(); - for (const aliasKey of aliasKeys) { - const alias = config.models?.[aliasKey]; - if (alias !== undefined && alias.model.length > 0) { - ids.add(alias.model); - } - } - return ids; -} - -function providerAliasKeys(config: PythinkerConfig, providerId: string): Set<string> { - const keys = new Set<string>(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId) keys.add(alias); - } - return keys; -} - -function generatedProviderAliasKeys( - config: PythinkerConfig, - providerId: string, - aliasPrefix: string, -): Set<string> { - const keys = new Set<string>(); - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider === providerId && alias.startsWith(aliasPrefix)) { - keys.add(alias); - } - } - return keys; -} - -function computeChanges(oldIds: Set<string>, newIds: Set<string>): { added: number; removed: number } { - let added = 0; - for (const id of newIds) { - if (!oldIds.has(id)) added++; - } - let removed = 0; - for (const id of oldIds) { - if (!newIds.has(id)) removed++; - } - return { added, removed }; -} - -interface ProviderModelSnapshot { - readonly alias: string; - readonly model: ModelAlias; -} - -// Compare the full model metadata for the relevant aliases, not just model IDs: -// a registry can change capabilities (e.g. enabling reasoning) without changing -// any model ID. Spreading the whole `ModelAlias` keeps this in sync with the -// schema automatically; only `capabilities` needs normalizing because its order -// is not meaningful. -function providerModelSnapshot( - config: PythinkerConfig, - providerId: string, - aliasKeys: ReadonlySet<string>, -): string { - const snapshots: ProviderModelSnapshot[] = []; - for (const alias of aliasKeys) { - const model = config.models?.[alias]; - if (model === undefined || model.provider !== providerId) continue; - snapshots.push({ - alias, - model: { - ...model, - capabilities: model.capabilities === undefined ? undefined : model.capabilities.toSorted(), - }, - }); - } - snapshots.sort((a, b) => a.alias.localeCompare(b.alias)); - return JSON.stringify(snapshots); -} - -function providerModelsEqual( - config: PythinkerConfig, - nextConfig: PythinkerConfig, - providerId: string, - aliasKeys: ReadonlySet<string>, -): boolean { - return ( - providerModelSnapshot(config, providerId, aliasKeys) === - providerModelSnapshot(nextConfig, providerId, aliasKeys) - ); -} - -function providerConfigSnapshot(config: PythinkerConfig, providerId: string): string { - return JSON.stringify(config.providers[providerId] ?? null); -} - -function providerConfigEqual(config: PythinkerConfig, nextConfig: PythinkerConfig, providerId: string): boolean { - return providerConfigSnapshot(config, providerId) === providerConfigSnapshot(nextConfig, providerId); -} - -function providerRefreshAliasKeys( - config: PythinkerConfig, - nextConfig: PythinkerConfig, - providerId: string, - aliasPrefix: string, -): Set<string> { - const keys = generatedProviderAliasKeys(config, providerId, aliasPrefix); - for (const key of providerAliasKeys(nextConfig, providerId)) keys.add(key); - return keys; -} - -function preserveUserProviderAliases( - config: PythinkerConfig, - providerId: string, - refreshedAliasKeys: ReadonlySet<string>, -): Record<string, ModelAlias> { - const preserved: Record<string, ModelAlias> = {}; - for (const [alias, model] of Object.entries(config.models ?? {})) { - if (model.provider !== providerId || refreshedAliasKeys.has(alias)) continue; - preserved[alias] = structuredClone(model); - } - return preserved; + resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>; + /** Product User-Agent sent on custom-registry (api.json) fetches. */ + readonly userAgent?: string; } -function restoreProviderAliases(config: PythinkerConfig, aliases: Record<string, ModelAlias>): void { - if (Object.keys(aliases).length === 0) return; - config.models = { - ...config.models, - ...aliases, - }; -} - -function restoreDefaultSelection( - config: PythinkerConfig, - defaultModel: string | undefined, - defaultThinking: boolean | undefined, -): void { - if (defaultModel === undefined) { - config.defaultModel = undefined; - config.defaultThinking = defaultThinking; - return; - } - if (config.models?.[defaultModel] === undefined) return; - config.defaultModel = defaultModel; - // A refresh may have just learned that the default model cannot disable - // thinking — never restore a stale thinking-off selection onto it. - const capabilities = config.models[defaultModel]?.capabilities ?? []; - config.defaultThinking = capabilities.includes('always_thinking') ? true : defaultThinking; -} - -// `apply*` may leave `defaultModel` pointing at an alias that no longer exists -// (e.g. the previously-selected model was dropped from the registry). Drop the -// dangling selection before either merge-based or full-replacement persistence. -function clampDanglingDefault(config: PythinkerConfig): void { - if (config.defaultModel !== undefined && config.models?.[config.defaultModel] === undefined) { - config.defaultModel = undefined; - config.defaultThinking = undefined; - } -} - -function clearDefaultThinkingWhenDefaultRemoved( - config: PythinkerConfig, - previousDefaultModel: string | undefined, -): void { - if (previousDefaultModel !== undefined && config.defaultModel === undefined) { - config.defaultThinking = undefined; - } -} - -function readOpenAICodexAccountId(provider: ProviderConfig): string | undefined { - const headers = provider.customHeaders; - if (typeof headers === 'object' && headers !== null) { - const accountId = headers['chatgpt-account-id']; - if (typeof accountId === 'string' && accountId.length > 0) { - return accountId; - } - } - const source = provider.source; - if (typeof source === 'object' && source !== null) { - const accountId = source['accountId']; - if (typeof accountId === 'string' && accountId.length > 0) { - return accountId; - } - } - return undefined; -} - -function readOpenAICodexRefreshToken(provider: ProviderConfig): string | undefined { - const source = provider.source; - if (typeof source !== 'object' || source === null) return undefined; - const refreshToken = source['refreshToken']; - return typeof refreshToken === 'string' && refreshToken.length > 0 ? refreshToken : undefined; -} - -function pickDefaultModel(config: PythinkerConfig, providerId: string, models: Array<{ id: string }>): string { - const firstModel = models[0]; - if (firstModel === undefined) return ''; - - const existingDefault = config.defaultModel; - if (existingDefault !== undefined) { - const alias = config.models?.[existingDefault]; - if (alias !== undefined && alias.provider === providerId) { - const stillAvailable = models.find((m) => m.id === alias.model); - if (stillAvailable !== undefined) { - return stillAvailable.id; - } - } - } - return firstModel.id; -} +export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; +/** + * Refresh remote model metadata for the configured providers. Thin adapter over + * the shared `refreshProviderModels` orchestrator in `@pymodel/pythinker-code-oauth` + * (which is also what the daemon's scheduled/manual refresh uses). + */ export async function refreshAllProviderModels( host: RefreshProviderHost, options: RefreshProviderOptions = {}, ): Promise<RefreshResult> { - const changed: ProviderChange[] = []; - const unchanged: string[] = []; - const failed: Array<{ provider: string; reason: string }> = []; - const scope = options.scope ?? 'all'; - - let config = await host.getConfig(); - - // ------------------------------------------------------------------------- - // 1. OpenAI Codex (OAuth) - // ------------------------------------------------------------------------- - const codexProvider = config.providers[OPENAI_CODEX_PROVIDER_ID]; - if (codexProvider !== undefined && codexProvider.type === 'openai_responses') { - const accessToken = codexProvider.apiKey; - const accountId = readOpenAICodexAccountId(codexProvider); - if (typeof accessToken === 'string' && accessToken.length > 0 && accountId !== undefined) { - try { - const models = await fetchOpenAICodexModels({ accessToken, accountId }); - if (models.length > 0) { - const selectedModelId = pickDefaultModel(config, OPENAI_CODEX_PROVIDER_ID, models); - const selectedModel = models.find((model) => model.id === selectedModelId); - if (selectedModel !== undefined) { - const next = structuredClone(config); - applyOpenAICodexOAuthConfig(asManaged(next), { - accessToken, - accountId, - refreshToken: readOpenAICodexRefreshToken(codexProvider), - models, - selectedModel, - thinking: next.defaultThinking ?? true, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - OPENAI_CODEX_PROVIDER_ID, - `${OPENAI_CODEX_PROVIDER_ID}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, OPENAI_CODEX_PROVIDER_ID, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, OPENAI_CODEX_PROVIDER_ID, refreshedAliasKeys)) { - unchanged.push(OPENAI_CODEX_PROVIDER_ID); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(OPENAI_CODEX_PROVIDER_ID); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId: OPENAI_CODEX_PROVIDER_ID, - providerName: 'OpenAI Codex (OAuth)', - added, - removed, - }); - } - } - } - } catch (error) { - failed.push({ - provider: OPENAI_CODEX_PROVIDER_ID, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - if (scope === 'oauth') { - return { changed, unchanged, failed }; - } - - // ------------------------------------------------------------------------- - // 2. Open Platforms (pythoughts-cn, pymodel, …) - // ------------------------------------------------------------------------- - const openPlatformIds = Object.keys(config.providers).filter((id) => isOpenPlatformId(id)); - for (const providerId of openPlatformIds) { - const platform = getOpenPlatformById(providerId); - if (platform === undefined) continue; - - const providerConfig = config.providers[providerId]; - if (providerConfig === undefined) continue; - const apiKey = providerConfig.apiKey; - if (typeof apiKey !== 'string' || apiKey.length === 0) continue; - - try { - let models = await fetchOpenPlatformModels(platform, apiKey); - models = filterModelsByPrefix(models, platform); - if (models.length === 0) continue; - - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((m) => m.id === selectedModelId); - if (selectedModel === undefined) continue; - const next = structuredClone(config); - applyOpenPlatformConfig(asManaged(next), { - platform, - models, - selectedModel, - thinking: false, - apiKey, - }); - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - providerId, - `${providerId}/`, - ); - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - if (providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(providerId); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - changed.push({ - providerId, - providerName: platform.name, - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - - // ------------------------------------------------------------------------- - // 3. models.dev catalog providers (grouped by catalog URL) - // ------------------------------------------------------------------------- - const catalogSources = new Map<string, string[]>(); - for (const [providerId, providerConfig] of Object.entries(config.providers)) { - const url = readCatalogUrl(providerConfig); - if (url === undefined) continue; - const providerIds = catalogSources.get(url); - if (providerIds === undefined) catalogSources.set(url, [providerId]); - else providerIds.push(providerId); - } - - for (const [url, providerIds] of catalogSources) { - let catalog: Catalog; - try { - catalog = await fetchCatalog(url); - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - continue; - } - - for (const providerId of providerIds) { - try { - const entry = catalog[providerId]; - if (entry === undefined) throw new Error(`Provider is missing from catalog at ${url}.`); - const wire = catalogConnectionWire(entry); - if (wire === undefined) throw new Error('Provider can no longer be configured with one API key.'); - const models = catalogProviderModels(entry); - if (models.length === 0) throw new Error('Provider has no usable catalog models.'); - - const provider = config.providers[providerId]; - if (provider === undefined) continue; - const registeredApiKey = - typeof provider.apiKey === 'string' && provider.apiKey.trim().length > 0 - ? provider.apiKey - : undefined; - const apiKeyEnvVar = provider.apiKeyEnvVar; - if (registeredApiKey === undefined && apiKeyEnvVar === undefined) { - throw new Error( - `Catalog provider "${providerId}" has no registered API key credential.`, - ); - } - // A registered key always wins over the environment fallback, so a - // catalog refresh cannot silently swap credentials. - const apiKey = registeredApiKey; - const selectedModelId = pickDefaultModel(config, providerId, models); - const selectedModel = models.find((model) => model.id === selectedModelId); - if (selectedModel === undefined) continue; - - const next = structuredClone(config); - applyCatalogProvider(next, { - providerId, - catalogUrl: url, - wire, - baseUrl: catalogBaseUrl(entry, wire) ?? provider.baseUrl, - apiKey, - apiKeyEnvVar, - models, - selectedModelId, - thinking: - selectedModel.alwaysThinking === true ? true : (config.defaultThinking ?? false), - }); - next.providers[providerId] = { - ...provider, - ...next.providers[providerId], - apiKey, - apiKeyEnvVar, - }; - const refreshedAliasKeys = providerRefreshAliasKeys( - config, - next, - providerId, - `${providerId}/`, - ); - restoreProviderAliases( - next, - preserveUserProviderAliases(config, providerId, refreshedAliasKeys), - ); - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - - const modelsEqual = providerModelsEqual(config, next, providerId, refreshedAliasKeys); - const configEqual = providerConfigEqual(config, next, providerId); - if (modelsEqual && configEqual) { - unchanged.push(providerId); - continue; - } - - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - await host.removeProvider(providerId); - config = await host.setConfig({ - providers: next.providers, - models: next.models, - defaultModel: next.defaultModel, - defaultThinking: next.defaultThinking, - }); - if (modelsEqual) { - unchanged.push(providerId); - } else { - changed.push({ - providerId, - providerName: entry.name ?? providerId, - added, - removed, - }); - } - } catch (error) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - // ------------------------------------------------------------------------- - // 4. Custom Registry providers (grouped by URL, with API-key candidates) - // ------------------------------------------------------------------------- - const customSources = new Map< - string, + return refreshProviderModels( { - readonly sources: CustomRegistrySource[]; - readonly sourceKeys: Set<string>; - readonly providerIds: string[]; - } - >(); - for (const [providerId, providerConfig] of Object.entries(config.providers)) { - if (providerId === OPENAI_CODEX_PROVIDER_ID) continue; - if (isOpenPlatformId(providerId)) continue; - const source = readCustomRegistrySource(providerConfig); - if (source === undefined) continue; - const key = customRegistrySourceKey(source); - const sourceKey = customRegistrySourceCredentialKey(source); - const entry = customSources.get(key); - if (entry !== undefined) { - if (!entry.sourceKeys.has(sourceKey)) { - entry.sources.push(source); - entry.sourceKeys.add(sourceKey); - } - entry.providerIds.push(providerId); - } else { - customSources.set(key, { - sources: [source], - sourceKeys: new Set([sourceKey]), - providerIds: [providerId], - }); - } - } - - for (const { sources, providerIds } of customSources.values()) { - try { - const { entries, source } = await fetchCustomRegistryFromSources(sources); - // Build the whole batch on one clone so that several changed providers - // from the same source do not overwrite each other's aliases, and so the - // config we compare is exactly the config we persist. - const next = structuredClone(config); - const changedProviders: Array<{ - readonly providerId: string; - readonly providerName: string; - readonly added: number; - readonly removed: number; - }> = []; - let hasUnreportedConfigChange = false; - const remoteEntries = Object.values(entries); - const remoteEntriesByProviderId = new Map( - remoteEntries.map((entry) => [entry.id, entry]), - ); - const providerIdsToSync = new Set(providerIds); - for (const entry of remoteEntries) providerIdsToSync.add(entry.id); - - for (const providerId of providerIdsToSync) { - const entry = remoteEntriesByProviderId.get(providerId); - if (entry === undefined) { - const oldIds = collectModelIdsForAliases(config, providerAliasKeys(config, providerId)); - removeCustomRegistryProvider(asManaged(next), providerId); - changedProviders.push({ - providerId, - providerName: providerId, - added: 0, - removed: oldIds.size, - }); - continue; - } - - const existed = config.providers[providerId] !== undefined; - applyCustomRegistryProvider(asManaged(next), entry, source); - const refreshedAliasKeys = providerRefreshAliasKeys(config, next, providerId, `${providerId}/`); - if (existed) { - restoreProviderAliases(next, preserveUserProviderAliases(config, providerId, refreshedAliasKeys)); - } - - if ( - existed && - providerModelsEqual(config, next, providerId, refreshedAliasKeys) && - providerConfigEqual(config, next, providerId) - ) { - unchanged.push(providerId); - } else if (existed && providerModelsEqual(config, next, providerId, refreshedAliasKeys)) { - unchanged.push(providerId); - hasUnreportedConfigChange = true; - } else { - const { added, removed } = computeChanges( - collectModelIdsForAliases(config, refreshedAliasKeys), - collectModelIdsForAliases(next, refreshedAliasKeys), - ); - changedProviders.push({ - providerId, - providerName: entry.name || providerId, - added, - removed, - }); - } - } - - if (changedProviders.length > 0 || hasUnreportedConfigChange) { - restoreDefaultSelection(next, config.defaultModel, config.defaultThinking); - clampDanglingDefault(next); - clearDefaultThinkingWhenDefaultRemoved(next, config.defaultModel); - // Full replacement, not merge: providers/models dropped from the remote - // registry and cleared defaults are removed from the persisted config. - config = await host.replaceConfig(next); - for (const change of changedProviders) { - changed.push({ - providerId: change.providerId, - providerName: change.providerName, - added: change.added, - removed: change.removed, - }); - } - } - } catch (error) { - for (const providerId of providerIds) { - failed.push({ - provider: providerId, - reason: error instanceof Error ? error.message : String(error), - }); - } - } - } - - return { changed, unchanged, failed }; + getConfig: () => host.getConfig(), + removeProvider: (providerId) => host.removeProvider(providerId), + setConfig: (patch) => host.setConfig(patch as unknown as PythinkerConfigPatch), + resolveOAuthToken: (providerName, oauthRef) => + host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef), + userAgent: host.userAgent, + }, + options, + ); } diff --git a/apps/pythinker-code/src/tui/utils/render-cache.ts b/apps/pythinker-code/src/tui/utils/render-cache.ts new file mode 100644 index 00000000..1f4816dc --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/render-cache.ts @@ -0,0 +1,28 @@ +/** + * Render-cache toggle for TUI message components. + * + * The transcript re-renders the entire component tree on every frame, and + * most message components rebuild their `render(width)` output from scratch + * even when their content has not changed. Caching the rendered lines (keyed + * on width + a dirty flag) turns an unchanged message's render into an O(1) + * array reference return, which is the dominant per-frame cost once the + * transcript grows long. + * + * The cache is on by default and can be disabled with + * `PYTHINKER_TUI_NO_RENDER_CACHE=1` as an escape hatch (and to let benchmarks + * compare cached vs. uncached runs in the same process). + */ + +let enabled = process.env['PYTHINKER_TUI_NO_RENDER_CACHE'] !== '1'; + +export function isRenderCacheEnabled(): boolean { + return enabled; +} + +/** + * Override the cache at runtime. Intended for benchmarks / tests only; + * production code should not call this. + */ +export function setRenderCacheEnabled(value: boolean): void { + enabled = value; +} diff --git a/apps/pythinker-code/src/tui/utils/screen-takeover.ts b/apps/pythinker-code/src/tui/utils/screen-takeover.ts new file mode 100644 index 00000000..4b0b965d --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/screen-takeover.ts @@ -0,0 +1,38 @@ +/** + * Mode-aware full-screen viewer takeover. + * + * In regular mode a viewer is mounted by snapshotting the root container's + * children and swapping the viewer in. In fullscreen (alternate screen) the + * root children are not painted at all — the layout root is — so the viewer + * must become the layout root instead. Both shapes restore cleanly and nest + * (a viewer opened from another viewer). + */ + +import type { Component, TUI } from '@pymodel/pi-tui'; +import { TuiAltScreen } from '@pymodel/pi-tui'; + +/** Restore data for a screen takeover; opaque to callers. */ +export type ScreenTakeover = + | { readonly kind: 'children'; readonly children: readonly Component[] } + | { readonly kind: 'root'; readonly root: Component | undefined }; + +export function beginScreenTakeover(ui: TUI, viewer: Component): ScreenTakeover { + if (ui instanceof TuiAltScreen) { + const root = ui.getLayoutRoot(); + ui.setLayoutRoot(viewer); + return { kind: 'root', root }; + } + const children = [...ui.children]; + ui.clear(); + ui.addChild(viewer); + return { kind: 'children', children }; +} + +export function endScreenTakeover(ui: TUI, takeover: ScreenTakeover): void { + if (takeover.kind === 'root') { + if (ui instanceof TuiAltScreen) ui.setLayoutRoot(takeover.root); + return; + } + ui.clear(); + for (const child of takeover.children) ui.addChild(child); +} diff --git a/apps/pythinker-code/src/tui/utils/searchable-list.ts b/apps/pythinker-code/src/tui/utils/searchable-list.ts index 7d707294..c424f649 100644 --- a/apps/pythinker-code/src/tui/utils/searchable-list.ts +++ b/apps/pythinker-code/src/tui/utils/searchable-list.ts @@ -2,11 +2,13 @@ * Cursor + fuzzy-search + paging state machine shared by list pickers * (ChoicePicker, ModelSelector). Pure logic, no rendering. * - * Components own presentation and key dispatch. This unit owns only cursor, - * paging, and search state. + * The component owns presentation and the keys that carry component-specific + * meaning — Enter (submit), Esc (cancel), and ←/→ (paging in one picker, a + * thinking toggle in another). This unit owns the keys that behave identically + * everywhere: ↑/↓, PgUp/PgDn, and search editing. */ -import { fuzzyFilter, Key, matchesKey } from '@earendil-works/pi-tui'; +import { fuzzyFilter, Key, matchesKey } from '@pymodel/pi-tui'; import { pageView, type PageView } from './paging'; import { isPrintableChar, printableChar } from './printable-key'; @@ -36,7 +38,7 @@ export interface SearchableListView<T> { } export class SearchableList<T> { - private readonly items: readonly T[]; + private items: readonly T[]; private readonly toSearchText: (item: T) => string; private readonly pageSize: number; private readonly searchable: boolean; @@ -51,6 +53,15 @@ export class SearchableList<T> { this.cursor = Math.max(opts.initialIndex ?? 0, 0); } + /** + * Replaces the item set (e.g. after another page was appended), keeping the + * active query; the cursor is clamped into the new range. + */ + setItems(items: readonly T[]): void { + this.items = items; + this.cursor = Math.min(this.cursor, Math.max(0, items.length - 1)); + } + filtered(): readonly T[] { if (this.query.length === 0) return this.items; return fuzzyFilter([...this.items], this.query, this.toSearchText); @@ -89,34 +100,6 @@ export class SearchableList<T> { this.cursor = Math.min(Math.max(0, this.filtered().length - 1), this.cursor + this.pageSize); } - moveToStart(): void { - this.cursor = 0; - } - - moveToEnd(): void { - this.cursor = Math.max(0, this.filtered().length - 1); - } - - moveToPrevious(predicate: (item: T) => boolean): void { - const items = this.filtered(); - for (let index = Math.min(this.cursor - 1, items.length - 1); index >= 0; index--) { - if (predicate(items[index]!)) { - this.cursor = index; - return; - } - } - } - - moveToNext(predicate: (item: T) => boolean): void { - const items = this.filtered(); - for (let index = this.cursor + 1; index < items.length; index++) { - if (predicate(items[index]!)) { - this.cursor = index; - return; - } - } - } - /** Clears the active query and resets the cursor. Returns whether a query was cleared. */ clearQuery(): boolean { if (this.query.length === 0) return false; @@ -125,7 +108,28 @@ export class SearchableList<T> { return true; } - handleSearchKey(data: string): boolean { + /** + * Handles the keys every picker shares: ↑/↓, PgUp/PgDn, and — when searchable — + * Backspace and printable characters. Returns true when the key was consumed. + * Enter, Esc, and ←/→ are intentionally left to the component. + */ + handleKey(data: string): boolean { + if (matchesKey(data, Key.up)) { + this.moveUp(); + return true; + } + if (matchesKey(data, Key.down)) { + this.moveDown(); + return true; + } + if (matchesKey(data, Key.pageUp)) { + this.pageUp(); + return true; + } + if (matchesKey(data, Key.pageDown)) { + this.pageDown(); + return true; + } if (!this.searchable) return false; if (matchesKey(data, Key.backspace)) { if (this.query.length > 0) { diff --git a/apps/pythinker-code/src/tui/utils/session-accent.ts b/apps/pythinker-code/src/tui/utils/session-accent.ts deleted file mode 100644 index b4b4efaa..00000000 --- a/apps/pythinker-code/src/tui/utils/session-accent.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** Stable accent color for a session key (title or id). */ -export function sessionAccentHex(key: string, mode: 'dark' | 'light'): string { - let hash = 5381; - for (let index = 0; index < key.length; index++) { - hash = Math.imul(hash, 33) + (key.codePointAt(index) ?? 0); - } - - return accentHexForHue((hash >>> 0) % 360, mode); -} - -export function accentHexForHue(hue: number, mode: 'dark' | 'light'): string { - if (mode === 'dark') return hslToHex(hue, 0.9, 0.72); - - for (let step = 0; step <= 11; step++) { - const accent = hslToHex(hue, 0.9, Math.max(0.2, 0.42 - step * 0.02)); - if (1.05 / (relativeLuminance(accent) + 0.05) >= 3) return accent; - } - return hslToHex(hue, 0.9, 0.2); -} - -function relativeLuminance(hex: string): number { - const linear = (channel: number) => - channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; - const red = linear(Number.parseInt(hex.slice(1, 3), 16) / 255); - const green = linear(Number.parseInt(hex.slice(3, 5), 16) / 255); - const blue = linear(Number.parseInt(hex.slice(5, 7), 16) / 255); - return 0.2126 * red + 0.7152 * green + 0.0722 * blue; -} - -function hslToHex(hue: number, saturation: number, lightness: number): string { - const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; - const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); - const match = lightness - chroma / 2; - const [red, green, blue] = - hue < 60 - ? [chroma, x, 0] - : hue < 120 - ? [x, chroma, 0] - : hue < 180 - ? [0, chroma, x] - : hue < 240 - ? [0, x, chroma] - : hue < 300 - ? [x, 0, chroma] - : [chroma, 0, x]; - return `#${[red, green, blue] - .map((channel) => Math.round((channel + match) * 255).toString(16).padStart(2, '0')) - .join('')}`.toUpperCase(); -} diff --git a/apps/pythinker-code/src/tui/utils/shell-output.ts b/apps/pythinker-code/src/tui/utils/shell-output.ts new file mode 100644 index 00000000..3a482feb --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/shell-output.ts @@ -0,0 +1,71 @@ +import { currentTheme } from '#/tui/theme'; + +// Captured command output can contain terminal control sequences — colours, +// cursor moves, alternate-screen switches, hyperlinks, `\r` spinners, bells, … +// We render through pi-tui, which passes strings straight to the terminal, so +// any sequence left intact is executed by the terminal and fights with pi-tui's +// own cursor control (the "blank screen + leftover characters" symptom). Strip +// everything a terminal would interpret as a command rather than printable text, +// keeping only `\n` and `\t` (which the renderer understands). + +// ESC [ <params> <intermediates> <final> — colours, cursor moves, clear, and +// private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). +const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +// ESC ] … <BEL> or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. +const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; +// ESC <char> (and ESC <intermediate> <char>) — charset/keypad selection, +// save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the +// CSI/OSC patterns, so it only catches sequences they didn't already consume. +const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; +// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … +// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; + +/** + * Strip every terminal control sequence from captured command output so it is + * safe to render via pi-tui (which does not sanitize on its own). + * + * Never throws: a bad or pathological input falls back to stripping only the + * C0 control characters, so rendering can never crash the TUI. + */ +export function sanitizeShellOutput(text: string): string { + if (typeof text !== 'string') return ''; + if (text.length === 0) return text; + try { + return text + .replace(OSC_PATTERN, '') + .replace(CSI_PATTERN, '') + .replace(ESC_SINGLE_PATTERN, '') + .replace(C0_CONTROL_PATTERN, ''); + } catch { + return text.replace(C0_CONTROL_PATTERN, ''); + } +} + +/** + * Format captured stdout/stderr for the transcript. Sanitizes both streams and + * dims them; stderr is red only on actual failure. + * + * Never throws: if anything goes wrong (theme lookup, huge input, …) it falls + * back to a best-effort plain view so a render error can never crash the TUI. + */ +export function formatBashOutputForDisplay(stdout: string, stderr: string, isError?: boolean): string { + try { + const dim = (s: string): string => currentTheme.fg('textDim', s); + const parts: string[] = []; + const cleanStdout = sanitizeShellOutput(stdout).trimEnd(); + if (cleanStdout.length > 0) parts.push(dim(cleanStdout)); + const cleanStderr = sanitizeShellOutput(stderr).trimEnd(); + if (cleanStderr.length > 0) { + // Dim grey normally; red only on actual failure (so warnings on a + // successful command are not mistaken for errors). + parts.push(isError ? currentTheme.fg('error', cleanStderr) : dim(cleanStderr)); + } + return parts.length > 0 ? parts.join('\n') : dim('(no output)'); + } catch { + const plain = [sanitizeShellOutput(String(stdout ?? '')), sanitizeShellOutput(String(stderr ?? ''))] + .filter((s) => s.length > 0) + .join('\n'); + return plain.length > 0 ? plain : '(no output)'; + } +} diff --git a/apps/pythinker-code/src/tui/utils/shimmer.ts b/apps/pythinker-code/src/tui/utils/shimmer.ts deleted file mode 100644 index 3f588830..00000000 --- a/apps/pythinker-code/src/tui/utils/shimmer.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { currentTheme, type ColorToken } from '#/tui/theme'; - -export interface ShimmerTextOptions { - baseToken: ColorToken; - shimmerToken: ColorToken; - altShimmerToken?: ColorToken; - /** Half-width of the cosine shimmer band, in terminal cells. */ - bandHalfWidth?: number; - phaseOffset?: number; -} - -const CELLS_PER_SECOND = 20; -const BAND_HALF_WIDTH = 6; - -type ShimmerTier = 'dim' | 'base' | 'shimmer'; - -export function shimmerText(text: string, options: ShimmerTextOptions): string { - const chars = Array.from(text); - if (chars.length === 0) return ''; - - const halfWidth = Math.max(1, options.bandHalfWidth ?? BAND_HALF_WIDTH); - const cycleLength = chars.length + halfWidth * 2; - const rawPosition = Date.now() / 1_000 * CELLS_PER_SECOND + (options.phaseOffset ?? 0); - const center = rawPosition % cycleLength - halfWidth; - const passIndex = Math.floor(rawPosition / cycleLength); - const peakToken = options.altShimmerToken !== undefined && passIndex % 2 !== 0 - ? options.altShimmerToken - : options.shimmerToken; - - let result = ''; - let segment = ''; - let activeTier: ShimmerTier | undefined; - - for (let index = 0; index < chars.length; index++) { - const char = chars[index]; - if (char === undefined) continue; - - const distance = Math.abs(index - center); - const intensity = - distance >= halfWidth ? 0 : (Math.cos(Math.PI * distance / halfWidth) + 1) / 2; - const tier: ShimmerTier = intensity < 0.22 ? 'dim' : intensity < 0.65 ? 'base' : 'shimmer'; - if (activeTier === undefined) { - activeTier = tier; - segment = char; - continue; - } - - if (tier === activeTier) { - segment += char; - continue; - } - - result += paintTier(activeTier, segment, options.baseToken, peakToken); - activeTier = tier; - segment = char; - } - - if (activeTier !== undefined) { - result += paintTier(activeTier, segment, options.baseToken, peakToken); - } - - return result; -} - -function paintTier( - tier: ShimmerTier, - text: string, - baseToken: ColorToken, - peakToken: ColorToken, -): string { - if (tier === 'dim') return currentTheme.fg('textDim', text); - if (tier === 'shimmer') return currentTheme.boldFg(peakToken, text); - return currentTheme.fg(baseToken, text); -} diff --git a/apps/pythinker-code/src/tui/utils/status-line-command.ts b/apps/pythinker-code/src/tui/utils/status-line-command.ts new file mode 100644 index 00000000..a9ec1541 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/status-line-command.ts @@ -0,0 +1,185 @@ +/** + * User-provided status line command (`status_line.command` in tui.toml). + * + * The footer spawns the command with a JSON snapshot on stdin and renders the + * first stdout line. Runs are throttled and time-boxed; any failure (spawn + * error, nonzero exit, timeout) yields null so the caller falls back to the + * built-in layout. Mirrors Claude Code's statusLine contract at the seam: + * JSON in, first line out, 300ms ceiling. + */ + +import { spawn } from 'node:child_process'; + +export const STATUS_LINE_COMMAND_TIMEOUT_MS = 300; +export const STATUS_LINE_RERUN_INTERVAL_MS = 1_000; +export const STATUS_LINE_MAX_CAPTURE_BYTES = 65_536; + +export interface StatusLinePayload { + model: string; + cwd: string; + gitBranch: string | null; + permissionMode: string; + planMode: boolean; + contextUsage: number; + contextTokens: number; + maxContextTokens: number; + sessionId: string; + version: string; +} + +export function runStatusLineCommand( + command: string, + payload: StatusLinePayload, + timeoutMs: number = STATUS_LINE_COMMAND_TIMEOUT_MS, +): Promise<string | null> { + return new Promise((resolve) => { + let settled = false; + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + resolve(value); + }; + + const isWin = process.platform === 'win32'; + let child; + try { + child = spawn(isWin ? (process.env['ComSpec'] ?? 'cmd.exe') : 'sh', isWin ? ['/d', '/s', '/c', command] : ['-c', command], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, PYTHINKER_CODE_STATUS_LINE: '1' }, + // Own process group on POSIX so a timeout can drop the whole tree, + // not just the shell wrapper. + detached: !isWin, + }); + } catch { + finish(null); + return; + } + + const killTree = (): void => { + if (child.pid === undefined) return; + if (isWin) { + try { + spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } catch { + // best effort + } + } else { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } + }; + + const timer = setTimeout(() => { + killTree(); + finish(null); + }, timeoutMs); + timer.unref?.(); + + let stdout = ''; + child.stdout?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + if (stdout.includes('\n')) return; // first line is complete + stdout += chunk; + // Only the first line is ever used; stop accumulating past it (and cap + // a missing-newline stream) so a chatty command can't grow memory + // unboundedly before the timeout lands. + const cut = stdout.indexOf('\n'); + if (cut >= 0) { + stdout = stdout.slice(0, cut + 1); + } else if (stdout.length > STATUS_LINE_MAX_CAPTURE_BYTES) { + stdout = stdout.slice(0, STATUS_LINE_MAX_CAPTURE_BYTES); + } + }); + child.on('error', () => { + clearTimeout(timer); + finish(null); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) { + finish(null); + return; + } + const firstLine = (stdout.split('\n')[0] ?? '').trimEnd(); + finish(firstLine.length > 0 ? firstLine : null); + }); + + child.stdin?.on('error', () => { + // The command closed stdin early (e.g. `true`); nothing more to send. + }); + child.stdin?.end(JSON.stringify(payload)); + }); +} + +/** + * Throttled cache around `runStatusLineCommand` for a sync render path: + * `current()` returns the last good line, and a refresh is kicked off in the + * background at most once per interval. `onUpdate` fires when a fresh line + * lands so the footer can repaint. + */ +export class StatusLineCommandRunner { + private lastRunAt = 0; + private cached: string | null = null; + private inFlight = false; + private pendingPayload: StatusLinePayload | null = null; + private trailingTimer: ReturnType<typeof setTimeout> | null = null; + + constructor( + readonly command: string, + private readonly onUpdate: () => void, + ) {} + + current(): string | null { + return this.cached; + } + + maybeRefresh(payload: StatusLinePayload): void { + const now = Date.now(); + if (this.inFlight || now - this.lastRunAt < STATUS_LINE_RERUN_INTERVAL_MS) { + // Don't drop the update: land it as soon as the current gap expires, + // so a final state change is never lost to throttling. + this.pendingPayload = payload; + this.scheduleTrailing(now); + return; + } + this.startRun(payload, now); + } + + dispose(): void { + if (this.trailingTimer !== null) { + clearTimeout(this.trailingTimer); + this.trailingTimer = null; + } + this.pendingPayload = null; + } + + private scheduleTrailing(now: number): void { + if (this.trailingTimer !== null) return; + const waitMs = Math.max(0, STATUS_LINE_RERUN_INTERVAL_MS - (now - this.lastRunAt)); + this.trailingTimer = setTimeout(() => { + this.trailingTimer = null; + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }, waitMs); + this.trailingTimer.unref?.(); + } + + private startRun(payload: StatusLinePayload, now: number): void { + this.inFlight = true; + this.lastRunAt = now; + void runStatusLineCommand(this.command, payload).then((line) => { + this.inFlight = false; + if (line !== null) { + this.cached = line; + this.onUpdate(); + } + const pending = this.pendingPayload; + this.pendingPayload = null; + if (pending !== null) this.maybeRefresh(pending); + }); + } +} diff --git a/apps/pythinker-code/src/tui/utils/steer-input.ts b/apps/pythinker-code/src/tui/utils/steer-input.ts new file mode 100644 index 00000000..a7108fc2 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/steer-input.ts @@ -0,0 +1,56 @@ +/** + * Steer-input composition for `session.steer`: flattens queued items (and the + * editor draft) into one payload — the historical `'\n\n'`-joined string when + * nothing carries media, or a merged part list when any item has extracted + * media parts (queued image messages, or the editor draft after placeholder + * extraction). Media parts are self-contained daemon references; no machine + * `<media path>` tag is authored, so text parts always merge freely. + */ + +import type { PromptPart } from '@pymodel/pythinker-code-sdk'; + +import type { SteerInputItem } from '../types'; + +/** + * Flatten steer items into the payload `session.steer` expects. + * + * Items are separated by the historical `'\n\n'`, which merges into the + * adjacent text part. The one exception is two touching media parts: a + * standalone `{type:'text',text:'\n\n'}` between them would be rejected + * by `normalizePromptInput` as an empty text part, so the separator is + * dropped there (media parts are self-delimiting anyway). + */ +export function combineSteerInput(items: readonly SteerInputItem[]): string | PromptPart[] { + const hasMedia = items.some((item) => item.parts !== undefined && item.parts.length > 0); + if (!hasMedia) return items.map((item) => item.text).join('\n\n'); + const parts: PromptPart[] = []; + for (const item of items) { + const first = item.parts?.[0]; + const startsWithMedia = first !== undefined && first.type !== 'text'; + const lastIsMedia = parts.length > 0 && parts.at(-1)?.type !== 'text'; + if (parts.length > 0 && !(lastIsMedia && startsWithMedia)) { + appendSteerText(parts, '\n\n'); + } + if (item.parts !== undefined && item.parts.length > 0) { + for (const part of item.parts) { + if (part.type !== 'text') { + parts.push(part); + continue; + } + appendSteerText(parts, part.text); + } + } else { + appendSteerText(parts, item.text); + } + } + return parts; +} + +function appendSteerText(parts: PromptPart[], text: string): void { + const last = parts.at(-1); + if (last?.type === 'text') { + parts[parts.length - 1] = { type: 'text', text: last.text + text }; + return; + } + parts.push({ type: 'text', text }); +} diff --git a/apps/pythinker-code/src/tui/utils/step-retry.ts b/apps/pythinker-code/src/tui/utils/step-retry.ts new file mode 100644 index 00000000..34a79887 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/step-retry.ts @@ -0,0 +1,19 @@ +import { RETRY_DETAIL_MAX_CHARS } from '../constant/rendering'; +import type { StepRetryState } from '../types'; + +export function formatStepRetryLabel(retry: StepRetryState): string { + const base = `Retrying (${retry.nextAttempt}/${retry.maxAttempts}) · ${retry.errorName}`; + if (retry.phase === 'attempt') return base; + const delaySeconds = Math.max(1, Math.ceil(retry.delayMs / 1000)); + return `${base} · in ${delaySeconds}s`; +} + +/** Detail line under the spinner: status code + provider message, single-line, capped. */ +export function formatStepRetryDetail(retry: StepRetryState): string { + const message = retry.errorMessage.replaceAll(/\s+/g, ' ').trim(); + const code = retry.statusCode === undefined ? '' : String(retry.statusCode); + const detail = [code, message].filter((part) => part.length > 0).join(' · '); + return detail.length > RETRY_DETAIL_MAX_CHARS + ? `${detail.slice(0, RETRY_DETAIL_MAX_CHARS - 1)}…` + : detail; +} diff --git a/apps/pythinker-code/src/tui/utils/tab-strip.ts b/apps/pythinker-code/src/tui/utils/tab-strip.ts new file mode 100644 index 00000000..56482f55 --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/tab-strip.ts @@ -0,0 +1,94 @@ +/** + * Shared tab strip renderer for tabbed dialogs (model selector, plugin + * marketplace, …). The active tab is filled with the brand background, inactive + * tabs are muted — matching the AskUserQuestion dialog. See + * .agents/skills/write-tui/DESIGN.md §5. + * + * When the strip is wider than the terminal, it scrolls to keep the active tab + * visible, framed by `<`/`>` markers. + */ + +import { visibleWidth } from '@pymodel/pi-tui'; +import chalk from 'chalk'; + +import type { ColorPalette } from '#/tui/theme/colors'; + +export interface RenderTabStripOptions { + readonly labels: readonly string[]; + readonly activeIndex: number; + readonly width: number; + readonly colors: ColorPalette; +} + +/** Style one tab cell. Active and inactive cells have the same visible width so + * switching never shifts the layout. */ +function styleTab(label: string, isActive: boolean, colors: ColorPalette): string { + const cell = ` ${label} `; + return isActive + ? chalk.bgHex(colors.primary).hex(colors.text).bold(cell) + : chalk.hex(colors.textMuted)(cell); +} + +export function renderTabStrip(opts: RenderTabStripOptions): string { + const { labels, activeIndex, width, colors } = opts; + const segments = labels.map((label, i) => styleTab(label, i === activeIndex, colors)); + + // If everything fits with a leading space, show the whole strip. Account for + // the single spaces `segments.join(' ')` inserts between tabs — otherwise the + // strip is declared to fit at widths where the joined line is actually wider + // and gets truncated instead of showing the `<`/`>` scroll markers. + const totalSegmentWidth = segments.reduce((sum, s) => sum + visibleWidth(s), 0); + const fullSeparatorWidth = Math.max(0, segments.length - 1); + if (1 + totalSegmentWidth + fullSeparatorWidth <= width) { + return ' ' + segments.join(' '); + } + + // Scrolling needed. Find the widest window that contains activeIndex. + const segmentWidths = segments.map((s) => visibleWidth(s)); + let start = activeIndex; + let end = activeIndex + 1; + let contentWidth = segmentWidths[activeIndex] ?? 0; + + const fits = (s: number, e: number, cw: number): boolean => { + const needLeft = s > 0; + const needRight = e < segments.length; + const frameWidth = (needLeft ? 2 : 1) + (needRight ? 2 : 0); + const separators = Math.max(0, e - s - 1); + return cw + separators + frameWidth <= width; + }; + + while (true) { + const leftW = start > 0 ? segmentWidths[start - 1]! : Infinity; + const rightW = end < segments.length ? segmentWidths[end]! : Infinity; + if (leftW === Infinity && rightW === Infinity) break; + + if (leftW <= rightW) { + if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else { + break; + } + } else if (fits(start, end + 1, contentWidth + rightW)) { + contentWidth += rightW; + end++; + } else if (fits(start - 1, end, contentWidth + leftW)) { + contentWidth += leftW; + start--; + } else { + break; + } + } + + const hasLeft = start > 0; + const hasRight = end < segments.length; + let strip = hasLeft ? chalk.hex(colors.textMuted)('< ') : ' '; + strip += segments.slice(start, end).join(' '); + if (hasRight) { + strip += chalk.hex(colors.textMuted)(' >'); + } + return strip; +} diff --git a/apps/pythinker-code/src/tui/utils/terminal-notification.ts b/apps/pythinker-code/src/tui/utils/terminal-notification.ts index ab6f1bff..59a868ca 100644 --- a/apps/pythinker-code/src/tui/utils/terminal-notification.ts +++ b/apps/pythinker-code/src/tui/utils/terminal-notification.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@pymodel/pi-tui'; import { BEL, ESC, MAX_TERMINAL_NOTIFICATION_MESSAGE_LENGTH, ST } from '#/tui/constant/terminal'; import type { TUIState } from '#/tui/tui-state'; diff --git a/apps/pythinker-code/src/tui/utils/terminal-size.ts b/apps/pythinker-code/src/tui/utils/terminal-size.ts deleted file mode 100644 index 54c96f94..00000000 --- a/apps/pythinker-code/src/tui/utils/terminal-size.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** Resolve when the stream reports a usable size, or after timeoutMs. */ -export function waitForTerminalSize( - stream: Pick<NodeJS.WriteStream, 'columns' | 'once' | 'removeListener'>, - timeoutMs = 250, -): Promise<void> { - if (typeof stream.columns === 'number' && stream.columns > 0) return Promise.resolve(); - - return new Promise((resolve) => { - const finish = (): void => { - clearTimeout(timeout); - stream.removeListener('resize', finish); - resolve(); - }; - const timeout = setTimeout(finish, timeoutMs); - stream.once('resize', finish); - timeout.unref?.(); - }); -} diff --git a/apps/pythinker-code/src/tui/utils/thinking-config.ts b/apps/pythinker-code/src/tui/utils/thinking-config.ts new file mode 100644 index 00000000..22eb020e --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/thinking-config.ts @@ -0,0 +1,47 @@ +import type { ThinkingEffort } from '@pymodel/pythinker-code-sdk'; + +/** Whether a thinking effort represents "thinking enabled" (anything but 'off'). */ +export function isThinkingOn(effort: ThinkingEffort): boolean { + return effort !== 'off'; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml. `'off'` disables thinking; `'on'` is the boolean-model + * on-signal rather than a declared effort, so it only persists `enabled` — + * boolean models resolve back to `'on'` at runtime via + * `defaultThinkingEffortFor`. A concrete effort persists as the global + * default, EXCEPT the model's highest declared level — the last entry of + * `support_efforts` (the list is ordered by strength, the same assumption + * the `middleOf` default-effort resolution makes) — which is session-only + * and records just `enabled`, so the most expensive tier never becomes the + * global default for every new session. When the model's levels are unknown + * the concrete effort is persisted as-is. + */ +export function thinkingEffortToConfig( + effort: ThinkingEffort, + supportEfforts?: readonly string[], +): { + enabled: boolean; + effort?: string; +} { + if (effort === 'off') return { enabled: false }; + if (effort === 'on') return { enabled: true }; + const top = supportEfforts?.at(-1); + if (top !== undefined && effort === top) return { enabled: true }; + return { enabled: true, effort }; +} + +/** + * Inverse of {@link thinkingEffortToConfig}: derive the runtime thinking effort + * to activate a model with from the persisted `[thinking]` config. Returns + * `'off'` when thinking is disabled, the configured concrete effort when set, + * and `undefined` when thinking is enabled without a concrete effort so the + * model's own default applies. + */ +export function thinkingEffortFromConfig( + config: { enabled?: boolean; effort?: string } | undefined, +): ThinkingEffort | undefined { + if (config?.enabled === false) return 'off'; + return config?.effort; +} diff --git a/apps/pythinker-code/src/tui/utils/thinking-levels.ts b/apps/pythinker-code/src/tui/utils/thinking-levels.ts deleted file mode 100644 index 7a9b7a00..00000000 --- a/apps/pythinker-code/src/tui/utils/thinking-levels.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Thinking-effort presentation helpers (TUI-only). - * - * Only theme-bound presentation lives here, because the SDK must not depend on - * the TUI theme. The level rules themselves are - * `@pymodel/pythinker-code-sdk`'s, so the terminal and VS Code renderers - * offer the same levels for the same model; import them from there. - */ - -import type { ColorToken } from '#/tui/theme'; - -const EFFORT_COLOR_TOKENS = { - minimal: 'effortLow', - low: 'effortLow', - medium: 'effortMedium', - high: 'effortHigh', - xhigh: 'effortXHigh', - max: 'effortMax', -} as const satisfies Record<string, ColorToken>; - -export function effortColorToken(level: string): ColorToken { - return EFFORT_COLOR_TOKENS[level as keyof typeof EFFORT_COLOR_TOKENS] ?? 'primary'; -} - -/** Compact label for footer / editor badges: `medium` → `med`, others unchanged. */ -export function shortEffortLabel(level: string): string { - return level === 'medium' ? 'med' : level; -} diff --git a/apps/pythinker-code/src/tui/utils/transcript-component-metadata.ts b/apps/pythinker-code/src/tui/utils/transcript-component-metadata.ts index ddfb6861..458c71ae 100644 --- a/apps/pythinker-code/src/tui/utils/transcript-component-metadata.ts +++ b/apps/pythinker-code/src/tui/utils/transcript-component-metadata.ts @@ -1,23 +1,11 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import type { TranscriptEntry } from '../types'; -export type TranscriptChildRole = 'durable' | 'live-durable' | 'ephemeral'; - -export interface TranscriptChildMetadata { - readonly role: TranscriptChildRole; - readonly edgeBlankPolicy: 'trim-plain' | 'preserve'; -} - const componentEntries = new WeakMap<Component, TranscriptEntry>(); -const componentMetadata = new WeakMap<Component, TranscriptChildMetadata>(); export function markTranscriptComponent(component: Component, entry: TranscriptEntry): void { componentEntries.set(component, entry); - markTranscriptChild(component, { - role: 'durable', - edgeBlankPolicy: 'trim-plain', - }); } export function getTranscriptComponentEntry( @@ -25,16 +13,3 @@ export function getTranscriptComponentEntry( ): TranscriptEntry | undefined { return componentEntries.get(component); } - -export function markTranscriptChild( - component: Component, - metadata: TranscriptChildMetadata, -): void { - componentMetadata.set(component, metadata); -} - -export function getTranscriptChildMetadata( - component: Component, -): TranscriptChildMetadata | undefined { - return componentMetadata.get(component); -} diff --git a/apps/pythinker-code/src/tui/utils/transcript-window.ts b/apps/pythinker-code/src/tui/utils/transcript-window.ts new file mode 100644 index 00000000..3c623fec --- /dev/null +++ b/apps/pythinker-code/src/tui/utils/transcript-window.ts @@ -0,0 +1,125 @@ +/** + * Sliding window for the TUI transcript. + * + * The transcript grows unbounded as the conversation goes on. To keep the TUI + * responsive and bounded, we only keep the most recent N *turns* (a turn = a + * user prompt plus everything the assistant does in response, identified by a + * shared `turnId`), and destroy older turns wholesale (component + entry). + * Within a kept turn, older steps — and assistant messages beyond a cap — are + * folded into a collapsed summary line so a single long turn cannot grow the + * mounted component tree without bound. + * + * All threshold logic here is pure so it can be unit-tested in isolation; the + * constants are the production defaults passed in by the TUI. + */ + +import type { TranscriptEntry } from '../types'; + +/** + * Read a non-negative integer env var, falling back to `fallback` when it is + * unset, empty, negative, or not an integer. `0` is a valid value (call sites + * treat it as "feature disabled"). + */ +export function readEnvInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return fallback; + return value; +} + +/** Master switch for the sliding window. */ +export const TRANSCRIPT_WINDOW_ENABLED = true; + +/** Keep the most recent N turns. `0` disables trimming. */ +export const TRANSCRIPT_MAX_TURNS = readEnvInt('PYTHINKER_CODE_TUI_MAX_TURNS', 15); + +/** Only the most recent E turns are allowed to expand (Ctrl+O). `0` disables expanding. */ +export const TRANSCRIPT_EXPAND_TURNS = readEnvInt('PYTHINKER_CODE_TUI_EXPAND_TURNS', 3); + +/** Only trim once the window exceeds maxTurns by this much (avoids churn). */ +export const TRANSCRIPT_HYSTERESIS = readEnvInt('PYTHINKER_CODE_TUI_HYSTERESIS', 5); + +/** Keep this many recent steps untouched inside a turn; older steps are merged into a summary. `0` disables merging. */ +export const TRANSCRIPT_KEEP_RECENT_STEPS = readEnvInt('PYTHINKER_CODE_TUI_KEEP_RECENT_STEPS', 30); + +/** Keep this many recent assistant messages mounted inside the active turn; older ones fold into the step summary. `0` disables folding. */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT = readEnvInt('PYTHINKER_CODE_TUI_KEEP_RECENT_ASSISTANT', 20); + +/** + * Once a turn ends, fold all but its last few assistant messages into the + * step summary — intermediate chatter is rarely re-read, while the tail + * usually holds the conclusion. `0` disables folding. + */ +export const TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED = readEnvInt( + 'PYTHINKER_CODE_TUI_KEEP_RECENT_ASSISTANT_COMPLETED', + 2, +); + +export interface TranscriptTurn { + readonly turnId: string | undefined; + readonly entries: TranscriptEntry[]; +} + +/** + * Group consecutive entries into turns by `turnId`. Entries with the same + * non-undefined `turnId` that are adjacent belong to the same turn. + * + * Entries with an undefined `turnId` are buffered and attached to the *next* + * defined turn. This matters because a user message is appended (with + * `turnId: undefined`) before its turn actually starts, so without this + * buffering every user message would become its own single-entry turn at the + * front and get trimmed first. Any undefined entries left at the tail (no + * following turn) become their own turn. + */ +export function groupTurns(entries: readonly TranscriptEntry[]): TranscriptTurn[] { + const turns: TranscriptTurn[] = []; + let current: TranscriptTurn | undefined; + let pendingUndefined: TranscriptEntry[] = []; + + for (const entry of entries) { + const turnId = entry.turnId; + if (turnId === undefined) { + pendingUndefined.push(entry); + continue; + } + if (current !== undefined && current.turnId === turnId) { + current.entries.push(entry); + } else { + current = { turnId, entries: [...pendingUndefined, entry] }; + pendingUndefined = []; + turns.push(current); + } + } + + if (pendingUndefined.length > 0) { + turns.push({ turnId: undefined, entries: pendingUndefined }); + } + + return turns; +} + +/** + * Decide which entries to destroy so the remaining turns fit within + * `maxTurns`. Returns an empty set when the turn count is within + * `maxTurns + hysteresis`. Oldest turns are removed first; the most recent + * turn is never removed (it is the active / just-finished turn). + */ +export function turnsToTrim( + turns: readonly TranscriptTurn[], + maxTurns: number, + hysteresis: number, +): Set<TranscriptEntry> { + const toRemove = new Set<TranscriptEntry>(); + + if (turns.length <= maxTurns + hysteresis) return toRemove; + + let remaining = turns.length; + // `turns.length - 1` keeps the most recent turn off-limits. + for (let i = 0; i < turns.length - 1 && remaining > maxTurns; i++) { + const turn = turns[i]!; + for (const entry of turn.entries) toRemove.add(entry); + remaining--; + } + return toRemove; +} diff --git a/apps/pythinker-code/src/utils/cache-hint-config.ts b/apps/pythinker-code/src/utils/cache-hint-config.ts new file mode 100644 index 00000000..4909970d --- /dev/null +++ b/apps/pythinker-code/src/utils/cache-hint-config.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { + getClientConfig, + peekClientConfig, + resetClientConfigCache, + type ClientConfigFetchOptions, +} from '#/utils/client-configs'; + +/** The cache-hint rules are one named config on the client-configs endpoint. */ +const CONFIG_NAME = 'estimated_cache_duration'; + +const cacheHintModelRuleSchema = z.object({ + min_tokens_to_hint: z.number(), + cache_duration: z.number(), +}); + +const cacheHintConfigSchema = z.object({ + version: z.literal(1), + config: z.record(z.string(), cacheHintModelRuleSchema), +}); + +export type CacheHintConfig = z.infer<typeof cacheHintConfigSchema>; +export type CacheHintConfigFetchOptions = ClientConfigFetchOptions; + +/** + * Returns the cache-hint config, preferring the cache (1 day, persisted + * across restarts). Any failure resolves to `undefined` — callers treat + * that as "do not hint". + */ +export async function getCacheHintConfig( + options: CacheHintConfigFetchOptions = {}, +): Promise<CacheHintConfig | undefined> { + return getClientConfig(CONFIG_NAME, cacheHintConfigSchema, options); +} + +/** Fire-and-forget refresh, e.g. on new-session creation. Never throws. */ +export function refreshCacheHintConfigInBackground( + options: CacheHintConfigFetchOptions = {}, +): void { + void getCacheHintConfig(options).catch(() => undefined); +} + +/** Synchronous peek at the fresh cache; undefined when missing or stale. */ +export function peekCacheHintConfig(now?: number): CacheHintConfig | undefined { + return peekClientConfig(CONFIG_NAME, cacheHintConfigSchema, now); +} + +/** Test hook: drop the in-process cache. */ +export function resetCacheHintConfigCache(): void { + resetClientConfigCache(CONFIG_NAME); +} diff --git a/apps/pythinker-code/src/utils/catalog-fetch.ts b/apps/pythinker-code/src/utils/catalog-fetch.ts new file mode 100644 index 00000000..8d2ad710 --- /dev/null +++ b/apps/pythinker-code/src/utils/catalog-fetch.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_CATALOG_URL, + fetchCatalog, + loadBuiltInCatalog, + type Catalog, + type FetchCatalogOptions, +} from '@pymodel/pythinker-code-sdk'; + +import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; + +export interface FetchCatalogOrBuiltInResult { + readonly catalog: Catalog; + /** True when the network fetch failed and the release-build snapshot was used. */ + readonly fromBuiltIn: boolean; +} + +export interface FetchCatalogOrBuiltInOptions extends FetchCatalogOptions { + /** + * Override the built-in snapshot JSON (tests). Defaults to the tsdown-injected + * `__PYTHINKER_CODE_BUILT_IN_CATALOG__` constant. + */ + readonly builtInJson?: string; +} + +/** + * Fetches a models.dev-style catalog, falling back to the release-build + * snapshot when the public default URL is unreachable. + * + * Custom `--url` overrides never fall back — a private registry must fail + * loudly rather than silently substitute models.dev. User abort + * (`signal.aborted`) also skips the fallback so Cancel stays Cancel. + */ +export async function fetchCatalogOrBuiltIn( + url: string, + options: FetchCatalogOrBuiltInOptions = {}, +): Promise<FetchCatalogOrBuiltInResult> { + try { + const catalog = await fetchCatalog(url, options); + return { catalog, fromBuiltIn: false }; + } catch (error) { + if (options.signal?.aborted) throw error; + if (isAbortError(error)) throw error; + if (url !== DEFAULT_CATALOG_URL) throw error; + const builtIn = loadBuiltInCatalog(options.builtInJson ?? BUILT_IN_CATALOG_JSON); + if (builtIn === undefined) throw error; + return { catalog: builtIn, fromBuiltIn: true }; + } +} + +function isAbortError(error: unknown): boolean { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} diff --git a/apps/pythinker-code/src/utils/client-configs.ts b/apps/pythinker-code/src/utils/client-configs.ts new file mode 100644 index 00000000..73b64dd6 --- /dev/null +++ b/apps/pythinker-code/src/utils/client-configs.ts @@ -0,0 +1,187 @@ +import { join } from 'node:path'; + +import { pythinkerCodeBaseUrl } from '@pymodel/pythinker-code-oauth'; +import { z } from 'zod'; + +import { getCacheDir } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Generic client for the public client-configs endpoint: + * `POST {pythinkerCodeBaseUrl}/client_configs {"name": "<config name>"}` returns + * `{ name, config: <payload> }`, where the payload shape is config-specific + * and validated by the caller-supplied schema. + * + * Each named config is cached for a day, in two layers: an in-process map + * (the only layer the synchronous peek can see) and a JSON file under the + * CLI cache dir (survives restarts, so the TTL holds across processes). An + * entry missing/stale in both layers triggers a refetch. Any failure + * resolves to `undefined` — callers treat that as "config unavailable" and + * degrade quietly. + */ +const CLIENT_CONFIGS_PATH = '/client_configs'; + +/** Cache validity per config name: 1 day. */ +const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 5000; + +export interface ClientConfigFetchOptions { + /** Managed OAuth token; sent as Bearer when present. The endpoint is + * public, so anonymous fetches work too. */ + readonly accessToken?: string; + /** Test hook. */ + readonly fetchImpl?: typeof fetch; + /** Test hook. */ + readonly now?: number; + /** Test hook: override the cache file path, or null to skip the disk + * layer entirely. */ + readonly cacheFile?: string | null; +} + +const cache = new Map<string, { readonly fetchedAt: number; readonly data: unknown }>(); + +const cacheFileEnvelopeSchema = z.object({ + version: z.literal(1), + fetchedAt: z.number(), + config: z.unknown(), +}); + +function cacheFileFor(name: string, options: ClientConfigFetchOptions): string | undefined { + if (options.cacheFile === null) return undefined; + if (options.cacheFile !== undefined) return options.cacheFile; + return join(getCacheDir(), 'client-configs', `${name.replaceAll(/[^a-zA-Z0-9_-]/g, '_')}.json`); +} + +/** Fresh disk entry, or undefined when missing/stale/invalid. */ +async function readDiskCache<S extends z.ZodType>( + file: string, + schema: S, + now: number, +): Promise<{ readonly fetchedAt: number; readonly data: z.infer<S> } | undefined> { + let envelope: z.infer<typeof cacheFileEnvelopeSchema>; + try { + // The sentinel's fetchedAt=0 reads as stale, i.e. missing. + envelope = await readJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: 0, + config: undefined, + }); + } catch { + return undefined; // malformed cache file — treat as missing + } + if (now - envelope.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(envelope.config); + return parsed.success + ? { fetchedAt: envelope.fetchedAt, data: parsed.data as z.infer<S> } + : undefined; +} + +/** Best-effort persist; a cache write failure must never break the caller. */ +async function writeDiskCache(file: string, data: unknown, now: number): Promise<void> { + try { + await writeJsonFile(file, cacheFileEnvelopeSchema, { + version: 1, + fetchedAt: now, + config: data, + }); + } catch { + // A cache that cannot be written just means the next process refetches. + } +} + +/** Returns the named client config, preferring the caches over the network. */ +export async function getClientConfig<S extends z.ZodType>( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise<z.infer<S> | undefined> { + const now = options.now ?? Date.now(); + const hit = cache.get(name); + if (hit !== undefined && now - hit.fetchedAt < CONFIG_CACHE_TTL_MS) { + return hit.data as z.infer<S>; + } + const file = cacheFileFor(name, options); + if (file !== undefined) { + const diskHit = await readDiskCache(file, schema, now); + if (diskHit !== undefined) { + // Warm the in-process layer with the original fetch time, so the entry + // still expires a day after it was actually fetched. + cache.set(name, diskHit); + return diskHit.data; + } + } + const data = await fetchClientConfig(name, schema, options); + if (data === undefined) return undefined; + cache.set(name, { fetchedAt: now, data }); + if (file !== undefined) await writeDiskCache(file, data, now); + return data; +} + +/** Fire-and-forget refresh of a named config. Never throws. */ +export function refreshClientConfigInBackground<S extends z.ZodType>( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): void { + void getClientConfig(name, schema, options).catch(() => undefined); +} + +/** + * Synchronous peek at the fresh in-process cache; undefined when missing or + * stale. Only sees the in-process layer — the disk layer is read by the + * async `getClientConfig`, which warms this layer. + */ +export function peekClientConfig<S extends z.ZodType>( + name: string, + schema: S, + now: number = Date.now(), +): z.infer<S> | undefined { + const hit = cache.get(name); + if (hit === undefined || now - hit.fetchedAt >= CONFIG_CACHE_TTL_MS) return undefined; + const parsed = schema.safeParse(hit.data); + return parsed.success ? (parsed.data as z.infer<S>) : undefined; +} + +export async function fetchClientConfig<S extends z.ZodType>( + name: string, + schema: S, + options: ClientConfigFetchOptions = {}, +): Promise<z.infer<S> | undefined> { + const fetchFn = options.fetchImpl ?? fetch; + const headers: Record<string, string> = { + accept: 'application/json', + 'content-type': 'application/json', + }; + if (options.accessToken !== undefined) { + headers['authorization'] = `Bearer ${options.accessToken}`; + } + try { + const response = await fetchFn(`${pythinkerCodeBaseUrl()}${CLIENT_CONFIGS_PATH}`, { + method: 'POST', + headers, + body: JSON.stringify({ name }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return undefined; + const body: unknown = await response.json(); + if (typeof body !== 'object' || body === null) return undefined; + const envelope = body as Record<string, unknown>; + if (envelope['name'] !== name) return undefined; + const parsed = schema.safeParse(envelope['config']); + return parsed.success ? (parsed.data as z.infer<S>) : undefined; + } catch { + return undefined; + } +} + +/** + * Test hook: drop one or all in-process cached configs. Disk files in tests + * are isolated via the `cacheFile` option. + */ +export function resetClientConfigCache(name?: string): void { + if (name === undefined) { + cache.clear(); + } else { + cache.delete(name); + } +} diff --git a/apps/pythinker-code/src/utils/clipboard/clipboard-common.ts b/apps/pythinker-code/src/utils/clipboard/clipboard-common.ts new file mode 100644 index 00000000..dc0f6088 --- /dev/null +++ b/apps/pythinker-code/src/utils/clipboard/clipboard-common.ts @@ -0,0 +1,158 @@ +import { readFileSync } from 'node:fs'; +import { spawn, spawnSync } from 'node:child_process'; + +import type { ClipboardModule } from './clipboard-native'; + +export type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; +export type RunCommand = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => { stdout: Buffer; ok: boolean }; +export type RunCommandAsync = ( + command: string, + args: string[], + options?: RunCommandOptions, +) => Promise<{ stdout: Buffer; ok: boolean }>; + +export const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; + +export const DEFAULT_LIST_TIMEOUT_MS = 1000; +export const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; + +export function baseMimeType(raw: string): string { + return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); +} + +export function isSupportedImageMimeType(mime: string): boolean { + const base = baseMimeType(mime); + return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); +} + +export function parseTargetList(output: Buffer): string[] { + return output + .toString('utf-8') + .split(/\r?\n/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); +} + +export function runCommand( + command: string, + args: string[], + options?: RunCommandOptions, +): { stdout: Buffer; ok: boolean } { + const result = spawnSync(command, args, { + timeout: options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS, + maxBuffer: DEFAULT_MAX_BUFFER_BYTES, + env: options?.env, + }); + if (result.error !== undefined || result.status !== 0) { + return { ok: false, stdout: Buffer.alloc(0) }; + } + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); + return { ok: true, stdout }; +} + +/** + * Non-blocking counterpart of `runCommand`. Used by the clipboard image probe + * on the startup path so a slow or wedged helper (notably `powershell.exe` on + * WSL, or a stuck `wl-paste`/`xclip`) cannot freeze the event loop. The child + * is killed and the promise resolves with `ok: false` once `timeoutMs` elapses + * or the captured stdout exceeds `DEFAULT_MAX_BUFFER_BYTES`. + */ +export function runCommandAsync( + command: string, + args: string[], + options?: RunCommandOptions, +): Promise<{ stdout: Buffer; ok: boolean }> { + const timeoutMs = options?.timeoutMs ?? DEFAULT_LIST_TIMEOUT_MS; + return new Promise((resolve) => { + let child; + try { + child = spawn(command, args, { + env: options?.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + + const chunks: Buffer[] = []; + let totalBytes = 0; + let settled = false; + let timer: ReturnType<typeof setTimeout>; + + // Marks the promise as settled and clears the timeout. Returns true only for + // the first caller, so each event handler below resolves at most once. + const claim = (): boolean => { + if (settled) return false; + settled = true; + clearTimeout(timer); + return true; + }; + + timer = setTimeout(() => { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }, timeoutMs); + + child.stdout?.on('data', (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > DEFAULT_MAX_BUFFER_BYTES) { + child.kill(); + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + chunks.push(chunk); + }); + + child.on('error', () => { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + }); + + child.on('close', (code) => { + if (code !== 0) { + if (claim()) resolve({ ok: false, stdout: Buffer.alloc(0) }); + return; + } + if (claim()) resolve({ ok: true, stdout: Buffer.concat(chunks) }); + }); + }); +} + +export function isWaylandSession(env: NodeJS.ProcessEnv): boolean { + return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; +} + +export function isWSL(env: NodeJS.ProcessEnv): boolean { + if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; + try { + return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); + } catch { + return false; + } +} + +export function isFileLikeNativeFormat(format: string): boolean { + const f = format.toLowerCase(); + const base = baseMimeType(format); + return ( + f.includes('file-url') || + f.includes('file url') || + f.includes('nsfilenames') || + f.includes('com.apple.finder') || + base === 'text/uri-list' || + base === 'public.url' + ); +} + +export function safeAvailableFormats(clip: ClipboardModule | null): string[] { + if (clip?.availableFormats === undefined) return []; + try { + return clip.availableFormats(); + } catch { + return []; + } +} diff --git a/apps/pythinker-code/src/utils/clipboard/clipboard-has-image.ts b/apps/pythinker-code/src/utils/clipboard/clipboard-has-image.ts new file mode 100644 index 00000000..8c344d02 --- /dev/null +++ b/apps/pythinker-code/src/utils/clipboard/clipboard-has-image.ts @@ -0,0 +1,44 @@ +import { isFileLikeNativeFormat, safeAvailableFormats } from './clipboard-common'; +import { clipboard, type ClipboardModule } from './clipboard-native'; + +async function hasImageViaNative(clip: ClipboardModule | null): Promise<boolean> { + if (clip === null) return false; + + // Finder exposes file icons/thumbnails as image data when a non-image file + // is copied. Treat file-like clipboard contents as "not a pasteable image" + // to match the read path in clipboard-image.ts. + const formats = safeAvailableFormats(clip); + if (formats.some(isFileLikeNativeFormat)) return false; + + try { + return clip.hasImage(); + } catch { + return false; + } +} + +export async function clipboardHasImage(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + clipboard?: ClipboardModule | null; +}): Promise<boolean> { + const env = options?.env ?? process.env; + const platform = options?.platform ?? process.platform; + const clip = options?.clipboard ?? clipboard; + + if (env['TERMUX_VERSION'] !== undefined) return false; + + // The focus-driven clipboard-image hint does not probe on Linux. The probe + // would spawn wl-paste / xclip, which on Wayland perturbs seat focus and + // re-triggers the terminal's focus event, creating a focus feedback loop + // (window repeatedly gains/loses focus, IME candidate window cannot stay + // focused — see issue #1090). macOS and Windows are fine: both use the + // in-process native module, which neither spawns a subprocess nor perturbs + // focus. + // + // Image *paste* is unaffected on all platforms: it reads the clipboard + // through readClipboardMedia() on the explicit paste path, not here. + if (platform !== 'darwin' && platform !== 'win32') return false; + + return hasImageViaNative(clip); +} diff --git a/apps/pythinker-code/src/utils/clipboard/clipboard-image.ts b/apps/pythinker-code/src/utils/clipboard/clipboard-image.ts index c2bc1c7f..9252ed22 100644 --- a/apps/pythinker-code/src/utils/clipboard/clipboard-image.ts +++ b/apps/pythinker-code/src/utils/clipboard/clipboard-image.ts @@ -16,7 +16,6 @@ * supported, or every fallback fails. */ -import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { readFileSync, statSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -25,6 +24,20 @@ import { fileURLToPath } from 'node:url'; import { parseImageMeta } from '#/utils/image/image-mime'; +import { + DEFAULT_LIST_TIMEOUT_MS, + SUPPORTED_IMAGE_MIME_TYPES, + baseMimeType, + isFileLikeNativeFormat, + isSupportedImageMimeType, + isWaylandSession, + isWSL, + parseTargetList, + runCommand as runCommandBase, + safeAvailableFormats, + type RunCommand, + type RunCommandOptions, +} from './clipboard-common'; import { clipboard, type ClipboardModule } from './clipboard-native'; export interface ClipboardImage { @@ -49,14 +62,6 @@ export class ClipboardMediaError extends Error { } } -type RunCommandOptions = { timeoutMs?: number; env?: NodeJS.ProcessEnv }; -type RunCommand = ( - command: string, - args: string[], - options?: RunCommandOptions, -) => { stdout: Buffer; ok: boolean }; - -const SUPPORTED_IMAGE_MIME_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const; const MAX_VIDEO_BYTES = 100 * 1024 * 1024; const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ @@ -75,10 +80,8 @@ const VIDEO_MIME_BY_SUFFIX: Readonly<Record<string, string>> = Object.freeze({ '.3g2': 'video/3gpp2', }); -const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000; -const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; const MACOS_FILE_PATH_SCRIPT = String.raw` ObjC.import('AppKit'); @@ -115,28 +118,6 @@ if (String(pb) !== '[id nil]') { out.join('\n'); `.trim(); -function isWaylandSession(env: NodeJS.ProcessEnv): boolean { - return Boolean(env['WAYLAND_DISPLAY']) || env['XDG_SESSION_TYPE'] === 'wayland'; -} - -function isWSL(env: NodeJS.ProcessEnv): boolean { - if (env['WSL_DISTRO_NAME'] !== undefined || env['WSLENV'] !== undefined) return true; - try { - return /microsoft|wsl/i.test(readFileSync('/proc/version', 'utf-8')); - } catch { - return false; - } -} - -function baseMimeType(raw: string): string { - return raw.split(';')[0]?.trim().toLowerCase() ?? raw.toLowerCase(); -} - -function isSupportedImageMimeType(mime: string): boolean { - const base = baseMimeType(mime); - return (SUPPORTED_IMAGE_MIME_TYPES as readonly string[]).includes(base); -} - function selectPreferredImageMimeType(candidates: string[]): string | null { const normalized = candidates .map((t) => t.trim()) @@ -253,29 +234,11 @@ function readMediaFromText(text: string): ClipboardMedia | null { return readMediaFromPaths(parseClipboardPaths(text)); } -function runCommand( - command: string, - args: string[], - options?: RunCommandOptions, -): { stdout: Buffer; ok: boolean } { - const result = spawnSync(command, args, { - timeout: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, - maxBuffer: DEFAULT_MAX_BUFFER_BYTES, +function runCommand(command: string, args: string[], options?: RunCommandOptions): { stdout: Buffer; ok: boolean } { + return runCommandBase(command, args, { + timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS, env: options?.env, }); - if (result.error !== undefined || result.status !== 0) { - return { ok: false, stdout: Buffer.alloc(0) }; - } - const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? ''); - return { ok: true, stdout }; -} - -function parseTargetList(output: Buffer): string[] { - return output - .toString('utf-8') - .split(/\r?\n/) - .map((t) => t.trim()) - .filter((t) => t.length > 0); } function readClipboardFileMediaViaWlPaste(): ClipboardMedia | null { @@ -394,28 +357,6 @@ function readClipboardFilePathsViaMacOs(run: RunCommand): string[] { return parseClipboardPaths(result.stdout.toString('utf-8')); } -function isFileLikeNativeFormat(format: string): boolean { - const f = format.toLowerCase(); - const base = baseMimeType(format); - return ( - f.includes('file-url') || - f.includes('file url') || - f.includes('nsfilenames') || - f.includes('com.apple.finder') || - base === 'text/uri-list' || - base === 'public.url' - ); -} - -function safeAvailableFormats(clip: ClipboardModule | null): string[] { - if (clip?.availableFormats === undefined) return []; - try { - return clip.availableFormats(); - } catch { - return []; - } -} - async function readClipboardFileMediaViaNativeText( clip: ClipboardModule | null, ): Promise<{ media: ClipboardMedia | null; lookedFileLike: boolean }> { diff --git a/apps/pythinker-code/src/utils/clipboard/clipboard-osc52.ts b/apps/pythinker-code/src/utils/clipboard/clipboard-osc52.ts new file mode 100644 index 00000000..f6997684 --- /dev/null +++ b/apps/pythinker-code/src/utils/clipboard/clipboard-osc52.ts @@ -0,0 +1,40 @@ +const ESC = '\u001B'; +const BEL = '\u0007'; +const ST = '\\'; + +function isInsideTmux(): boolean { + return (process.env['TMUX'] ?? '').length > 0; +} + +/** + * Build an OSC 52 sequence that asks the terminal emulator to put `text` on + * the system clipboard. The sequence reaches the *local* clipboard through + * stdout alone, so it keeps working over SSH and inside containers where no + * native clipboard tool exists. Terminals without OSC 52 support silently + * ignore it. + * + * tmux swallows bare OSC sequences, so inside tmux the sequence is wrapped in + * a DCS passthrough with doubled ESC bytes (same convention as + * `buildTerminalNotificationSequences`). + */ +export function buildClipboardOSC52(text: string, insideTmux = isInsideTmux()): string { + const payload = Buffer.from(text, 'utf8').toString('base64'); + const sequence = `${ESC}]52;c;${payload}${BEL}`; + if (!insideTmux) return sequence; + const escaped = sequence.replaceAll(ESC, `${ESC}${ESC}`); + return `${ESC}Ptmux;${escaped}${ESC}${ST}`; +} + +/** + * Write the OSC 52 sequence to stdout. Returns false when stdout is not a + * terminal (the sequence would pollute piped output) or the write failed. + */ +export function writeClipboardOSC52(text: string): boolean { + if (!process.stdout.isTTY) return false; + try { + process.stdout.write(buildClipboardOSC52(text)); + return true; + } catch { + return false; + } +} diff --git a/apps/pythinker-code/src/utils/clipboard/clipboard-text.ts b/apps/pythinker-code/src/utils/clipboard/clipboard-text.ts index 8a8295f5..8738c9ed 100644 --- a/apps/pythinker-code/src/utils/clipboard/clipboard-text.ts +++ b/apps/pythinker-code/src/utils/clipboard/clipboard-text.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { clipboard } from './clipboard-native'; +import { writeClipboardOSC52 } from './clipboard-osc52'; function runClipboardCommand(command: string, args: readonly string[], input: string): void { const result = spawnSync(command, args, { encoding: 'utf8', input }); @@ -40,16 +41,34 @@ async function copyWithPlatformCommand(text: string): Promise<void> { throw new Error('No clipboard command is available.'); } -export async function copyTextToClipboard(text: string): Promise<void> { +/** How the text was delivered: a verified local clipboard tool, or an + * unverified OSC 52 escape emitted to the terminal as a last resort. */ +export type ClipboardCopyMethod = 'native' | 'osc52'; + +export async function copyTextToClipboard(text: string): Promise<ClipboardCopyMethod> { + // OSC 52 travels over stdout to the local terminal emulator, so it reaches + // the clipboard even over SSH or in containers with no native clipboard + // tool. Emit it up front; every failure path below can fall back on it. + const osc52Emitted = writeClipboardOSC52(text); + const clipboardModule = clipboard; if (clipboardModule?.setText !== undefined) { try { await clipboardModule.setText(text); - return; + return 'native'; } catch { // Fall back to platform clipboard commands below. } } - await copyWithPlatformCommand(text); + try { + await copyWithPlatformCommand(text); + return 'native'; + } catch (error) { + // The native clipboard is unreachable (headless server, SSH session, + // missing wl-copy/xclip …) but the terminal may still have delivered the + // text via OSC 52; without a terminal there is nothing left to try. + if (osc52Emitted) return 'osc52'; + throw error; + } } diff --git a/apps/pythinker-code/src/utils/git/git-status.ts b/apps/pythinker-code/src/utils/git/git-status.ts index c77256f0..56b7f0af 100644 --- a/apps/pythinker-code/src/utils/git/git-status.ts +++ b/apps/pythinker-code/src/utils/git/git-status.ts @@ -9,6 +9,8 @@ import { execFile, spawnSync } from 'node:child_process'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + const BRANCH_TTL_MS = 5_000; const STATUS_TTL_MS = 15_000; const PULL_REQUEST_TTL_MS = 60_000; @@ -67,7 +69,11 @@ export function createGitStatusCache( workDir: string, options: GitStatusCacheOptions = {}, ): GitStatusCache { - const isRepo = detectGitRepo(workDir); + // This cache is constructed before the workspace trust gate, so the git + // binary must be resolved through PATH to an absolute path — a bare name + // would let cmd.exe pick up a `git.exe` planted in the workspace. + const git = resolveCommandPath('git', workDir); + const isRepo = git !== undefined && detectGitRepo(git, workDir); let branch: BranchState = { value: null, fetchedAt: 0 }; let status: StatusState = { dirty: false, @@ -87,16 +93,16 @@ export function createGitStatusCache( return { getStatus: () => { - if (!isRepo) return null; + if (!isRepo || git === undefined) return null; const now = Date.now(); if (now - branch.fetchedAt >= BRANCH_TTL_MS) { - branch = { value: readBranch(workDir), fetchedAt: now }; + branch = { value: readBranch(git, workDir), fetchedAt: now }; } if (branch.value === null) return null; if (now - status.fetchedAt >= STATUS_TTL_MS) { - status = { ...readStatus(workDir), fetchedAt: now }; + status = { ...readStatus(git, workDir), fetchedAt: now }; } refreshPullRequestIfNeeded(branch.value, now); @@ -143,9 +149,9 @@ export function createGitStatusCache( } } -function detectGitRepo(workDir: string): boolean { +function detectGitRepo(git: string, workDir: string): boolean { try { - const result = spawnSync('git', ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { + const result = spawnSync(git, ['-C', workDir, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, }); @@ -155,9 +161,9 @@ function detectGitRepo(workDir: string): boolean { } } -function readBranch(workDir: string): string | null { +function readBranch(git: string, workDir: string): string | null { try { - const result = spawnSync('git', ['-C', workDir, 'branch', '--show-current'], { + const result = spawnSync(git, ['-C', workDir, 'branch', '--show-current'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, }); @@ -169,7 +175,10 @@ function readBranch(workDir: string): string | null { } } -function readStatus(workDir: string): { +function readStatus( + git: string, + workDir: string, +): { dirty: boolean; ahead: number; behind: number; @@ -177,7 +186,7 @@ function readStatus(workDir: string): { diffDeleted: number; } { try { - const result = spawnSync('git', ['-C', workDir, 'status', '--porcelain', '-b'], { + const result = spawnSync(git, ['-C', workDir, 'status', '--porcelain', '-b'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, @@ -200,7 +209,7 @@ function readStatus(workDir: string): { dirty = true; } } - const diff = dirty ? readDiffStats(workDir) : { added: 0, deleted: 0 }; + const diff = dirty ? readDiffStats(git, workDir) : { added: 0, deleted: 0 }; return { dirty, ahead, @@ -213,9 +222,9 @@ function readStatus(workDir: string): { } } -function readDiffStats(workDir: string): { added: number; deleted: number } { +function readDiffStats(git: string, workDir: string): { added: number; deleted: number } { try { - const result = spawnSync('git', ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { + const result = spawnSync(git, ['-C', workDir, 'diff', '--numstat', 'HEAD', '--'], { encoding: 'utf8', timeout: SPAWN_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, @@ -244,9 +253,16 @@ function parseDiffNumstatCount(value: string | undefined): number { function readPullRequest(workDir: string): Promise<PullRequestInfo | null> { return new Promise((resolve) => { + // Resolve gh through PATH as well — this runs with cwd = workDir, where a + // planted `gh.exe` would otherwise be picked up by cmd.exe on Windows. + const gh = resolveCommandPath('gh', workDir); + if (gh === undefined) { + resolve(null); + return; + } try { execFile( - 'gh', + gh, ['pr', 'view', '--json', 'number,url'], { cwd: workDir, diff --git a/apps/pythinker-code/src/utils/heap-dump.ts b/apps/pythinker-code/src/utils/heap-dump.ts deleted file mode 100644 index f9d6ac8b..00000000 --- a/apps/pythinker-code/src/utils/heap-dump.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createWriteStream } from 'node:fs'; -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { pipeline } from 'node:stream/promises'; -import { getHeapSnapshot, getHeapSpaceStatistics, getHeapStatistics } from 'node:v8'; - -export type HeapDumpResult = - | { readonly success: true; readonly heapPath: string; readonly diagPath: string } - | { readonly success: false; readonly error: string }; - -export async function performHeapDump( - sessionId: string, - version: string, - outputDirectory = join(homedir(), 'Desktop'), -): Promise<HeapDumpResult> { - try { - const diagnostics = await captureMemoryDiagnostics(sessionId, version); - const filename = safeFilename(sessionId); - const heapPath = join(outputDirectory, `${filename}.heapsnapshot`); - const diagPath = join(outputDirectory, `${filename}-diagnostics.json`); - - await mkdir(outputDirectory, { recursive: true, mode: 0o700 }); - await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 }); - await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 })); - - return { success: true, heapPath, diagPath }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -async function captureMemoryDiagnostics(sessionId: string, version: string): Promise<object> { - const memory = process.memoryUsage(); - const heap = getHeapStatistics(); - const resource = process.resourceUsage(); - const uptimeSeconds = process.uptime(); - const nativeMemory = memory.rss - memory.heapUsed; - const mbPerHour = uptimeSeconds > 0 - ? (memory.rss / uptimeSeconds * 3600) / (1024 * 1024) - : 0; - const activeHandles = processInternalCount('_getActiveHandles'); - const activeRequests = processInternalCount('_getActiveRequests'); - const openFileDescriptors = await optionalDirectoryCount('/proc/self/fd'); - const potentialLeaks: string[] = []; - - if (heap.number_of_detached_contexts > 0) { - potentialLeaks.push( - `${String(heap.number_of_detached_contexts)} detached context(s) - possible context leak`, - ); - } - if (activeHandles !== undefined && activeHandles > 100) { - potentialLeaks.push(`${String(activeHandles)} active handles - possible timer or socket leak`); - } - if (nativeMemory > memory.heapUsed) { - potentialLeaks.push('Native memory exceeds heap memory'); - } - if (mbPerHour > 100) { - potentialLeaks.push(`High average memory growth: ${mbPerHour.toFixed(1)} MB/hour`); - } - if (openFileDescriptors !== undefined && openFileDescriptors > 500) { - potentialLeaks.push(`${String(openFileDescriptors)} open file descriptors`); - } - - return { - timestamp: new Date().toISOString(), - sessionId, - version, - trigger: 'manual', - uptimeSeconds, - memoryUsage: memory, - memoryGrowthRate: { - bytesPerSecond: uptimeSeconds > 0 ? memory.rss / uptimeSeconds : 0, - mbPerHour, - }, - v8HeapStats: { - heapSizeLimit: heap.heap_size_limit, - mallocedMemory: heap.malloced_memory, - peakMallocedMemory: heap.peak_malloced_memory, - detachedContexts: heap.number_of_detached_contexts, - nativeContexts: heap.number_of_native_contexts, - }, - v8HeapSpaces: safeHeapSpaceStatistics(), - resourceUsage: { - maxRSS: resource.maxRSS * 1024, - userCPUTime: resource.userCPUTime, - systemCPUTime: resource.systemCPUTime, - }, - activeHandles, - activeRequests, - openFileDescriptors, - analysis: { - potentialLeaks, - recommendation: potentialLeaks.length === 0 - ? 'No obvious leak indicators. Inspect the heap snapshot for retained objects.' - : `${String(potentialLeaks.length)} potential leak indicator(s) found.`, - }, - smapsRollup: await optionalFile('/proc/self/smaps_rollup'), - platform: process.platform, - nodeVersion: process.version, - }; -} - -function safeHeapSpaceStatistics(): readonly object[] | undefined { - try { - return getHeapSpaceStatistics().map((space) => ({ - name: space.space_name, - size: space.space_size, - used: space.space_used_size, - available: space.space_available_size, - })); - } catch { - return undefined; - } -} - -function processInternalCount( - name: '_getActiveHandles' | '_getActiveRequests', -): number | undefined { - const method = ( - process as typeof process & Partial<Record<typeof name, () => readonly unknown[]>> - )[name]; - return typeof method === 'function' ? method.call(process).length : undefined; -} - -async function optionalDirectoryCount(path: string): Promise<number | undefined> { - try { - return (await readdir(path)).length; - } catch { - return undefined; - } -} - -async function optionalFile(path: string): Promise<string | undefined> { - try { - return await readFile(path, 'utf8'); - } catch { - return undefined; - } -} - -function safeFilename(sessionId: string): string { - const filename = sessionId.replaceAll(/[^a-zA-Z0-9._-]+/gu, '-'); - return filename.length > 0 ? filename : 'pythinker-code'; -} diff --git a/apps/pythinker-code/src/utils/history/input-history.ts b/apps/pythinker-code/src/utils/history/input-history.ts index ed3af578..cade00be 100644 --- a/apps/pythinker-code/src/utils/history/input-history.ts +++ b/apps/pythinker-code/src/utils/history/input-history.ts @@ -3,6 +3,10 @@ * * Semantics: * - One JSON object per line (`InputHistoryEntry { content }`) + * - `content` is the raw input. Shell commands are stored with a leading `!` + * (e.g. `!ls -la`) so ↑ recall can distinguish them from prompts and restore + * bash mode; the `!` is stripped again when the entry is recalled. Plain + * prompts (and legacy entries without a leading `!`) are normal prompts. * - Append-only writes * - Skip empty entries * - Skip when same as last entry (consecutive deduplication) @@ -21,20 +25,6 @@ const InputHistoryEntrySchema: z.ZodType<InputHistoryEntry> = z.object({ content: z.string(), }); -export function selectRecentInputHistory( - entries: readonly InputHistoryEntry[], -): readonly string[] { - const selected: string[] = []; - const seen = new Set<string>(); - for (let index = entries.length - 1; index >= 0 && selected.length < 100; index--) { - const content = entries[index]?.content.trim(); - if (content === undefined || content.length === 0 || seen.has(content)) continue; - seen.add(content); - selected.push(content); - } - return selected; -} - export async function loadInputHistory(file: string): Promise<InputHistoryEntry[]> { return readJsonlFile(file, InputHistoryEntrySchema); } diff --git a/apps/pythinker-code/src/utils/open-url.ts b/apps/pythinker-code/src/utils/open-url.ts index 10b1a887..4112d9c6 100644 --- a/apps/pythinker-code/src/utils/open-url.ts +++ b/apps/pythinker-code/src/utils/open-url.ts @@ -1,30 +1,11 @@ import { execFile } from 'node:child_process'; -export interface OpenUrlCommand { - readonly command: string; - readonly args: readonly string[]; -} - -/** - * Windows uses `rundll32` rather than `cmd /c start` because `cmd` re-parses - * its arguments and cuts a URL at the first `&`, which strips every OAuth - * query parameter after `client_id`. - */ -export function openUrlCommandFor( - url: string, - platform: NodeJS.Platform = process.platform, -): OpenUrlCommand { - switch (platform) { - case 'darwin': - return { command: 'open', args: [url] }; - case 'win32': - return { command: 'rundll32', args: ['url.dll,FileProtocolHandler', url] }; - default: - return { command: 'xdg-open', args: [url] }; - } -} - export function openUrl(url: string): void { - const { command, args } = openUrlCommandFor(url); - execFile(command, [...args], () => {}); + const command: [string, string[]] = + process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + execFile(command[0], command[1], () => {}); } diff --git a/apps/pythinker-code/src/utils/paths.ts b/apps/pythinker-code/src/utils/paths.ts index 3b46b74d..6d8e58c1 100644 --- a/apps/pythinker-code/src/utils/paths.ts +++ b/apps/pythinker-code/src/utils/paths.ts @@ -18,8 +18,8 @@ import { PYTHINKER_CODE_HOME_ENV, PYTHINKER_CODE_INPUT_HISTORY_DIR_NAME, PYTHINKER_CODE_LOG_DIR_NAME, + PYTHINKER_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, - PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME, PYTHINKER_CODE_UPDATE_INSTALL_STATE_FILE_NAME, PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME, @@ -82,17 +82,21 @@ export function getUpdateInstallLockFile(): string { } /** - * Return the update installer log: `<dataDir>/updates/install.log`. + * Return the rollout decision log: `<dataDir>/updates/rollout.log`. */ -export function getUpdateInstallLogFile(): string { - return join(getDataDir(), PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_INSTALL_LOG_FILE_NAME); +export function getUpdateRolloutLogFile(): string { + return join(getDataDir(), PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); } /** - * Return the rollout decision log: `<dataDir>/updates/rollout.log`. + * Return the plugin update notice state file: `<dataDir>/updates/plugin-notices.json`. */ -export function getUpdateRolloutLogFile(): string { - return join(getDataDir(), PYTHINKER_CODE_UPDATE_DIR_NAME, PYTHINKER_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); +export function getPluginUpdateNoticeStateFile(): string { + return join( + getDataDir(), + PYTHINKER_CODE_UPDATE_DIR_NAME, + PYTHINKER_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + ); } /** diff --git a/apps/pythinker-code/src/utils/persistence.ts b/apps/pythinker-code/src/utils/persistence.ts index 76d801c0..a458ae02 100644 --- a/apps/pythinker-code/src/utils/persistence.ts +++ b/apps/pythinker-code/src/utils/persistence.ts @@ -6,7 +6,7 @@ * these helpers. */ -import { appendFile, mkdir, open, readFile, rename, unlink } from 'node:fs/promises'; +import { appendFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { basename, dirname, join } from 'node:path'; import type { z } from 'zod'; @@ -52,41 +52,14 @@ export async function writeJsonFile<T>( filePath: string, schema: z.ZodType<T>, value: T, - options?: { - /** - * Also fsync the file and its parent directory so the write survives a - * crash. Costs two blocking disk flushes — reserve it for state whose - * loss corrupts a workflow (e.g. install.json), not routine caches. - */ - readonly durable?: boolean; - }, ): Promise<void> { assertNonConfigWrite(filePath); const parsed = schema.parse(value); await mkdir(dirname(filePath), { recursive: true }); const tmpPath = tempPathFor(filePath); try { - const file = await open(tmpPath, 'wx', 0o600); - try { - await file.writeFile(`${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); - if (options?.durable === true) await file.sync(); - } finally { - await file.close(); - } + await writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf-8'); await rename(tmpPath, filePath); - if (options?.durable === true) { - // A synced file plus rename is not crash-durable until the directory - // entry is flushed. Some platforms do not allow opening directories, so - // retain the atomic write even when that final step is unavailable. - const directory = await open(dirname(filePath), 'r').catch(() => null); - if (directory !== null) { - try { - await directory.sync().catch(() => {}); - } finally { - await directory.close(); - } - } - } } catch (error) { await unlink(tmpPath).catch(() => {}); throw error; diff --git a/apps/pythinker-code/src/utils/plugin-marketplace.ts b/apps/pythinker-code/src/utils/plugin-marketplace.ts index 341930e3..c53126d9 100644 --- a/apps/pythinker-code/src/utils/plugin-marketplace.ts +++ b/apps/pythinker-code/src/utils/plugin-marketplace.ts @@ -1,965 +1,87 @@ -import { readFile, stat } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from 'node:path'; -import { fileURLToPath } from 'node:url'; +/** + * `#/utils/plugin-marketplace` — CLI-side wrapper over the shared plugin + * marketplace client/parser (`@pymodel/agent-core-v2`, + * `app/plugin/marketplace`). The shared module owns catalog reading, the + * lenient entry normalization, source resolution, and version derivation; + * this wrapper adds only the CLI's configured-source resolution (option → + * env → production default), the source-checkout fallback for offline dev, + * and the caller-supplied built-in capability entry injection. + */ + +import { stat } from 'node:fs/promises'; +import { resolve } from 'node:path'; -import type { PluginInstallOptions, PluginSummary } from '@pymodel/pythinker-code-sdk'; -import { gt, valid } from 'semver'; +import { + parsePluginMarketplace, + readPluginMarketplace, + withBuiltInEntries, + withLatestVersions, + type MarketplaceLocation, + type PluginMarketplace, + type PluginMarketplaceEntry, +} from '@pymodel/agent-core-v2/app/plugin/marketplace'; import { - ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS, - ANTHROPIC_PLUGIN_MARKETPLACE_REPOSITORY, - CLAUDE_PLUGIN_MARKETPLACE_PATH, - PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS, PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '#/constant/app'; -export const PLUGIN_MARKETPLACE_TIERS = ['official', 'curated'] as const; -const DEFAULT_PLUGIN_MARKETPLACE_FETCH_TIMEOUT_MS = 15_000; - -export type PluginMarketplaceTier = (typeof PLUGIN_MARKETPLACE_TIERS)[number]; -export type PluginMarketplaceFormat = 'pythinker' | 'claude'; -export type PluginMarketplaceSupportedComponent = - | 'skills' - | 'agents' - | 'mcpServers' - | 'lspServers' - | 'outputStyles'; - -export interface PluginMarketplaceAuthor { - readonly name: string; - readonly email?: string; - readonly url?: string; -} - -export type PluginMarketplaceInstall = - | { - readonly kind: 'supported'; - readonly source: string; - readonly options: PluginInstallOptions; - } - | { - readonly kind: 'unsupported'; - readonly reason: string; - }; - -export interface PluginMarketplaceEntry { - readonly id: string; - readonly displayName: string; - /** Canonical install source when supported; otherwise the human-readable source label. */ - readonly source: string; - readonly sourceLabel: string; - readonly marketplaceName: string; - readonly marketplaceOwner?: string; - readonly tier?: PluginMarketplaceTier; - readonly version?: string; - readonly description?: string; - readonly author?: PluginMarketplaceAuthor; - readonly homepage?: string; - readonly repository?: string; - readonly license?: string; - readonly category?: string; - readonly keywords?: readonly string[]; - readonly tags?: readonly string[]; - readonly strict?: boolean; - readonly defaultEnabled?: boolean; - readonly supportedComponents: readonly PluginMarketplaceSupportedComponent[]; - readonly unsupportedComponents: readonly string[]; - readonly declaredRef?: string; - readonly effectiveSha?: string; - readonly github?: { - readonly owner: string; - readonly repo: string; - }; - readonly repositorySubdirectory?: string; - readonly install: PluginMarketplaceInstall; -} - -export interface PluginMarketplace { - readonly format: PluginMarketplaceFormat; - readonly source: string; - readonly sourceLabel: string; - readonly name: string; - readonly owner?: PluginMarketplaceAuthor; - readonly description?: string; - readonly version?: string; - readonly plugins: readonly PluginMarketplaceEntry[]; -} - -export type PluginUpdateStatus = - | { readonly kind: 'not-installed' } - | { readonly kind: 'up-to-date'; readonly version?: string } - | { readonly kind: 'update'; readonly local: string; readonly latest: string }; - -/** Compare marketplace and installed semver without inventing updates for opaque versions. */ -export function computeUpdateStatus( - latest: string | undefined, - local: string | undefined, - installed: boolean, -): PluginUpdateStatus { - if (!installed) return { kind: 'not-installed' }; - if ( - latest !== undefined && - local !== undefined && - valid(latest) !== null && - valid(local) !== null && - gt(latest, local) - ) { - return { kind: 'update', local, latest }; - } - return { kind: 'up-to-date', version: local }; -} - -export function computeMarketplaceEntryStatus( - entry: PluginMarketplaceEntry, - installed: PluginSummary | undefined, -): PluginUpdateStatus { - if (installed === undefined) return { kind: 'not-installed' }; - - const latestSha = entry.effectiveSha?.toLowerCase(); - const installedSha = ( - installed.github?.installedSha ?? - (installed.github?.ref.kind === 'sha' ? installed.github.ref.value : undefined) - )?.toLowerCase(); - const sameRepository = - entry.github !== undefined && - installed.github !== undefined && - entry.github.owner.toLowerCase() === installed.github.owner.toLowerCase() && - entry.github.repo.toLowerCase() === installed.github.repo.toLowerCase(); - - if (latestSha !== undefined && installedSha !== undefined && sameRepository) { - return latestSha === installedSha - ? { kind: 'up-to-date', version: installed.version } - : { kind: 'update', local: installedSha, latest: latestSha }; - } - return computeUpdateStatus(entry.version, installed.version, true); -} - -interface GithubRepositoryContext { - readonly owner: string; - readonly repo: string; - readonly ref: string; -} - -export interface MarketplaceLocation { - readonly kind: 'remote' | 'local'; - readonly resolved: string; - readonly sourceLabel: string; - readonly marketplaceRoot?: string; - readonly github?: GithubRepositoryContext; -} +export { + computeUpdateStatus, + PLUGIN_MARKETPLACE_TIERS, + type PluginMarketplace, + type PluginMarketplaceEntry, + type PluginMarketplaceTier, + type MarketplaceUpdateStatus, +} from '@pymodel/agent-core-v2/app/plugin/marketplace'; export interface LoadPluginMarketplaceOptions { readonly workDir: string; readonly source?: string; readonly fetchImpl?: typeof fetch; - readonly fetchTimeoutMs?: number; + /** + * Built-in capability rows to inject, supplied by the caller from the + * engine's capability registry (this util owns no product knowledge). + * Undefined means no injection. + */ + readonly builtInEntries?: readonly PluginMarketplaceEntry[]; } export async function loadPluginMarketplace( options: LoadPluginMarketplaceOptions, ): Promise<PluginMarketplace> { - const location = await resolveMarketplaceLocation(options.source, options.workDir); - const raw = await readMarketplaceText( - location, - options.fetchImpl ?? fetch, - options.fetchTimeoutMs ?? DEFAULT_PLUGIN_MARKETPLACE_FETCH_TIMEOUT_MS, - ); - return parsePluginMarketplace(raw, location); -} - -export function parsePluginMarketplace( - raw: string, - location: MarketplaceLocation, -): PluginMarketplace { - let parsed: unknown; + const configuredSource = options.source ?? process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const source = configuredSource ?? PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL; + const fetchImpl = options.fetchImpl ?? fetch; + let read: { raw: string; location: MarketplaceLocation }; try { - parsed = JSON.parse(raw); - } catch (error) { - throw new Error(`Plugin marketplace is not valid JSON: ${formatParseError(error)}`, { - cause: error, + read = await readPluginMarketplace({ + source, + workDir: options.workDir, + fetchImpl, + sourceCheckoutLocation: + configuredSource === undefined ? getSourceCheckoutMarketplaceLocation : undefined, }); - } - - if (!isRecord(parsed)) throw new TypeError('Plugin marketplace must be an object.'); - const rawPlugins = parsed['plugins']; - if (!Array.isArray(rawPlugins)) { - throw new TypeError('Plugin marketplace must contain a "plugins" array.'); - } - - const format = detectMarketplaceFormat(parsed, rawPlugins); - const owner = format === 'claude' - ? requiredAuthor(parsed['owner'], 'Claude plugin marketplace "owner"') - : optionalAuthor(parsed['owner'], 'Plugin marketplace "owner"'); - const name = format === 'claude' - ? requiredCatalogString(parsed, 'name') - : optionalStringField(parsed, 'name', 'Plugin marketplace') ?? 'Pythinker'; - const pluginRoot = format === 'claude' ? parseCatalogPluginRoot(parsed) : undefined; - const context: CatalogContext = { format, name, owner, location, pluginRoot }; - const plugins = rawPlugins.map((entry, index) => - format === 'claude' - ? parseClaudeMarketplaceEntry(entry, index, context) - : parsePythinkerMarketplaceEntry(entry, index, context), - ); - assertUniquePluginIds(plugins); - - return { - format, - source: location.resolved, - sourceLabel: location.sourceLabel, - name, - owner, - description: optionalStringField(parsed, 'description', 'Plugin marketplace'), - version: optionalStringField(parsed, 'version', 'Plugin marketplace'), - plugins, - }; -} - -interface CatalogContext { - readonly format: PluginMarketplaceFormat; - readonly name: string; - readonly owner?: PluginMarketplaceAuthor; - readonly location: MarketplaceLocation; - readonly pluginRoot?: string; -} - -function parseCatalogPluginRoot(catalog: Record<string, unknown>): string | undefined { - const metadata = catalog['metadata']; - if (metadata === undefined) return undefined; - if (!isRecord(metadata)) { - throw new TypeError('Claude plugin marketplace "metadata" must be an object.'); - } - return optionalStringField(metadata, 'pluginRoot', 'Claude plugin marketplace "metadata"'); -} - -async function resolveMarketplaceLocation( - source: string | undefined, - workDir: string, -): Promise<MarketplaceLocation> { - const requested = source?.trim() || PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS; - if (requested === PYTHINKER_CODE_PLUGIN_MARKETPLACE_ALIAS) { - const configured = process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV] - ?? PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL; - if (configured.trim().length === 0) { - throw new Error(`${PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); - } - return resolveExplicitMarketplaceLocation(configured, workDir, 'Pythinker'); - } - if (requested === ANTHROPIC_PLUGIN_MARKETPLACE_ALIAS) { - const repository = parseGithubRepository(ANTHROPIC_PLUGIN_MARKETPLACE_REPOSITORY)!; - return githubMarketplaceLocation(repository, 'Anthropic official'); - } - return resolveExplicitMarketplaceLocation(requested, workDir); -} - -async function resolveExplicitMarketplaceLocation( - source: string, - workDir: string, - sourceLabel = source, -): Promise<MarketplaceLocation> { - const trimmed = source.trim(); - if (trimmed.length === 0) throw new Error('Plugin marketplace source cannot be empty.'); - if (trimmed.startsWith('file://')) { - return localMarketplaceLocation(fileURLToPath(trimmed), sourceLabel); - } - if (isHttpUrl(trimmed)) { - const github = parseGithubRepository(trimmed); - if (github !== undefined) return githubMarketplaceLocation(github, sourceLabel); - return { kind: 'remote', resolved: trimmed, sourceLabel }; - } - - const localPath = resolveLocalPath(trimmed, workDir); - if (await pathExists(localPath)) return localMarketplaceLocation(localPath, sourceLabel); - const github = parseGithubRepository(trimmed); - if (github !== undefined) return githubMarketplaceLocation(github, sourceLabel); - return localMarketplaceLocation(localPath, sourceLabel); -} - -async function localMarketplaceLocation( - inputPath: string, - sourceLabel: string, -): Promise<MarketplaceLocation> { - let catalogPath = inputPath; - let marketplaceRoot: string; - try { - const info = await stat(inputPath); - if (info.isDirectory()) { - marketplaceRoot = inputPath; - catalogPath = join(inputPath, CLAUDE_PLUGIN_MARKETPLACE_PATH); - } else { - marketplaceRoot = marketplaceRootForCatalogFile(inputPath); - } - } catch { - marketplaceRoot = marketplaceRootForCatalogFile(inputPath); - } - return { - kind: 'local', - resolved: catalogPath, - sourceLabel, - marketplaceRoot, - }; -} - -function githubMarketplaceLocation( - github: GithubRepositoryContext, - sourceLabel: string, -): MarketplaceLocation { - return { - kind: 'remote', - resolved: rawGithubUrl(github, CLAUDE_PLUGIN_MARKETPLACE_PATH), - sourceLabel, - github, - }; -} - -function marketplaceRootForCatalogFile(catalogPath: string): string { - const parent = dirname(catalogPath); - return parent.endsWith(`${sep}.claude-plugin`) ? dirname(parent) : parent; -} - -/** - * Read a marketplace document, local or remote. Remote reads race the - * whole request — headers and body — against one deadline, aborting the - * fetch via `AbortSignal` so a stalled marketplace cannot hang the CLI. - */ -async function readMarketplaceText( - location: MarketplaceLocation, - fetchImpl: typeof fetch, - timeoutMs: number, -): Promise<string> { - if (location.kind === 'local') return readFile(location.resolved, 'utf8'); - - const duration = Number.isFinite(timeoutMs) - ? Math.max(1, Math.floor(timeoutMs)) - : DEFAULT_PLUGIN_MARKETPLACE_FETCH_TIMEOUT_MS; - const controller = new AbortController(); - let timer: ReturnType<typeof setTimeout> | undefined; - const deadline = new Promise<never>((_resolve, reject) => { - timer = setTimeout(() => { - const error = new Error(`Plugin marketplace request timed out after ${String(duration)}ms.`); - reject(error); - controller.abort(error); - }, duration); - }); - - try { - const response = await Promise.race([ - fetchImpl(location.resolved, { signal: controller.signal }), - deadline, - ]); - if (!response.ok) throw new Error(`Plugin marketplace returned HTTP ${response.status}`); - return await Promise.race([response.text(), deadline]); - } finally { - if (timer !== undefined) clearTimeout(timer); - } -} - -/** - * Decide the format from the entry shape, not the top-level keys: a - * `name`/`owner` file may still be a Pythinker marketplace. Mixed entry - * formats are rejected outright; with no entries the top-level keys - * (`owner`/`metadata`) decide as a fallback. - */ -function detectMarketplaceFormat( - value: Record<string, unknown>, - plugins: readonly unknown[], -): PluginMarketplaceFormat { - const hasPythinkerEntry = plugins.some( - (plugin) => isRecord(plugin) && plugin['id'] !== undefined, - ); - const hasClaudeEntry = plugins.some( - (plugin) => isRecord(plugin) && plugin['id'] === undefined && plugin['name'] !== undefined, - ); - if (hasPythinkerEntry && hasClaudeEntry) { - throw new Error('Plugin marketplace cannot mix Pythinker and Claude entry formats.'); - } - if (hasPythinkerEntry) return 'pythinker'; - if (hasClaudeEntry) return 'claude'; - return value['owner'] !== undefined || value['metadata'] !== undefined - ? 'claude' - : 'pythinker'; -} - -function parsePythinkerMarketplaceEntry( - value: unknown, - index: number, - context: CatalogContext, -): PluginMarketplaceEntry { - const entry = requiredEntryRecord(value, index); - const id = requiredEntryString(entry, 'id', index); - const sourceValue = optionalStringField(entry, 'source', `Plugin marketplace entry ${id}`) - ?? optionalStringField(entry, 'url', `Plugin marketplace entry ${id}`) - ?? optionalStringField(entry, 'downloadUrl', `Plugin marketplace entry ${id}`); - if (sourceValue === undefined) { - throw new Error(`Plugin marketplace entry ${id} must define "source".`); - } - const source = resolvePythinkerEntrySource(sourceValue, context.location); - const displayName = optionalStringField(entry, 'displayName', `Plugin marketplace entry ${id}`) - ?? optionalStringField(entry, 'name', `Plugin marketplace entry ${id}`) - ?? id; - const version = optionalStringField(entry, 'version', `Plugin marketplace entry ${id}`); - const description = optionalStringField(entry, 'description', `Plugin marketplace entry ${id}`) - ?? optionalStringField(entry, 'shortDescription', `Plugin marketplace entry ${id}`); - const homepage = optionalStringField(entry, 'homepage', `Plugin marketplace entry ${id}`) - ?? optionalStringField(entry, 'websiteURL', `Plugin marketplace entry ${id}`); - const keywords = optionalStringArrayField(entry, 'keywords', `Plugin marketplace entry ${id}`); - const tier = parseMarketplaceTier(entry, id); - const options: PluginInstallOptions = { - definition: { id, displayName, version, description, homepage, keywords }, - }; - - return { - id, - displayName, - source, - sourceLabel: sourceValue, - marketplaceName: context.name, - marketplaceOwner: context.owner?.name, - tier, - version, - description, - author: optionalAuthor(entry['author'], `Plugin marketplace entry ${id} "author"`), - homepage, - repository: optionalStringField(entry, 'repository', `Plugin marketplace entry ${id}`), - license: optionalStringField(entry, 'license', `Plugin marketplace entry ${id}`), - category: optionalStringField(entry, 'category', `Plugin marketplace entry ${id}`), - keywords, - tags: optionalStringArrayField(entry, 'tags', `Plugin marketplace entry ${id}`), - strict: undefined, - defaultEnabled: undefined, - supportedComponents: [], - unsupportedComponents: [], - declaredRef: undefined, - effectiveSha: undefined, - github: undefined, - repositorySubdirectory: undefined, - install: { kind: 'supported', source, options }, - }; -} - -function parseClaudeMarketplaceEntry( - value: unknown, - index: number, - context: CatalogContext, -): PluginMarketplaceEntry { - const entry = requiredEntryRecord(value, index); - const id = requiredEntryString(entry, 'name', index); - const displayName = optionalStringField(entry, 'displayName', `Plugin marketplace entry ${id}`) ?? id; - const version = optionalStringField(entry, 'version', `Plugin marketplace entry ${id}`); - const description = optionalStringField(entry, 'description', `Plugin marketplace entry ${id}`); - const author = optionalAuthor(entry['author'], `Plugin marketplace entry ${id} "author"`); - const homepage = optionalStringField(entry, 'homepage', `Plugin marketplace entry ${id}`); - const explicitRepository = optionalStringField(entry, 'repository', `Plugin marketplace entry ${id}`); - const license = optionalStringField(entry, 'license', `Plugin marketplace entry ${id}`); - const category = optionalStringField(entry, 'category', `Plugin marketplace entry ${id}`); - const keywords = optionalStringArrayField(entry, 'keywords', `Plugin marketplace entry ${id}`); - const tags = optionalStringArrayField(entry, 'tags', `Plugin marketplace entry ${id}`); - const strict = optionalBooleanField(entry, 'strict', `Plugin marketplace entry ${id}`); - const defaultEnabled = optionalBooleanField(entry, 'defaultEnabled', `Plugin marketplace entry ${id}`); - const supportedComponents = supportedComponentNames(entry); - const unsupportedComponents = unsupportedComponentNames(entry); - const resolved = resolveClaudeEntrySource( - entry['source'], - context.pluginRoot, - context.location, - id, - ); - const components = supportedComponentDeclarations(entry); - const repository = explicitRepository ?? ( - resolved.github === undefined - ? undefined - : `https://github.com/${resolved.github.owner}/${resolved.github.repo}` - ); - const options: PluginInstallOptions = { - repositorySubdirectory: resolved.repositorySubdirectory, - definition: { - id, - displayName, - version, - description, - author, - homepage, - repository, - license, - category, - keywords, - tags, - components, - unsupportedComponents, - strict, - defaultEnabled, - }, - }; - const install: PluginMarketplaceInstall = resolved.unsupportedReason === undefined - ? { kind: 'supported', source: resolved.source, options } - : { kind: 'unsupported', reason: resolved.unsupportedReason }; - - return { - id, - displayName, - source: resolved.source, - sourceLabel: resolved.sourceLabel, - marketplaceName: context.name, - marketplaceOwner: context.owner?.name, - tier: undefined, - version, - description, - author, - homepage, - repository, - license, - category, - keywords, - tags, - strict, - defaultEnabled, - supportedComponents, - unsupportedComponents, - declaredRef: resolved.declaredRef, - effectiveSha: resolved.effectiveSha, - github: resolved.github, - repositorySubdirectory: resolved.repositorySubdirectory, - install, - }; -} - -interface ResolvedClaudeSource { - readonly source: string; - readonly sourceLabel: string; - readonly repositorySubdirectory?: string; - readonly declaredRef?: string; - readonly effectiveSha?: string; - readonly github?: { readonly owner: string; readonly repo: string }; - readonly unsupportedReason?: string; -} - -function resolveClaudeEntrySource( - value: unknown, - pluginRoot: string | undefined, - location: MarketplaceLocation, - id: string, -): ResolvedClaudeSource { - if (typeof value === 'string') { - const source = value.trim(); - if (source.length === 0) { - throw new Error(`Plugin marketplace entry ${id} "source" cannot be empty.`); - } - if (isRelativePluginPath(source)) { - const subdirectory = safeRepositoryPath(id, pluginRoot, source); - if (location.github !== undefined) { - return githubPluginSource(location.github, subdirectory, location.github.ref); - } - if (location.kind === 'local' && location.marketplaceRoot !== undefined) { - return { - source: resolveContainedLocalPath(location.marketplaceRoot, subdirectory), - sourceLabel: source, - repositorySubdirectory: undefined, - declaredRef: undefined, - effectiveSha: undefined, - github: undefined, - unsupportedReason: undefined, - }; - } - return unsupportedClaudeSource( - source, - 'Relative Claude plugin sources require a GitHub repository or local marketplace directory.', - ); - } - - const github = parseGithubRepository(source); - if (github !== undefined) { - return githubPluginSource(github, undefined, github.ref); - } - return unsupportedClaudeSource(source, unsupportedSourceReason(source)); - } - - if (!isRecord(value)) { - throw new TypeError(`Plugin marketplace entry ${id} "source" must be a string or object.`); - } - const sourceKind = requiredObjectString(value, 'source', `Plugin marketplace entry ${id} "source"`); - const sha = optionalStringField(value, 'sha', `Plugin marketplace entry ${id} "source"`); - if (sha !== undefined && !FULL_SHA_RE.test(sha)) { - throw new Error(`Plugin marketplace entry ${id} "sha" must be a 40-character hexadecimal SHA.`); - } - const declaredRef = optionalStringField(value, 'ref', `Plugin marketplace entry ${id} "source"`); - const pin = sha ?? declaredRef ?? 'HEAD'; - const declaredPath = optionalStringField(value, 'path', `Plugin marketplace entry ${id} "source"`); - - if (sourceKind === 'npm') { - return unsupportedClaudeSource( - optionalStringField(value, 'package', `Plugin marketplace entry ${id} "source"`) ?? 'npm', - 'npm plugin sources are not supported.', - ); - } - if (sourceKind !== 'github' && sourceKind !== 'url' && sourceKind !== 'git-subdir') { - return unsupportedClaudeSource(sourceKind, `Claude source type "${sourceKind}" is not supported.`); - } - - const repositoryInput = sourceKind === 'github' - ? optionalStringField(value, 'repo', `Plugin marketplace entry ${id} "source"`) - ?? optionalStringField(value, 'url', `Plugin marketplace entry ${id} "source"`) - : optionalStringField(value, 'url', `Plugin marketplace entry ${id} "source"`); - if (repositoryInput === undefined) { - throw new Error(`Plugin marketplace entry ${id} source type "${sourceKind}" must define a repository.`); - } - const github = parseGithubRepository(repositoryInput); - if (github === undefined) { - return unsupportedClaudeSource(repositoryInput, unsupportedSourceReason(repositoryInput)); - } - if (sourceKind === 'git-subdir' && declaredPath === undefined) { - throw new Error(`Plugin marketplace entry ${id} source type "git-subdir" must define "path".`); - } - const subdirectory = safeRepositoryPath(id, declaredPath); - return githubPluginSource(github, subdirectory, pin, declaredRef, sha); -} - -function githubPluginSource( - github: GithubRepositoryContext, - repositorySubdirectory: string | undefined, - pin: string, - declaredRef = pin, - effectiveSha = FULL_SHA_RE.test(pin) ? pin : undefined, -): ResolvedClaudeSource { - const source = `https://github.com/${github.owner}/${github.repo}/tree/${encodeGithubRefPath(pin)}`; - const pathSuffix = repositorySubdirectory === undefined ? '' : `/${repositorySubdirectory}`; - return { - source, - sourceLabel: `${github.owner}/${github.repo}${pathSuffix}@${pin}`, - repositorySubdirectory, - declaredRef, - effectiveSha, - github: { owner: github.owner, repo: github.repo }, - unsupportedReason: undefined, - }; -} - -function unsupportedClaudeSource(sourceLabel: string, reason: string): ResolvedClaudeSource { - return { - source: sourceLabel, - sourceLabel, - repositorySubdirectory: undefined, - declaredRef: undefined, - effectiveSha: undefined, - github: undefined, - unsupportedReason: reason, - }; -} - -function supportedComponentDeclarations( - entry: Record<string, unknown>, -): Record<string, unknown> | undefined { - const components: Record<string, unknown> = {}; - for (const name of SUPPORTED_COMPONENTS) { - if (entry[name] !== undefined) components[name] = entry[name]; - } - return Object.keys(components).length === 0 ? undefined : components; -} - -function supportedComponentNames( - entry: Record<string, unknown>, -): readonly PluginMarketplaceSupportedComponent[] { - return SUPPORTED_COMPONENTS.filter((name) => entry[name] !== undefined); -} - -function unsupportedComponentNames(entry: Record<string, unknown>): readonly string[] { - return UNSUPPORTED_COMPONENTS.filter((name) => entry[name] !== undefined); -} - -const SUPPORTED_COMPONENTS: readonly PluginMarketplaceSupportedComponent[] = [ - 'skills', - 'agents', - 'mcpServers', - 'lspServers', - 'outputStyles', -]; - -const UNSUPPORTED_COMPONENTS = [ - 'commands', - 'hooks', - 'workflows', - 'monitors', - 'themes', - 'channels', - 'dependencies', - 'configuration', -] as const; - -const FULL_SHA_RE = /^[0-9a-fA-F]{40}$/u; -const GITHUB_SHORTHAND_RE = /^([^/\s]+)\/([^/\s]+)$/u; - -/** - * Parse `owner/repo`, `owner/repo/tree/<ref>`, or a github.com URL into a - * repository context. The path is rebuilt from the raw string rather - * than `URL.pathname` (which collapses encoded slashes), and each - * segment is decoded separately so `%2F` inside a segment becomes a - * literal `/` in the ref while URL-normalized segments like `..` stay - * detectable and are rejected. - */ -function parseGithubRepository(input: string): GithubRepositoryContext | undefined { - const shorthand = GITHUB_SHORTHAND_RE.exec(input.trim()); - if (shorthand !== null && !input.includes('://')) { - return { owner: shorthand[1]!, repo: stripGitSuffix(shorthand[2]!), ref: 'HEAD' }; - } - - const trimmed = input.trim(); - const url = URL.parse(trimmed); - if (url?.hostname !== 'github.com') return undefined; - const schemeEnd = trimmed.indexOf('://'); - const pathStart = trimmed.indexOf('/', schemeEnd + 3); - const rawPathWithSuffix = pathStart < 0 ? '' : trimmed.slice(pathStart); - const suffixStart = rawPathWithSuffix.search(/[?#]/u); - const rawPath = suffixStart < 0 ? rawPathWithSuffix : rawPathWithSuffix.slice(0, suffixStart); - const segments = rawPath.startsWith('/') ? rawPath.slice(1).split('/') : rawPath.split('/'); - if (segments.length < 2) return undefined; - const owner = segments[0]!; - const repo = stripGitSuffix(segments[1]!); - if (owner.length === 0 || repo.length === 0) return undefined; - if (segments.length === 2 || (segments.length === 3 && segments[2] === '')) { - return { owner, repo, ref: 'HEAD' }; - } - if (segments[2] !== 'tree' || segments.length < 4) return undefined; - try { - const refSegments = segments.slice(3).flatMap((segment) => decodeURIComponent(segment).split('/')); - if (hasUnsafeRefSegment(refSegments)) return undefined; - return { owner, repo, ref: refSegments.join('/') }; - } catch { - return undefined; - } -} - -function rawGithubUrl(github: GithubRepositoryContext, catalogPath: string): string { - return `https://raw.githubusercontent.com/${github.owner}/${github.repo}/${encodeGithubRefPath(github.ref)}/${catalogPath}`; -} - -/** - * Percent-encode each ref segment for use in a URL path. Rejects empty, - * `.`, and `..` segments: in a raw URL they would be normalized by the - * server (or worse, escape the repo path) instead of naming a ref. - */ -function encodeGithubRefPath(ref: string): string { - const segments = ref.split('/'); - if (hasUnsafeRefSegment(segments)) { - throw new Error('GitHub ref must not contain empty, ".", or ".." path segments.'); - } - return segments.map(encodeURIComponent).join('/'); -} - -function hasUnsafeRefSegment(segments: readonly string[]): boolean { - return segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..'); -} - -function stripGitSuffix(value: string): string { - return value.endsWith('.git') ? value.slice(0, -4) : value; -} - -function safeRepositoryPath( - id: string, - ...parts: readonly (string | undefined)[] -): string | undefined { - const segments: string[] = []; - for (const raw of parts) { - if (raw === undefined) continue; - const trimmed = raw.trim(); - if (trimmed.length === 0 || trimmed === '.') continue; - if (trimmed.includes('\\') || trimmed.startsWith('/') || win32.isAbsolute(trimmed)) { - throw new Error(`Plugin marketplace entry ${id} contains an absolute or unsafe plugin path.`); - } - for (const segment of trimmed.split('/')) { - if (segment.length === 0 || segment === '.') continue; - if (segment === '..') { - throw new Error(`Plugin marketplace entry ${id} plugin path must stay inside its repository.`); - } - segments.push(segment); - } - } - return segments.length === 0 ? undefined : segments.join('/'); -} - -function resolveContainedLocalPath(root: string, subdirectory: string | undefined): string { - const target = resolve(root, subdirectory ?? '.'); - const fromRoot = relative(root, target); - if (fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { - throw new Error('Claude plugin path must stay inside the marketplace root.'); - } - return target; -} - -function resolvePythinkerEntrySource(source: string, location: MarketplaceLocation): string { - const trimmed = source.trim(); - if (isHttpUrl(trimmed) || trimmed.startsWith('~/') || trimmed === '~' || isAbsolute(trimmed)) { - return trimmed; - } - if (trimmed.startsWith('file://')) return fileURLToPath(trimmed); - if (location.kind === 'remote') return new URL(trimmed, location.resolved).toString(); - return resolve(dirname(location.resolved), trimmed); -} - -function resolveLocalPath(input: string, workDir: string): string { - if (input === '~') return homedir(); - if (input.startsWith('~/')) return join(homedir(), input.slice(2)); - return isAbsolute(input) ? input : resolve(workDir, input); -} - -function isRelativePluginPath(source: string): boolean { - return !/^[a-z][a-z\d+.-]*:/iu.test(source) && - !source.startsWith('git@') && - !isAbsolute(source) && - !win32.isAbsolute(source); -} - -function unsupportedSourceReason(source: string): string { - if (source.startsWith('npm:')) return 'npm plugin sources are not supported.'; - if (source.startsWith('git@') || source.startsWith('ssh://')) { - return 'SSH plugin sources are not supported.'; - } - if (/^git(?:\+[^:]+)?:\/\//iu.test(source)) { - return 'Generic Git plugin sources are not supported.'; - } - if (isHttpUrl(source)) return 'Only GitHub-backed Claude plugin sources are supported.'; - return 'This Claude plugin source is not supported.'; -} - -function isHttpUrl(value: string): boolean { - return value.startsWith('http://') || value.startsWith('https://'); -} - -async function pathExists(path: string): Promise<boolean> { - try { - await stat(path); - return true; - } catch { - return false; - } -} - -function assertUniquePluginIds(entries: readonly PluginMarketplaceEntry[]): void { - const seen = new Set<string>(); - for (const entry of entries) { - const normalized = entry.id.toLowerCase(); - if (seen.has(normalized)) { - throw new Error(`Plugin marketplace contains duplicate plugin name "${entry.id}".`); + } catch (error) { + if (options.builtInEntries !== undefined) { + // The built-in entries do not come from the catalog — keep them + // visible when the catalog itself is unreachable. + return withBuiltInEntries({ source, plugins: [] }, options.builtInEntries); } - seen.add(normalized); - } -} - -function requiredEntryRecord(value: unknown, index: number): Record<string, unknown> { - if (!isRecord(value)) { - throw new TypeError(`Plugin marketplace entry ${index + 1} must be an object.`); + throw error; } - return value; -} - -function requiredEntryString( - value: Record<string, unknown>, - field: string, - index: number, -): string { - const result = optionalStringField(value, field, `Plugin marketplace entry ${index + 1}`); - if (result === undefined) { - throw new Error(`Plugin marketplace entry ${index + 1} must define "${field}".`); - } - return result; -} - -function requiredCatalogString(value: Record<string, unknown>, field: string): string { - const result = optionalStringField(value, field, 'Claude plugin marketplace'); - if (result === undefined) throw new Error(`Claude plugin marketplace must define "${field}".`); - return result; -} - -function requiredObjectString( - value: Record<string, unknown>, - field: string, - context: string, -): string { - const result = optionalStringField(value, field, context); - if (result === undefined) throw new Error(`${context} must define "${field}".`); - return result; -} - -function optionalStringField( - value: Record<string, unknown>, - field: string, - context: string, -): string | undefined { - const raw = value[field]; - if (raw === undefined) return undefined; - if (typeof raw !== 'string') throw new TypeError(`${context} "${field}" must be a string.`); - const trimmed = raw.trim(); - return trimmed.length === 0 ? undefined : trimmed; -} - -function optionalStringArrayField( - value: Record<string, unknown>, - field: string, - context: string, -): readonly string[] | undefined { - const raw = value[field]; - if (raw === undefined) return undefined; - if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string')) { - throw new TypeError(`${context} "${field}" must be an array of strings.`); - } - const out = raw.map((item) => (item as string).trim()).filter((item) => item.length > 0); - return out.length === 0 ? undefined : out; -} - -function optionalBooleanField( - value: Record<string, unknown>, - field: string, - context: string, -): boolean | undefined { - const raw = value[field]; - if (raw === undefined) return undefined; - if (typeof raw !== 'boolean') throw new TypeError(`${context} "${field}" must be a boolean.`); - return raw; -} - -function requiredAuthor(value: unknown, context: string): PluginMarketplaceAuthor { - const author = optionalAuthor(value, context); - if (author === undefined) throw new Error(`${context} must define "name".`); - return author; -} - -function optionalAuthor( - value: unknown, - context: string, -): PluginMarketplaceAuthor | undefined { - if (value === undefined) return undefined; - if (!isRecord(value)) throw new TypeError(`${context} must be an object.`); - const name = optionalStringField(value, 'name', context); - if (name === undefined) throw new Error(`${context} must define "name".`); - return { - name, - email: optionalStringField(value, 'email', context), - url: optionalStringField(value, 'url', context), - }; -} - -function parseMarketplaceTier( - value: Record<string, unknown>, - id: string, -): PluginMarketplaceTier | undefined { - const raw = value['tier']; - if (raw === undefined) return undefined; - if (typeof raw !== 'string') { - throw new TypeError(`Plugin marketplace entry ${id} "tier" must be a string.`); - } - const tier = raw.trim(); - if (tier.length === 0) return undefined; - if ((PLUGIN_MARKETPLACE_TIERS as readonly string[]).includes(tier)) { - return tier as PluginMarketplaceTier; - } - throw new Error( - `Plugin marketplace entry ${id} "tier" must be one of: ${PLUGIN_MARKETPLACE_TIERS.join(', ')}.`, + const marketplace = await withLatestVersions( + parsePluginMarketplace(read.raw, read.location), + fetchImpl, ); + return options.builtInEntries !== undefined + ? withBuiltInEntries(marketplace, options.builtInEntries) + : marketplace; } -function isRecord(value: unknown): value is Record<string, unknown> { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function formatParseError(error: unknown): string { - return error instanceof Error ? error.message : String(error); +async function getSourceCheckoutMarketplaceLocation(): Promise<MarketplaceLocation | undefined> { + const marketplacePath = resolve(import.meta.dirname, '../../../../plugins/marketplace.json'); + const info = await stat(marketplacePath).catch(() => undefined); + if (info?.isFile() !== true) return undefined; + return { raw: marketplacePath, kind: 'local', resolved: marketplacePath }; } diff --git a/apps/pythinker-code/src/utils/plugin-update-notice-state.ts b/apps/pythinker-code/src/utils/plugin-update-notice-state.ts new file mode 100644 index 00000000..7baebbe7 --- /dev/null +++ b/apps/pythinker-code/src/utils/plugin-update-notice-state.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { getPluginUpdateNoticeStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Records, per plugin, the newest marketplace version an update notice was + * already shown for. A plugin is re-notified only when the marketplace + * advertises a version different from the recorded one. + */ +export type PluginUpdateNoticeState = { + version: 1; + /** pluginId -> latest marketplace version already notified. */ + notified: Record<string, string>; +}; + +const PluginUpdateNoticeStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const notified = (value as { notified?: unknown }).notified; + if (typeof notified !== 'object' || notified === null) { + return { ...(value as Record<string, unknown>), notified: {} }; + } + + const normalizedNotified: Record<string, string> = {}; + for (const [key, record] of Object.entries(notified)) { + if (key.length === 0 || typeof record !== 'string' || record.length === 0) continue; + normalizedNotified[key] = record; + } + + return { ...(value as Record<string, unknown>), notified: normalizedNotified }; + }, + z + .object({ + version: z.literal(1), + notified: z.record(z.string().min(1), z.string().min(1)), + }) + .strict(), +); + +export function emptyPluginUpdateNoticeState(): PluginUpdateNoticeState { + return { + version: 1, + notified: {}, + }; +} + +export async function readPluginUpdateNoticeState( + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise<PluginUpdateNoticeState> { + try { + return await readJsonFile( + filePath, + PluginUpdateNoticeStateSchema, + emptyPluginUpdateNoticeState(), + ); + } catch { + return emptyPluginUpdateNoticeState(); + } +} + +export async function writePluginUpdateNoticeState( + value: PluginUpdateNoticeState, + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise<void> { + await writeJsonFile(filePath, PluginUpdateNoticeStateSchema, value); +} diff --git a/apps/pythinker-code/src/utils/process/external-editor.ts b/apps/pythinker-code/src/utils/process/external-editor.ts index 48404bc7..890dc13b 100644 --- a/apps/pythinker-code/src/utils/process/external-editor.ts +++ b/apps/pythinker-code/src/utils/process/external-editor.ts @@ -41,7 +41,16 @@ export async function editInExternalEditor( const file = join(dir, 'prompt.md'); await writeFile(file, initialText, 'utf-8'); try { - if (!(await openFileInExternalEditor(file, command))) return undefined; + const shellCmd = `${command} ${quoteShellArg(file)}`; + const code = await new Promise<number>((resolve, reject) => { + const child = spawn(shellCmd, { + stdio: 'inherit', + shell: true, + }); + child.on('exit', (c) => { resolve(c ?? 0); }); + child.on('error', reject); + }); + if (code !== 0) return undefined; return await readFile(file, 'utf-8'); } finally { await rm(dir, { recursive: true, force: true }).catch(() => { @@ -50,15 +59,3 @@ export async function editInExternalEditor( } } -export async function openFileInExternalEditor(file: string, command: string): Promise<boolean> { - const shellCmd = `${command} ${quoteShellArg(file)}`; - const code = await new Promise<number>((resolve, reject) => { - const child = spawn(shellCmd, { - stdio: 'inherit', - shell: true, - }); - child.on('exit', (value) => { resolve(value ?? 0); }); - child.on('error', reject); - }); - return code === 0; -} diff --git a/apps/pythinker-code/src/utils/process/fd-detect.ts b/apps/pythinker-code/src/utils/process/fd-detect.ts index 0de0159c..6dac22c7 100644 --- a/apps/pythinker-code/src/utils/process/fd-detect.ts +++ b/apps/pythinker-code/src/utils/process/fd-detect.ts @@ -17,6 +17,7 @@ import { pipeline } from 'node:stream/promises'; import { PYTHINKER_CODE_CDN_BASE } from '#/constant/app'; import { getBinDir } from '#/utils/paths'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; const CANDIDATES = ['fd', 'fdfind']; const FD_BASE_URL = `${PYTHINKER_CODE_CDN_BASE}/fd`; @@ -56,9 +57,11 @@ export async function ensureFdPath(): Promise<string | null> { function detectSystemFdPath(): string | null { for (const name of CANDIDATES) { + const commandPath = resolveCommandPath(name); + if (commandPath === undefined) continue; try { - const result = spawnSync(name, ['--version'], { stdio: 'ignore' }); - if (result.status === 0) return name; + const result = spawnSync(commandPath, ['--version'], { stdio: 'ignore' }); + if (result.status === 0) return commandPath; } catch { // ENOENT, EACCES, etc. — try next candidate. } diff --git a/apps/pythinker-code/src/utils/process/resolve-command.ts b/apps/pythinker-code/src/utils/process/resolve-command.ts new file mode 100644 index 00000000..721342e6 --- /dev/null +++ b/apps/pythinker-code/src/utils/process/resolve-command.ts @@ -0,0 +1,79 @@ +import { accessSync, constants, statSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +// cmd.exe / CreateProcess search the current directory before PATH, so on +// Windows a bare command name can execute a binary planted in the workspace +// the user just opened (binary planting). Resolving through PATH ourselves — +// and refusing any hit inside the cwd — keeps that from happening before the +// workspace trust gate has run. + +const DEFAULT_WIN32_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']; + +function pathExtensions(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): readonly string[] { + if (platform !== 'win32') return ['']; + const raw = env['PATHEXT']; + if (raw === undefined || raw.trim().length === 0) return DEFAULT_WIN32_PATHEXT; + return raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0); +} + +function candidateNames(command: string, extensions: readonly string[]): readonly string[] { + if (extensions.length === 1 && extensions[0] === '') return [command]; + const lower = command.toLowerCase(); + // An explicitly suffixed name (npm.cmd) is tried as-is first, like cmd.exe. + if (extensions.some((ext) => lower.endsWith(ext.toLowerCase()))) { + return [command, ...extensions.map((ext) => command + ext)]; + } + return extensions.map((ext) => command + ext); +} + +function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(candidate).isFile()) return false; + // Windows has no executable bit; file existence is enough there. + if (platform !== 'win32') accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean { + let resolvedCandidate = resolve(candidate); + let resolvedCwd = resolve(cwd); + if (platform === 'win32') { + resolvedCandidate = resolvedCandidate.toLowerCase(); + resolvedCwd = resolvedCwd.toLowerCase(); + } + const rel = relative(resolvedCwd, resolvedCandidate); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * Resolve a bare command name to an absolute executable path by searching + * PATH (PATHEXT-aware on Windows). Returns undefined when the command is not + * found — or when the only hit lives inside `cwd`, since executing that would + * run whatever a malicious workspace planted there. + */ +export function resolveCommandPath(command: string, cwd: string = process.cwd()): string | undefined { + const platform = process.platform; + const env = process.env; + const extensions = pathExtensions(platform, env); + const names = candidateNames(command, extensions); + const pathValue = env['PATH'] ?? ''; + const separator = platform === 'win32' ? ';' : ':'; + for (const dir of pathValue.split(separator)) { + // An empty PATH entry means the current directory on POSIX — anything it + // could produce would be rejected by the cwd check anyway, so skip it. + if (dir === '') continue; + for (const name of names) { + const candidate = join(dir, name); + if (!isExecutableFile(candidate, platform)) continue; + if (isInsideCwd(candidate, cwd, platform)) return undefined; + return resolve(candidate); + } + } + return undefined; +} diff --git a/apps/pythinker-code/src/utils/startup-trace.ts b/apps/pythinker-code/src/utils/startup-trace.ts new file mode 100644 index 00000000..511be6e3 --- /dev/null +++ b/apps/pythinker-code/src/utils/startup-trace.ts @@ -0,0 +1,34 @@ +// src/utils/startup-trace.ts +// +// Debug-only startup phase tracer, enabled with PYTHINKER_STARTUP_TRACE=1. +// Each call appends one `<elapsed-ms> <label>` line to the trace file +// (default /tmp/pythinker-startup-trace.log, override with +// PYTHINKER_STARTUP_TRACE_LOG=<path>), so a slow or BLOCKING startup phase +// (network preflight, slow fs, spawnSync) is visible by wall-clock even +// where a CPU profile would only show idle. Temporary instrumentation. + +import { appendFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; + +const enabled = process.env['PYTHINKER_STARTUP_TRACE'] !== undefined && process.env['PYTHINKER_STARTUP_TRACE'] !== ''; +const logPath = process.env['PYTHINKER_STARTUP_TRACE_LOG'] ?? '/tmp/pythinker-startup-trace.log'; +const t0 = performance.now(); +let prepared = false; + +export function startupTrace(label: string): void { + if (!enabled) return; + if (!prepared) { + prepared = true; + try { + mkdirSync(path.dirname(logPath), { recursive: true }); + appendFileSync(logPath, `--- ${new Date().toISOString()} pid=${process.pid} ---\n`); + } catch { + /* best effort */ + } + } + try { + appendFileSync(logPath, `${(performance.now() - t0).toFixed(0).padStart(7)}ms ${label}\n`); + } catch { + /* best effort */ + } +} diff --git a/apps/pythinker-code/src/utils/terminal-restore.ts b/apps/pythinker-code/src/utils/terminal-restore.ts new file mode 100644 index 00000000..5a93f382 --- /dev/null +++ b/apps/pythinker-code/src/utils/terminal-restore.ts @@ -0,0 +1,31 @@ +/** + * Best-effort terminal restoration for crash / emergency-exit paths. + * + * The normal shutdown path goes through pi-tui's `TUI.stop()`, which restores + * raw mode, the cursor, bracketed paste, and the Kitty / modifyOtherKeys + * keyboard protocols. When we bail out without running `TUI.stop()` — an + * uncaught exception, a SIGTERM whose cleanup throws, or a SIGHUP — the + * terminal would otherwise be left stuck in raw mode with a hidden cursor, and + * the user's shell would look broken afterwards. Writing these sequences lets + * the terminal recover. + * + * Every step is wrapped: the terminal may already be dead (EIO), and an exit + * path must never throw. + */ + +// Show cursor (`?25h`), disable bracketed paste (`?2004l`), pop the Kitty +// keyboard protocol (`<u`), and reset modifyOtherKeys (`>4;0m`). +const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\u001B[<u\u001B[>4;0m'; + +export function restoreTerminalModes(): void { + try { + process.stdin.setRawMode(false); + } catch { + // ignore — raw mode may not be active, or stdin may not be a TTY. + } + try { + process.stdout.write(TERMINAL_RESTORE_SEQUENCE); + } catch { + // ignore — the terminal may already be dead (EIO). + } +} diff --git a/apps/pythinker-code/src/utils/usage/debug-timing.ts b/apps/pythinker-code/src/utils/usage/debug-timing.ts index 457b686a..87f72696 100644 --- a/apps/pythinker-code/src/utils/usage/debug-timing.ts +++ b/apps/pythinker-code/src/utils/usage/debug-timing.ts @@ -1,7 +1,30 @@ +import { formatTokenCount } from './usage-format'; + +interface DebugTokenUsage { + readonly inputOther?: number; + readonly inputCacheRead?: number; + readonly inputCacheCreation?: number; + readonly output?: number; +} + export interface StepTimingInput { - readonly llmFirstTokenLatencyMs?: number | undefined; - readonly llmStreamDurationMs?: number | undefined; - readonly usage?: { readonly output: number } | undefined; + readonly llmFirstTokenLatencyMs?: number; + readonly llmStreamDurationMs?: number; + /** + * Split of `llmFirstTokenLatencyMs` into the client-side request-build + * portion (`llmRequestBuildMs`) and the network + API-server portion + * (`llmServerFirstTokenMs`). Both present together or not at all. + */ + readonly llmRequestBuildMs?: number; + readonly llmServerFirstTokenMs?: number; + /** + * Split of `llmStreamDurationMs` (the decode window) into server time spent + * awaiting parts (`llmServerDecodeMs`) and client time spent processing parts + * (`llmClientConsumeMs`). Both present together or not at all. + */ + readonly llmServerDecodeMs?: number; + readonly llmClientConsumeMs?: number; + readonly usage?: DebugTokenUsage; } // Decode TPS is only meaningful when the output actually streamed over a @@ -17,21 +40,68 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine const streamMs = input.llmStreamDurationMs; if (latency === undefined || streamMs === undefined) return undefined; - const parts: string[] = [`TTFT: ${formatDuration(latency)}`]; + const parts: string[] = [`TTFT: ${formatTtft(input)}`]; const outputTokens = input.usage?.output; if (outputTokens !== undefined && outputTokens > 0) { if (streamMs >= MIN_STREAM_MS_FOR_TPS) { const tps = (outputTokens / (streamMs / 1000)).toFixed(1); - parts.push(`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)})`); + parts.push( + `TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`, + ); } else { parts.push( `${outputTokens} tokens in ${formatDuration(streamMs)} (stream too short for TPS)`, ); } } + + const inputTokens = usageInputTotal(input.usage); + const hasInputUsage = + input.usage !== undefined && + (input.usage.inputOther !== undefined || + input.usage.inputCacheRead !== undefined || + input.usage.inputCacheCreation !== undefined); + if (hasInputUsage && (inputTokens > 0 || (outputTokens ?? 0) > 0)) { + const cacheReadTokens = input.usage.inputCacheRead ?? 0; + const cacheCreationTokens = input.usage.inputCacheCreation ?? 0; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheReadTokens / inputTokens) * 100) : 0; + const cacheParts = [`cache read ${formatTokenCount(cacheReadTokens)} (${cacheHitRate}%)`]; + if (cacheCreationTokens > 0) { + cacheParts.push(`write ${formatTokenCount(cacheCreationTokens)}`); + } + parts.push(`tokens in ${formatTokenCount(inputTokens)}`); + parts.push(cacheParts.join(' / ')); + } + return `[Debug] ${parts.join(' | ')}`; } +function usageInputTotal(usage: DebugTokenUsage | undefined): number { + if (usage === undefined) return 0; + return (usage.inputOther ?? 0) + (usage.inputCacheRead ?? 0) + (usage.inputCacheCreation ?? 0); +} + +// Render TTFT, splitting the latency into the network + API-server portion and +// the in-process request-build portion when the provider reported the +// boundary. Falls back to the bare total otherwise. +function formatTtft(input: StepTimingInput): string { + const total = formatDuration(input.llmFirstTokenLatencyMs ?? 0); + const build = input.llmRequestBuildMs; + const server = input.llmServerFirstTokenMs; + if (build === undefined || server === undefined) return total; + return `${total} (api ${formatDuration(server)} + client ${formatDuration(build)})`; +} + +// Render the decode-window split as a trailing clause, e.g. +// `; server 4.6s + client 0.4s`. A large client share means the host's per-part +// processing is throttling decode. Empty when the provider did not report it. +function formatDecodeSplit(input: StepTimingInput): string { + const server = input.llmServerDecodeMs; + const client = input.llmClientConsumeMs; + if (server === undefined || client === undefined) return ''; + return `; server ${formatDuration(server)} + client ${formatDuration(client)}`; +} + function formatDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms / 1000).toFixed(1)}s`; diff --git a/apps/pythinker-code/src/utils/usage/usage-format.ts b/apps/pythinker-code/src/utils/usage/usage-format.ts index c1fd8424..b44adb3f 100644 --- a/apps/pythinker-code/src/utils/usage/usage-format.ts +++ b/apps/pythinker-code/src/utils/usage/usage-format.ts @@ -5,6 +5,11 @@ * command itself chalks the colour afterwards. */ +/** + * Format a token count in 1024-based units: context sizes are powers of + * two, so 262144 reads as "256k", not "262.1k". k values at or above + * 100 are rounded to whole numbers ("977k"). + */ export function formatTokenCount(n: number): string { if (!Number.isFinite(n) || n < 0) return '0'; if (n >= 1024 * 1024) return `${trimDecimal(n / (1024 * 1024))}M`; @@ -15,16 +20,23 @@ export function formatTokenCount(n: number): string { return String(n); } -function trimDecimal(value: number): string { - const formatted = value.toFixed(1); - return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; +/** One decimal place, dropping a redundant ".0" ("1.0" → "1", "1.5" stays). */ +function trimDecimal(v: number): string { + const s = v.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; } +/** + * Usage as a whole-number percentage of `max`, ceiled so any non-zero + * usage shows at least 1%, clamped to [0, 100]. A non-positive or + * non-finite `max` reports 0. + */ export function usagePercent(used: number, max: number): number { if (!Number.isFinite(max) || max <= 0) return 0; return Math.min(100, Math.max(0, Math.ceil((used / max) * 100))); } +/** `usagePercent` for callers that only know the ratio (NaN-safe). */ export function usagePercentFromRatio(ratio: number): number { return Math.min(100, Math.max(0, Math.ceil(safeUsageRatio(ratio) * 100))); } diff --git a/apps/pythinker-code/test/cli/acp-native.test.ts b/apps/pythinker-code/test/cli/acp-native.test.ts new file mode 100644 index 00000000..7a6d3436 --- /dev/null +++ b/apps/pythinker-code/test/cli/acp-native.test.ts @@ -0,0 +1,181 @@ +/** + * `pythinker acp` + * + * Verifies that the ACP v2 sub-command is registered on the program and that + * the action wires `@pymodel/acp-server`'s `runAcpServer` (the real server + * is stubbed so the test doesn't actually take over stdio). The module is + * loaded via a lazy dynamic import in the action, so the mock intercepts that + * import. + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@pymodel/acp-server', () => ({ + runAcpServer: vi.fn(async () => undefined), +})); + +import { runAcpServer } from '@pymodel/acp-server'; + +import { registerAcpCommand } from '#/cli/sub/acp'; +import { registerNativeAcpCommand } from '#/cli/sub/acp-native'; +import { getDataDir } from '#/utils/paths'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('pythinker acp', () => { + let exitSpy: ReturnType<typeof vi.spyOn>; + let stderrSpy: ReturnType<typeof vi.spyOn>; + + beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); + vi.mocked(runAcpServer).mockClear(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new ExitCalled(code); + }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + vi.unstubAllEnvs(); + }); + + it('registers an `acp` subcommand on the program', () => { + const program = new Command('pythinker'); + registerNativeAcpCommand(program); + + const acpV2 = program.commands.find((c) => c.name() === 'acp'); + expect(acpV2).toBeDefined(); + expect(acpV2?.description()).toMatch(/Agent Client Protocol/); + }); + + it('uses the v2 server for the default `acp` command', async () => { + const program = new Command('pythinker').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); + + expect(runAcpServer).toHaveBeenCalledTimes(1); + expect(vi.mocked(runAcpServer).mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ homeDir: getDataDir() }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('invokes runAcpServer with the v2 host options and exits 0 on success', async () => { + const program = new Command('pythinker').exitOverride(); + registerNativeAcpCommand(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); + + expect(runAcpServer).toHaveBeenCalledTimes(1); + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(optsArg).toEqual( + expect.objectContaining({ + homeDir: getDataDir(), + agentInfo: { name: 'Pythinker Code CLI', version: expect.any(String) }, + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('forwards PYTHINKER_CODE_HOME to terminalAuthEnv and homeDir when set', async () => { + const previous = process.env['PYTHINKER_CODE_HOME']; + process.env['PYTHINKER_CODE_HOME'] = '/tmp/pythinker-debug'; + try { + const program = new Command('pythinker').exitOverride(); + registerNativeAcpCommand(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(optsArg).toEqual( + expect.objectContaining({ + homeDir: '/tmp/pythinker-debug', + terminalAuthEnv: { PYTHINKER_CODE_HOME: '/tmp/pythinker-debug' }, + }), + ); + } finally { + if (previous === undefined) { + delete process.env['PYTHINKER_CODE_HOME']; + } else { + process.env['PYTHINKER_CODE_HOME'] = previous; + } + } + }); + + it('omits terminalAuthEnv when PYTHINKER_CODE_HOME is unset', async () => { + const previous = process.env['PYTHINKER_CODE_HOME']; + delete process.env['PYTHINKER_CODE_HOME']; + try { + const program = new Command('pythinker').exitOverride(); + registerNativeAcpCommand(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as { + terminalAuthEnv?: unknown; + }; + expect(optsArg.terminalAuthEnv).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env['PYTHINKER_CODE_HOME']; + } else { + process.env['PYTHINKER_CODE_HOME'] = previous; + } + } + }); + + it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => { + const program = new Command('pythinker').exitOverride(); + registerNativeAcpCommand(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as { + terminalAuthLegacyCommand?: string; + }; + expect(typeof optsArg.terminalAuthLegacyCommand).toBe('string'); + expect((optsArg.terminalAuthLegacyCommand ?? '').length).toBeGreaterThan(0); + expect(optsArg.terminalAuthLegacyCommand).toBe(process.argv[1]); + }); + + it('exits without starting the ACP server when --login is passed', async () => { + // Stub the SDK harness so runLoginFlow doesn't hit a real OAuth endpoint: + // harness.auth.login resolves immediately and triggers exit 0. + const loginStub = vi.fn(async () => ({ providerName: 'pythinker-code' })); + vi.doMock(import('@pymodel/pythinker-code-sdk'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createPythinkerHarness: () => + ({ + auth: { login: loginStub }, + }) as unknown as ReturnType<typeof actual.createPythinkerHarness>, + }; + }); + vi.resetModules(); + const { registerNativeAcpCommand: freshRegister } = await import('#/cli/sub/acp-native'); + try { + const program = new Command('pythinker').exitOverride(); + freshRegister(program); + + await expect(program.parseAsync(['node', 'pythinker', 'acp', '--login'])).rejects.toThrow( + ExitCalled, + ); + + expect(loginStub).toHaveBeenCalledTimes(1); + expect(runAcpServer).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + vi.doUnmock('@pymodel/pythinker-code-sdk'); + vi.resetModules(); + } + }); +}); diff --git a/apps/pythinker-code/test/cli/acp.test.ts b/apps/pythinker-code/test/cli/acp.test.ts index 579fe57c..827ef970 100644 --- a/apps/pythinker-code/test/cli/acp.test.ts +++ b/apps/pythinker-code/test/cli/acp.test.ts @@ -30,25 +30,18 @@ describe('pythinker acp', () => { let stderrSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { - vi.mocked(runAcpServer).mockReset().mockResolvedValue(undefined); + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + vi.mocked(runAcpServer).mockClear(); exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { throw new ExitCalled(code); }) as never); - stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( - _chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - const complete = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - complete?.(); - return true; - }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); }); afterEach(() => { exitSpy.mockRestore(); stderrSpy.mockRestore(); + vi.unstubAllEnvs(); }); it('registers an `acp` subcommand on the program', () => { @@ -78,37 +71,6 @@ describe('pythinker acp', () => { expect(exitSpy).toHaveBeenCalledWith(0); }); - it('exits 0 when the ACP transport closes with EPIPE', async () => { - vi.mocked(runAcpServer).mockRejectedValueOnce( - Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }), - ); - const program = new Command('pythinker').exitOverride(); - registerAcpCommand(program); - - await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); - - expect(exitSpy).toHaveBeenCalledOnce(); - expect(exitSpy).toHaveBeenCalledWith(0); - expect(stderrSpy).not.toHaveBeenCalledWith( - expect.stringContaining('acp server: fatal error'), - expect.anything(), - ); - }); - - it('treats a rejected undefined value as a fatal server error', async () => { - vi.mocked(runAcpServer).mockRejectedValueOnce(undefined); - const program = new Command('pythinker').exitOverride(); - registerAcpCommand(program); - - await expect(program.parseAsync(['node', 'pythinker', 'acp'])).rejects.toThrow(ExitCalled); - - expect(exitSpy).toHaveBeenCalledWith(1); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('acp server: fatal error: Unknown error'), - expect.anything(), - ); - }); - it('forwards PYTHINKER_CODE_HOME to terminalAuthEnv when set', async () => { const previous = process.env['PYTHINKER_CODE_HOME']; process.env['PYTHINKER_CODE_HOME'] = '/tmp/pythinker-debug'; @@ -170,36 +132,19 @@ describe('pythinker acp', () => { }); it('exits without starting the ACP server when --login is passed', async () => { - // `acp --login` pivots into the same `runLoginFlow` as `pythinker login`: - // the terminal picker (mocked to choose Kimi OAuth) runs, then the login - // resolves and triggers exit 0. `importOriginal` preserves the other named - // exports (`ErrorCodes`, etc.) that constant/app.ts depends on at module - // load, and the fetchCatalog stub keeps the flow offline. - const originalAcpIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); - Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); - vi.doMock(import('@clack/prompts'), async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - select: vi.fn().mockResolvedValue(undefined), - spinner: vi.fn(() => ({ - start: vi.fn(), - stop: vi.fn(), - error: vi.fn(), - })) as unknown as typeof actual.spinner, - }; - }); + // Stub the harness module so runLoginFlow doesn't hit a real OAuth + // endpoint: harness.auth.login resolves immediately and triggers exit 0. + // `importOriginal` preserves the other named exports (`ErrorCodes`, etc.) + // that constant/app.ts depends on at module load. + const loginStub = vi.fn(async () => ({ providerName: 'pythinker-code' })); vi.doMock(import('@pymodel/pythinker-code-sdk'), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, createPythinkerHarness: () => ({ - auth: { - status: vi.fn(async () => ({ providers: [] })), - }, + auth: { login: loginStub }, }) as unknown as ReturnType<typeof actual.createPythinkerHarness>, - fetchCatalog: vi.fn().mockRejectedValue(new Error('offline')), }; }); vi.resetModules(); @@ -212,16 +157,11 @@ describe('pythinker acp', () => { ExitCalled, ); + expect(loginStub).toHaveBeenCalledTimes(1); expect(runAcpServer).not.toHaveBeenCalled(); - expect(exitSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); } finally { - vi.doUnmock('@clack/prompts'); vi.doUnmock('@pymodel/pythinker-code-sdk'); - if (originalAcpIsTTY === undefined) { - delete (process.stdin as { isTTY?: boolean }).isTTY; - } else { - Object.defineProperty(process.stdin, 'isTTY', originalAcpIsTTY); - } vi.resetModules(); } }); diff --git a/apps/pythinker-code/test/cli/dashboard.test.ts b/apps/pythinker-code/test/cli/dashboard.test.ts deleted file mode 100644 index 8404d602..00000000 --- a/apps/pythinker-code/test/cli/dashboard.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * `pythinker dashboard` - * - * Verifies the CLI layer for the session dashboard: home + auto-port - * resolution, browser open vs `--no-open`, and the session deep-link path. - * Uses injected deps so no real port is bound and the real dashboard server is - * never started. - */ - -import { describe, it, expect, vi } from 'vitest'; - -import { handleDashboard, type DashboardDeps } from '#/cli/sub/dashboard'; - -function makeDeps(over: Partial<DashboardDeps> = {}): { - deps: DashboardDeps; - opened: string[]; - out: string[]; -} { - const opened: string[] = []; - const out: string[] = []; - const deps: DashboardDeps = { - getHomeDir: () => '/home/k', - startDashboardServer: vi.fn(async (o) => ({ - port: 41234, - host: '127.0.0.1', - url: 'http://127.0.0.1:41234/', - close: async () => {}, - _opts: o, - })) as unknown as DashboardDeps['startDashboardServer'], - openUrl: async (u: string) => { - opened.push(u); - }, - waitForShutdown: async () => {}, - stdout: { - write: (s: string) => { - out.push(s); - return true; - }, - }, - stderr: { write: () => true }, - exit: vi.fn() as unknown as DashboardDeps['exit'], - ...over, - }; - return { deps, opened, out }; -} - -describe('handleDashboard', () => { - it('starts the server with the home dir + auto port and opens the browser', async () => { - const { deps, opened, out } = makeDeps(); - await handleDashboard(deps, { open: true }); - expect(deps.startDashboardServer).toHaveBeenCalledWith( - expect.objectContaining({ homeDir: '/home/k', port: 0 }), - ); - expect(opened).toEqual(['http://127.0.0.1:41234/']); - expect(out.join('')).toContain('http://127.0.0.1:41234/'); - }); - - it('does not open the browser when open is false', async () => { - const { deps, opened } = makeDeps(); - await handleDashboard(deps, { open: false }); - expect(opened).toEqual([]); - }); - - it('deep-links to a session when sessionId is given', async () => { - const { deps, opened } = makeDeps(); - await handleDashboard(deps, { open: true, sessionId: 'sess_abc' }); - expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc'); - }); - - it('uses the explicit port when provided', async () => { - const { deps } = makeDeps(); - await handleDashboard(deps, { open: false, port: 4321 }); - expect(deps.startDashboardServer).toHaveBeenCalledWith( - expect.objectContaining({ homeDir: '/home/k', port: 4321 }), - ); - }); - - it('closes the server after shutdown', async () => { - const close = vi.fn(async () => {}); - const { deps } = makeDeps({ - startDashboardServer: vi.fn(async () => ({ - port: 41234, - host: '127.0.0.1', - url: 'http://127.0.0.1:41234/', - close, - })) as unknown as DashboardDeps['startDashboardServer'], - }); - await handleDashboard(deps, { open: false }); - expect(close).toHaveBeenCalledOnce(); - }); - - it('reports a clean error and exits when the server fails to start', async () => { - const errored: string[] = []; - const { deps, opened } = makeDeps({ - startDashboardServer: vi.fn(async () => { - throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321'); - }) as unknown as DashboardDeps['startDashboardServer'], - stderr: { - write: (s: string) => { - errored.push(s); - return true; - }, - }, - waitForShutdown: vi.fn(async () => {}), - }); - await handleDashboard(deps, { open: true, port: 4321 }); - expect(errored.join('')).toContain('Failed to start pythinker dashboard'); - expect(errored.join('')).toContain('EADDRINUSE'); - expect(deps.exit).toHaveBeenCalledWith(1); - // Nothing past the failed start should run. - expect(opened).toEqual([]); - expect(deps.waitForShutdown).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/pythinker-code/test/cli/doctor.test.ts b/apps/pythinker-code/test/cli/doctor.test.ts index b844f94f..c4b18f74 100644 --- a/apps/pythinker-code/test/cli/doctor.test.ts +++ b/apps/pythinker-code/test/cli/doctor.test.ts @@ -1,12 +1,11 @@ -import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { Command } from 'commander'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { - findPythinkerExecutables, handleDoctor, registerDoctorCommand, type DoctorDeps, @@ -15,11 +14,13 @@ import { let dir: string; beforeEach(async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); dir = join(tmpdir(), `pythinker-doctor-${Date.now()}-${Math.random().toString(36).slice(2)}`); await mkdir(dir, { recursive: true }); }); afterEach(async () => { + vi.unstubAllEnvs(); await rm(dir, { recursive: true, force: true }); }); @@ -39,18 +40,6 @@ function makeDeps(): { defaultTuiConfigPath: () => join(dir, 'tui.toml'), stdout: { write: (chunk) => stdout.push(chunk) > 0 }, stderr: { write: (chunk) => stderr.push(chunk) > 0 }, - runtimeInfo: async () => ({ - version: '1.2.3', - installSource: 'pnpm-global', - packageRoot: '/opt/pythinker', - executable: '/usr/local/bin/node', - update: { - latest: '1.3.0', - checkedAt: '2026-07-29T12:00:00.000Z', - autoUpdate: 'on' as const, - mode: 'background-install' as const, - }, - }), exit: (code) => { exitCodes.push(code); throw new Error(`exit ${String(code)}`); @@ -114,187 +103,25 @@ describe('pythinker doctor', () => { expect(out).toContain('built-in defaults will apply'); }); - it('reports the active runtime and installation source', async () => { - const { deps, stdout } = makeDeps(); - - const code = await handleDoctor(deps, {}); - - expect(code).toBe(0); - expect(stdout.join('')).toContain( - [ - 'Runtime', - ' Version: 1.2.3', - ' Install source: pnpm-global', - ' Package root: /opt/pythinker', - ' Executable: /usr/local/bin/node', - ' Update channel: CDN staged rollout', - ' Auto-update: on (installs in background)', - ' Latest cached version: 1.3.0 (checked 2026-07-29T12:00:00.000Z)', - ].join('\n'), - ); - }); - - it('reports Homebrew preparation and restart activation accurately', async () => { - const { deps, stdout } = makeDeps(); - - const code = await handleDoctor({ - ...deps, - runtimeInfo: async () => ({ - version: '1.2.3', - installSource: 'homebrew', - packageRoot: '/opt/homebrew/Cellar/pythinker-code/1.2.3', - executable: '/opt/homebrew/bin/node', - update: { - latest: '1.3.0', - checkedAt: '2026-07-29T12:00:00.000Z', - autoUpdate: 'on', - mode: 'restart-install', - pendingVersion: '1.3.0', - pendingRequestedBy: 'automatic', - logPath: '/tmp/updates/install.log', - }, - }), - }, {}); - - expect(code).toBe(0); - expect(stdout.join('')).toContain( - [ - ' Auto-update: on (prepare in background; install on next launch)', - ' Latest cached version: 1.3.0 (checked 2026-07-29T12:00:00.000Z)', - ' Prepared update: 1.3.0 (installs on next launch)', - ' Update log: /tmp/updates/install.log', - ].join('\n'), - ); - }); - - it('reports when automatic activation of a prepared update is paused', async () => { - const { deps, stdout } = makeDeps(); - - const code = await handleDoctor({ - ...deps, - runtimeInfo: async () => ({ - version: '1.2.3', - installSource: 'homebrew', - packageRoot: '/opt/homebrew/Cellar/pythinker-code/1.2.3', - executable: '/opt/homebrew/bin/node', - update: { - latest: '1.3.0', - checkedAt: '2026-07-29T12:00:00.000Z', - autoUpdate: 'off', - mode: 'restart-install', - pendingVersion: '1.3.0', - pendingRequestedBy: 'automatic', - }, - }), - }, {}); - - expect(code).toBe(0); - expect(stdout.join('')).toContain( - 'Prepared update: 1.3.0 (automatic activation paused until auto-update is enabled)', - ); - }); - - it('reports the recorded update outcomes', async () => { - const { deps, stdout } = makeDeps(); - - const code = await handleDoctor( - { - ...deps, - runtimeInfo: async () => ({ - version: '0.12.0', - installSource: 'native', - executable: '/usr/local/bin/pythinker', - update: { - latest: '0.13.1', - checkedAt: '2026-08-08T12:00:00.000Z', - lastSuccess: - '0.13.1 (installed 2026-08-08T12:01:00.000Z) — unverified: probe timed out', - lastFailure: 'install 0.13.1 (attempt 1): still reports 0.12.0', - }, - }), - }, - {}, - ); - - expect(code).toBe(0); - const output = stdout.join(''); - expect(output).toContain( - ' Last update success: 0.13.1 (installed 2026-08-08T12:01:00.000Z) — unverified: probe timed out', - ); - expect(output).toContain(' Last update failure: install 0.13.1 (attempt 1): still reports 0.12.0'); - }); - - // A packaged native binary ships no package.json. Reporting it used to - // crash the whole command with "Could not locate package.json near …". - it('reports a native install that has no package root', async () => { - const { deps, stdout } = makeDeps(); - - const code = await handleDoctor( - { - ...deps, - runtimeInfo: async () => ({ - version: '1.2.3', - installSource: 'native', - executable: 'C:\\Programs\\Pythinker\\pythinker.exe', - }), - }, - {}, - ); - - expect(code).toBe(0); - const output = stdout.join(''); - expect(output).toContain(' Install source: native'); - expect(output).toContain(' Executable: C:\\Programs\\Pythinker\\pythinker.exe'); - expect(output).not.toContain('Package root'); - }); - - it('warns when multiple Pythinker executables are installed', async () => { - const { deps, stdout } = makeDeps(); + it('uses the legacy validator when legacy wins over the experimental flag', async () => { + const configPath = join(dir, 'config.toml'); + const text = '[providers.pythinker]\ntype = "pythinker"\n'; + await writeFile(configPath, text, 'utf-8'); + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '1'); + const validateConfigToml = vi.fn(async () => undefined); + const { deps } = makeDeps(); const code = await handleDoctor( { ...deps, - runtimeInfo: async () => ({ - version: '1.2.3', - installSource: 'pnpm-global', - packageRoot: '/opt/pythinker', - executable: '/usr/local/bin/node', - installations: ['/usr/local/bin/pythinker', '/opt/homebrew/bin/pythinker'], - ripgrep: { path: '/usr/local/bin/rg', source: 'system-path' }, - }), + configRpc: { validateConfigToml } as unknown as NonNullable<DoctorDeps['configRpc']>, }, - {}, + { target: 'config' }, ); expect(code).toBe(0); - expect(stdout.join('')).toContain( - [ - ' Warning: Multiple Pythinker executables found on PATH:', - ' /usr/local/bin/pythinker', - ' /opt/homebrew/bin/pythinker', - ' Search: /usr/local/bin/rg (system-path)', - ].join('\n'), - ); - }); - - it('finds distinct Pythinker executables on PATH', async () => { - const first = join(dir, 'first'); - const second = join(dir, 'second'); - await mkdir(first); - await mkdir(second); - await Promise.all([ - writeFile(join(first, 'pythinker'), '#!/bin/sh\n'), - writeFile(join(second, 'pythinker'), '#!/bin/sh\n'), - ]); - await Promise.all([ - chmod(join(first, 'pythinker'), 0o755), - chmod(join(second, 'pythinker'), 0o755), - ]); - - await expect(findPythinkerExecutables(`${first}:${second}`, 'linux')).resolves.toEqual([ - join(first, 'pythinker'), - join(second, 'pythinker'), - ]); + expect(validateConfigToml).toHaveBeenCalledWith({ text, filePath: configPath }); }); it('checks only config.toml when the config target is selected', async () => { @@ -464,3 +291,137 @@ max_context_size = "large" expect(err).toContain('models.pythinker.max_context_size:'); }); }); + +describe('pythinker doctor (default v2 config validation)', () => { + afterEach(() => { + delete process.env['PYTHINKER_LOOP_MAX_RETRIES_PER_STEP']; + delete process.env['PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP']; + }); + + it('accepts a config valid for the v2 engine, including schema-less keys', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +default_model = "pythinker" + +[providers.pythinker] +type = "pythinker" +base_url = "https://api.example.com/v1" +api_key = "YOUR_API_KEY" + +[models.pythinker] +provider = "pythinker" +model = "pythinker" +max_context_size = 262144 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + }); + + it('reports schema-invalid sections with TOML-style field paths', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[models.pythinker] +provider = "pythinker" +model = "pythinker" +max_context_size = "large" +`, + 'utf-8', + ); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Validation issues:'); + expect(err).toContain('models.pythinker.max_context_size:'); + }); + + it('warns about unknown top-level keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[providrs.pythinker] +type = "pythinker" +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain('Unknown top-level key ignored by the v2 engine: providrs.'); + }); + + it('reports TOML syntax errors with line and column', async () => { + await writeFile(join(dir, 'config.toml'), '[providers.pythinker\ntype = "pythinker"\n', 'utf-8'); + const { deps, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(1); + const err = stderr.join(''); + expect(err).toContain('Invalid TOML in'); + expect(err).toMatch(/\(line \d+, column \d+\)/); + }); + + it('warns about deprecated config keys without failing', async () => { + await writeFile( + join(dir, 'config.toml'), + ` +[loop_control] +max_retries_per_step = 3 +`, + 'utf-8', + ); + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + const out = stdout.join(''); + expect(out).toContain(`OK config.toml ${join(dir, 'config.toml')}`); + expect(out).toContain("'max_retries_per_step' is deprecated"); + expect(out).toContain("rename it to 'max_attempts_per_step'"); + }); + + it('warns about a deprecated env var that supplies a value', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['PYTHINKER_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + const { deps, stdout, stderr } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stderr.join('')).toBe(''); + expect(stdout.join('')).toContain( + 'Environment variable PYTHINKER_LOOP_MAX_RETRIES_PER_STEP is deprecated; use PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP instead.', + ); + }); + + it('does not warn about the deprecated env var when the primary one is set', async () => { + await writeFile(join(dir, 'config.toml'), '[loop_control]\n', 'utf-8'); + process.env['PYTHINKER_LOOP_MAX_RETRIES_PER_STEP'] = '5'; + process.env['PYTHINKER_LOOP_MAX_ATTEMPTS_PER_STEP'] = '5'; + const { deps, stdout } = makeDeps(); + + const code = await handleDoctor(deps, { target: 'config' }); + + expect(code).toBe(0); + expect(stdout.join('')).not.toContain('PYTHINKER_LOOP_MAX_RETRIES_PER_STEP'); + }); +}); diff --git a/apps/pythinker-code/test/cli/export.test.ts b/apps/pythinker-code/test/cli/export.test.ts index e7c146fd..f1dd97cd 100644 --- a/apps/pythinker-code/test/cli/export.test.ts +++ b/apps/pythinker-code/test/cli/export.test.ts @@ -28,6 +28,7 @@ type CreatePythinkerDeviceId = typeof createPythinkerDeviceIdFn; const mocks = vi.hoisted(() => ({ pythinkerHarnessConstructor: vi.fn(), + pythinkerHarnessV2Constructor: vi.fn(), harnessEnsureConfigFile: vi.fn(), harnessGetConfig: vi.fn(async () => ({ providers: {}, @@ -36,6 +37,7 @@ const mocks = vi.hoisted(() => ({ })), harnessGetCachedAccessToken: vi.fn(), harnessExportSession: vi.fn(), + harnessClose: vi.fn(async () => {}), harnessTrack: vi.fn(), createPythinkerDeviceId: vi.fn<CreatePythinkerDeviceId>(() => 'device-1'), initializeTelemetry: vi.fn(), @@ -49,26 +51,33 @@ const mocks = vi.hoisted(() => ({ vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { const actual = await importOriginal<typeof import('@pymodel/pythinker-code-sdk')>(); + const createFakeHarness = (options: { readonly homeDir?: string } | undefined) => { + const homeDir = options?.homeDir ?? '/tmp/pythinker-export-home'; + if (mocks.harnessCreatesDeviceIdOnConstruction) { + mocks.createPythinkerDeviceId(homeDir); + } + return { + homeDir, + auth: { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + track: mocks.harnessTrack, + exportSession: mocks.harnessExportSession, + close: mocks.harnessClose, + }; + }; return { ...actual, resolvePythinkerHome: mocks.resolvePythinkerHome, createPythinkerHarness: (...args: unknown[]) => { - const options = args[0] as { readonly homeDir?: string } | undefined; - const homeDir = options?.homeDir ?? '/tmp/pythinker-export-home'; - if (mocks.harnessCreatesDeviceIdOnConstruction) { - mocks.createPythinkerDeviceId(homeDir); - } mocks.pythinkerHarnessConstructor(...args); - return { - homeDir, - auth: { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }, - ensureConfigFile: mocks.harnessEnsureConfigFile, - getConfig: mocks.harnessGetConfig, - track: mocks.harnessTrack, - exportSession: mocks.harnessExportSession, - }; + return createFakeHarness(args[0] as { readonly homeDir?: string } | undefined); + }, + createPythinkerHarnessV2: (...args: unknown[]) => { + mocks.pythinkerHarnessV2Constructor(...args); + return createFakeHarness(args[0] as { readonly homeDir?: string } | undefined); }, }; }); @@ -80,6 +89,7 @@ vi.mock('@pymodel/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, + PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', }; }); @@ -92,10 +102,14 @@ vi.mock('@pymodel/pythinker-telemetry', () => ({ })); beforeEach(() => { + // Pin the legacy engine so the default-deps cases keep exercising the legacy + // SDK harness this suite asserts on; the routing cases below re-stub it. + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); tmp = mkdtempSync(join(tmpdir(), 'pythinker-export-')); }); afterEach(() => { + vi.unstubAllEnvs(); rmSync(tmp, { recursive: true, force: true }); vi.clearAllMocks(); mocks.harnessGetConfig.mockResolvedValue({ @@ -126,7 +140,7 @@ function makeResult(id: string, zipPath: string): ExportSessionResult { sessionId: id, exportedAt: '2026-04-18T12:00:00.000Z', pythinkerCodeVersion: '1.27.0', - wireProtocolVersion: '2.0', + wireProtocolVersion: '1.0', os: 'test', nodejsVersion: '22.0.0', workspaceDir: tmp, @@ -381,6 +395,7 @@ describe('pythinker export', () => { exit: ((code: number) => { throw new ExitCalled(code); }) as ExportDeps['exit'], + getShellEnv: () => ({ term: 'xterm-256color', shell: '/bin/zsh' }), }); await program.parseAsync(['node', 'pythinker', 'export', 'ses_telemetry', '--output', output], { @@ -410,6 +425,8 @@ describe('pythinker export', () => { version: expect.any(String), uiMode: 'shell', model: 'k2', + sessionId: undefined, + getAccessToken: expect.any(Function), }); expect(mocks.initializeTelemetry.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessExportSession.mock.invocationCallOrder[0]!, @@ -511,4 +528,64 @@ describe('pythinker export', () => { mocks.harnessTrack.mock.invocationCallOrder[0]!, ); }); + + it('builds the v2 harness by default', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); + const program = new Command('pythinker'); + const output = join(tmp, 'v2-engine.zip'); + mocks.harnessExportSession.mockResolvedValue(makeResult('ses_v2_engine', output)); + + registerExportCommand(program, { + cwd: () => tmp, + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + exit: ((code: number) => { + throw new ExitCalled(code); + }) as ExportDeps['exit'], + }); + + await program.parseAsync(['node', 'pythinker', 'export', 'ses_v2_engine', '--output', output], { + from: 'node', + }); + + expect(mocks.pythinkerHarnessV2Constructor).toHaveBeenCalledTimes(1); + expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); + expect(mocks.harnessExportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses_v2_engine', outputPath: output }), + ); + }); + + it('builds the legacy harness when the legacy flag is truthy', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + const program = new Command('pythinker'); + const output = join(tmp, 'legacy-engine.zip'); + mocks.harnessExportSession.mockResolvedValue(makeResult('ses_legacy_engine', output)); + + registerExportCommand(program, { + cwd: () => tmp, + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + exit: ((code: number) => { + throw new ExitCalled(code); + }) as ExportDeps['exit'], + }); + + await program.parseAsync(['node', 'pythinker', 'export', 'ses_legacy_engine', '--output', output], { + from: 'node', + }); + + expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalledTimes(1); + expect(mocks.pythinkerHarnessV2Constructor).not.toHaveBeenCalled(); + expect(mocks.harnessExportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses_legacy_engine', outputPath: output }), + ); + }); }); diff --git a/apps/pythinker-code/test/cli/ffi-launcher.test.ts b/apps/pythinker-code/test/cli/ffi-launcher.test.ts deleted file mode 100644 index 2ee3e8df..00000000 --- a/apps/pythinker-code/test/cli/ffi-launcher.test.ts +++ /dev/null @@ -1,496 +0,0 @@ -import type { ChildProcessWithoutNullStreams } from 'node:child_process'; -import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { copyFile, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -const FFI_FLAG = '--experimental-ffi'; -const FFI_WARNING_FLAG = '--disable-warning=ExperimentalWarning'; -const launcherSource = resolve(import.meta.dirname, '../../src/launcher.ts'); -const tsxLoader = createRequire(resolve(process.cwd(), 'package.json')).resolve('tsx'); - -interface ProcessResult { - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -} - -interface StartLauncherOptions { - readonly args?: readonly string[]; - readonly detached?: boolean; - readonly ffi?: boolean; -} - -let fixtureDir: string; -let launcherPath: string; -let nodeVersionPatchPath: string; - -function startLauncher( - env?: NodeJS.ProcessEnv, - options: StartLauncherOptions = {}, -): ChildProcessWithoutNullStreams { - const ffiArguments = options.ffi ? [FFI_FLAG, FFI_WARNING_FLAG] : []; - const nodeVersionArguments = - env?.['PYTHINKER_TEST_NODE_VERSION'] === undefined - ? [] - : ['--import', nodeVersionPatchPath]; - return spawn( - process.execPath, - [ - ...ffiArguments, - ...nodeVersionArguments, - '--import', - tsxLoader, - launcherPath, - ...(options.args ?? []), - ], - { - cwd: fixtureDir, - env: { ...process.env, ...env }, - stdio: 'pipe', - detached: options.detached, - }, - ); -} - -async function collect(child: ChildProcessWithoutNullStreams): Promise<ProcessResult> { - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - - const { code, signal } = await new Promise<{ - code: number | null; - signal: NodeJS.Signals | null; - }>((resolveResult, reject) => { - child.once('error', reject); - child.once('close', (code, signal) => resolveResult({ code, signal })); - }); - return { code, signal, stdout, stderr }; -} - -async function waitForStdout( - child: ChildProcessWithoutNullStreams, - expected: string, -): Promise<void> { - let output = ''; - child.stdout.setEncoding('utf8'); - await new Promise<void>((resolveOutput, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error(`Timed out waiting for launcher output: ${expected}`)); - }, 5_000); - const cleanup = (): void => { - clearTimeout(timeout); - child.stdout.off('data', onData); - child.off('error', onError); - }; - const onError = (error: Error): void => { - cleanup(); - reject(error); - }; - const onData = (chunk: string): void => { - output += chunk; - if (!output.includes(expected)) return; - cleanup(); - resolveOutput(); - }; - child.once('error', onError); - child.stdout.on('data', onData); - }); -} - -async function writeMain(source: string): Promise<void> { - await writeFile(join(fixtureDir, 'main.mjs'), source); -} - -beforeEach(async () => { - fixtureDir = await mkdtemp(join(tmpdir(), 'pythinker-ffi-launcher-')); - launcherPath = join(fixtureDir, 'launcher.ts'); - nodeVersionPatchPath = join(fixtureDir, 'patch-node-version.mjs'); - await copyFile(launcherSource, launcherPath); - await writeFile( - nodeVersionPatchPath, - ` - const version = process.env.PYTHINKER_TEST_NODE_VERSION; - if (version !== undefined) { - Object.defineProperty(process.versions, 'node', { configurable: true, value: version }); - } - if (process.env.PYTHINKER_TEST_BLOCK_EXECVE === '1') { - process.execve = () => { - throw new Error('unexpected process.execve call'); - }; - } - `, - ); -}); - -afterEach(async () => { - await rm(fixtureDir, { recursive: true, force: true }); -}); - -describe('FFI launcher', () => { - it('rejects Node versions below the runtime floor', async () => { - await writeMain("process.stdout.write('unexpected main import');"); - - const result = await collect( - startLauncher({ PYTHINKER_TEST_NODE_VERSION: '19.9.9' }), - ); - - expect(result.code).toBe(1); - expect(result.signal).toBeNull(); - expect(result.stdout).toBe(''); - expect(result.stderr).toContain( - 'Pythinker Code requires Node.js 20 or newer; you are running Node.js 19.9.9.', - ); - }); - - it('imports the app directly without FFI on Node 24', async () => { - await writeMain(` - process.stdout.write(JSON.stringify({ - pid: process.pid, - ffi: process.execArgv.includes('${FFI_FLAG}'), - warningDisabled: process.execArgv.includes('${FFI_WARNING_FLAG}'), - marker: process.env.PYTHINKER_CODE_FFI_CHILD, - imported: import.meta.url.endsWith('/main.mjs'), - })); - `); - - const child = startLauncher( - { - PYTHINKER_TEST_NODE_VERSION: '24.18.0', - PYTHINKER_TEST_BLOCK_EXECVE: '1', - }, - { args: ['direct'] }, - ); - const originalPid = child.pid; - const result = await collect(child); - const details = JSON.parse(result.stdout) as { - pid: number; - ffi: boolean; - warningDisabled: boolean; - marker?: string; - imported: boolean; - }; - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(''); - expect(details).toMatchObject({ - pid: originalPid, - ffi: false, - warningDisabled: false, - imported: true, - }); - expect(details.marker).toBeUndefined(); - }); - - it('starts with FFI and preserves argv', async () => { - await writeMain(` - process.stdout.write(JSON.stringify({ - pid: process.pid, - ffi: process.execArgv.includes('${FFI_FLAG}'), - warningDisabled: process.execArgv.includes('${FFI_WARNING_FLAG}'), - argv1: process.argv[1], - args: process.argv.slice(2), - marker: process.env.PYTHINKER_CODE_FFI_CHILD, - imported: import.meta.url.endsWith('/main.mjs'), - })); - `); - - const child = startLauncher(undefined, { args: ['alpha', 'two words'] }); - const originalPid = child.pid; - const result = await collect(child); - const details = JSON.parse(result.stdout) as { - pid: number; - ffi: boolean; - warningDisabled: boolean; - argv1: string; - args: string[]; - marker: string; - imported: boolean; - }; - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(''); - expect(details).toMatchObject({ - ffi: true, - warningDisabled: true, - argv1: launcherPath, - args: ['alpha', 'two words'], - marker: '1', - imported: true, - }); - expect(details.pid === originalPid).toBe(process.platform !== 'win32'); - }); - - it('uses the spawn fallback on win32 even when process.execve exists but throws', async () => { - // Regression: Windows Node ships process.execve as a defined function that - // throws ERR_FEATURE_UNAVAILABLE_ON_PLATFORM when called. The launcher must - // route win32 to the spawn fallback without ever calling execve. - const patchPath = join(fixtureDir, 'patch-win32.mjs'); - await writeFile( - patchPath, - ` - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - process.execve = () => { - throw new Error('The feature process.execve is unavailable on the current platform'); - }; - `, - ); - await writeMain(` - process.stdout.write(JSON.stringify({ imported: true, marker: process.env.PYTHINKER_CODE_FFI_CHILD })); - `); - - const child = spawn( - process.execPath, - ['--import', tsxLoader, '--import', patchPath, launcherPath], - { cwd: fixtureDir, env: { ...process.env }, stdio: 'pipe' }, - ); - const result = await collect(child); - - expect(result.stderr).not.toContain('process.execve is unavailable'); - expect(result.code).toBe(0); - const details = JSON.parse(result.stdout) as { imported: boolean; marker: string }; - expect(details).toMatchObject({ imported: true, marker: '1' }); - }); - - it.skipIf(process.platform === 'win32')( - 'preserves the parent process group and session across execve', - async () => { - await writeMain(` - const ffi = process.getBuiltinModule('node:ffi'); - const handle = ffi.dlopen(null, { - getpgid: { arguments: ['i32'], return: 'i32' }, - getpgrp: { arguments: [], return: 'i32' }, - getsid: { arguments: ['i32'], return: 'i32' }, - }); - try { - process.stdout.write(JSON.stringify({ - pid: process.pid, - processGroup: handle.functions.getpgrp(), - parentProcessGroup: handle.functions.getpgid(process.ppid), - session: handle.functions.getsid(0), - parentSession: handle.functions.getsid(process.ppid), - })); - } finally { - handle.lib.close(); - } - `); - - const child = startLauncher(); - const originalPid = child.pid; - const result = await collect(child); - const details = JSON.parse(result.stdout) as { - pid: number; - processGroup: number; - parentProcessGroup: number; - session: number; - parentSession: number; - }; - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(''); - expect(details.pid).toBe(originalPid); - expect(details.processGroup).toBe(details.parentProcessGroup); - expect(details.session).toBe(details.parentSession); - }, - ); - - it.skipIf(process.platform === 'win32')( - 'starts directly with FFI when already running as a session leader', - async () => { - await writeMain(` - const ffi = process.getBuiltinModule('node:ffi'); - const handle = ffi.dlopen(null, { - getpgrp: { arguments: [], return: 'i32' }, - getsid: { arguments: ['i32'], return: 'i32' }, - }); - try { - process.stdout.write(JSON.stringify({ - pid: process.pid, - processGroup: handle.functions.getpgrp(), - session: handle.functions.getsid(0), - marker: process.env.PYTHINKER_CODE_FFI_CHILD, - })); - } finally { - handle.lib.close(); - } - `); - - const child = startLauncher(undefined, { detached: true, ffi: true }); - const originalPid = child.pid; - const result = await collect(child); - const details = JSON.parse(result.stdout) as { - pid: number; - processGroup: number; - session: number; - marker?: string; - }; - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(''); - expect(details).toEqual({ - pid: originalPid, - processGroup: originalPid, - session: originalPid, - }); - }, - ); - - it.skipIf(process.platform === 'win32' || !existsSync('/usr/bin/expect'))( - 'preserves the foreground terminal and reads real PTY input after execve', - async () => { - await writeMain(` - import { closeSync, openSync } from 'node:fs'; - - const ffi = process.getBuiltinModule('node:ffi'); - const handle = ffi.dlopen(null, { - getpgrp: { arguments: [], return: 'i32' }, - getsid: { arguments: ['i32'], return: 'i32' }, - tcgetpgrp: { arguments: ['i32'], return: 'i32' }, - }); - const descriptor = openSync('/dev/tty', 'r'); - closeSync(descriptor); - try { - process.stdout.write('STATE:' + JSON.stringify({ - pid: process.pid, - processGroup: handle.functions.getpgrp(), - foregroundProcessGroup: handle.functions.tcgetpgrp(0), - session: handle.functions.getsid(0), - marker: process.env.PYTHINKER_CODE_FFI_CHILD, - }) + '\\n'); - } finally { - handle.lib.close(); - } - process.stdin.setEncoding('utf8'); - process.stdin.once('data', (data) => { - process.stdout.write('INPUT:' + data.trim() + '\\n', () => process.exit(0)); - }); - `); - const probePath = join(fixtureDir, 'pty-probe.exp'); - await writeFile(probePath, ` - set timeout 10 - spawn $env(PTY_NODE_PATH) --import $env(PTY_TSX_LOADER) $env(PTY_LAUNCHER_PATH) - expect "STATE:" - send "hello-from-pty\\r" - expect "INPUT:hello-from-pty" - expect eof - set status [wait] - exit [lindex $status 3] - `); - - const probe = spawn('/usr/bin/expect', [probePath], { - cwd: fixtureDir, - env: { - ...process.env, - PTY_NODE_PATH: process.execPath, - PTY_TSX_LOADER: tsxLoader, - PTY_LAUNCHER_PATH: launcherPath, - }, - stdio: 'pipe', - }); - const result = await collect(probe); - const stateMatch = /STATE:(\{[^\r\n]+\})/.exec(result.stdout); - expect(stateMatch).not.toBeNull(); - const state = JSON.parse(stateMatch![1]!) as { - pid: number; - processGroup: number; - foregroundProcessGroup: number; - session: number; - marker: string; - }; - - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - expect(result.stderr).toBe(''); - expect(result.stdout).toContain('INPUT:hello-from-pty'); - expect(state.processGroup).toBe(state.foregroundProcessGroup); - expect(state.session).toBe(state.pid); - expect(state.marker).toBe('1'); - }, - ); - - it('does not let a stale child sentinel bypass FFI re-execution', async () => { - await writeMain(` - process.stdout.write(JSON.stringify({ - pid: process.pid, - ffi: process.execArgv.includes('${FFI_FLAG}'), - marker: process.env.PYTHINKER_CODE_FFI_CHILD, - })); - `); - - const child = startLauncher({ PYTHINKER_CODE_FFI_CHILD: '1' }); - const originalPid = child.pid; - const result = await collect(child); - - const details = JSON.parse(result.stdout) as { - pid: number; - ffi: boolean; - marker: string; - }; - expect(result.code).toBe(0); - expect(result.stderr).toBe(''); - expect(details).toMatchObject({ ffi: true, marker: '1' }); - expect(details.pid === originalPid).toBe(process.platform !== 'win32'); - }); - - it('preserves the imported main module exit status', async () => { - await writeMain('process.exit(37);'); - - const result = await collect(startLauncher()); - - expect(result.code).toBe(37); - expect(result.signal).toBeNull(); - }); - - it.skipIf(process.platform === 'win32').each([ - 'SIGHUP' as const, - 'SIGINT' as const, - 'SIGTERM' as const, - ])('delivers %s directly to the execed application', async (signal) => { - await writeMain(` - process.on('${signal}', () => { - process.stdout.write('received-${signal}'); - process.exit(0); - }); - process.stdout.write('ready\\n'); - setInterval(() => {}, 1_000); - `); - const child = startLauncher(); - await waitForStdout(child, 'ready'); - - child.kill(signal); - const result = await collect(child); - - expect(result.stdout).toContain(`received-${signal}`); - expect(result.code).toBe(0); - expect(result.signal).toBeNull(); - }); - - it.skipIf(process.platform === 'win32')( - 'preserves a terminating signal from the execed application', - async () => { - await writeMain("setTimeout(() => process.kill(process.pid, 'SIGTERM'), 20);"); - - const result = await collect(startLauncher()); - - expect(result.code).toBeNull(); - expect(result.signal).toBe('SIGTERM'); - }, - ); -}); diff --git a/apps/pythinker-code/test/cli/goal-prompt.test.ts b/apps/pythinker-code/test/cli/goal-prompt.test.ts index 31bf25aa..87e739ce 100644 --- a/apps/pythinker-code/test/cli/goal-prompt.test.ts +++ b/apps/pythinker-code/test/cli/goal-prompt.test.ts @@ -46,6 +46,12 @@ describe('parseHeadlessGoalCreate', () => { expect(parseHeadlessGoalCreate('/goal status')).toBeUndefined(); expect(parseHeadlessGoalCreate('/goal pause')).toBeUndefined(); }); + + it('rejects malformed goal create prompts instead of falling through', () => { + expect(() => parseHeadlessGoalCreate(`/goal ${'x'.repeat(4001)}`)).toThrow( + 'Goal objective is too long', + ); + }); }); describe('goal summary', () => { @@ -86,6 +92,7 @@ const mocks = vi.hoisted(() => { getStatus: vi.fn(async () => ({ permission: 'auto', model: 'k2' })), createGoal: vi.fn(async () => snapshot({ status: 'active' })), getGoal: vi.fn(async () => ({ goal: snapshot({ status: 'complete' }) })), + getCronTasks: vi.fn(async () => ({ tasks: [] })), onEvent: vi.fn((handler: (event: any) => void) => { eventHandlers.add(handler); return () => eventHandlers.delete(handler); @@ -97,6 +104,7 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), }; return { session, @@ -160,15 +168,24 @@ describe('runPrompt headless goal mode', () => { let savedExitCode: typeof process.exitCode; beforeEach(() => { + // Pin the legacy engine so runPrompt stays on the SDK path this suite + // mocks, regardless of the host environment. Without this flag, runPrompt + // dispatches to the native v2 runner, which ignores these mocks. + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', ''); savedExitCode = process.exitCode; mocks.experimentalFeatures = [{ id: 'micro_compaction', enabled: true }]; mocks.sessions = []; mocks.session.createGoal.mockClear(); + mocks.session.prompt.mockClear(); + mocks.session.waitForBackgroundTasksOnPrint.mockClear(); mocks.session.getStatus.mockResolvedValue({ permission: 'auto', model: 'k2' } as never); mocks.session.getGoal.mockResolvedValue({ goal: snapshot({ status: 'complete' }) } as never); + mocks.session.getCronTasks.mockResolvedValue({ tasks: [] } as never); }); afterEach(() => { + vi.unstubAllEnvs(); process.exitCode = savedExitCode; }); @@ -178,7 +195,7 @@ describe('runPrompt headless goal mode', () => { await runPrompt(opts({ outputFormat: 'stream-json' }), 'test', { stdout, stderr, - process: { on: () => {}, off: () => {}, exit: () => undefined as never }, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, }); expect(mocks.session.createGoal).toHaveBeenCalledWith( @@ -195,7 +212,7 @@ describe('runPrompt headless goal mode', () => { await runPrompt(opts(), 'test', { stdout, stderr, - process: { on: () => {}, off: () => {}, exit: () => undefined as never }, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, }); expect(process.exitCode).toBe(GOAL_EXIT_CODES.blocked); }); @@ -222,7 +239,7 @@ describe('runPrompt headless goal mode', () => { await runPrompt(opts({ outputFormat: 'stream-json' }), 'test', { stdout, stderr, - process: { on: () => {}, off: () => {}, exit: () => undefined as never }, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, }); expect(stdout.text()).toContain('"status":"complete"'); @@ -237,12 +254,117 @@ describe('runPrompt headless goal mode', () => { await runPrompt(opts(), 'test', { stdout, stderr, - process: { on: () => {}, off: () => {}, exit: () => undefined as never }, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, }); expect(mocks.session.createGoal).toHaveBeenCalled(); - expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X', { - outputSchema: undefined, + expect(mocks.session.prompt).toHaveBeenCalledWith('Ship feature X'); + }); + + it('keeps listening across continuation turns until the goal is terminal', async () => { + const active = snapshot({ status: 'active', turnsUsed: 1, tokensUsed: 80 }); + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + mocks.session.getGoal.mockResolvedValueOnce({ goal: active } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: '1' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + await Promise.resolve(); + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: '2' })); + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }), + ); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + expect(stderr.text()).toContain('turns: 2'); + }); + + it('ignores stale goal checks once a continuation turn has started', async () => { + const completed = snapshot({ status: 'complete', turnsUsed: 2, tokensUsed: 160 }); + let resolveFirstGoal: ((value: { goal: null }) => void) | undefined; + const firstGoal = new Promise<{ goal: null }>((resolve) => { + resolveFirstGoal = resolve; + }); + mocks.session.getGoal + .mockImplementationOnce(() => firstGoal as never) + .mockResolvedValue({ goal: null } as never); + mocks.session.prompt.mockImplementationOnce(async () => { + const emit = (event: Record<string, unknown>) => { + for (const handler of [...mocks.eventHandlers]) { + handler(mocks.mainEvent(event)); + } + }; + emit({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } }); + emit({ type: 'assistant.delta', turnId: 1, delta: '1' }); + emit({ type: 'turn.ended', turnId: 1, reason: 'completed' }); + emit({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }); + emit({ type: 'assistant.delta', turnId: 2, delta: '2' }); + emit({ + type: 'goal.updated', + snapshot: completed, + change: { kind: 'completion', status: 'complete' }, + }); + resolveFirstGoal?.({ goal: null }); + await Promise.resolve(); + emit({ type: 'assistant.delta', turnId: 2, delta: ' tail' }); + emit({ type: 'turn.ended', turnId: 2, reason: 'completed' }); + }); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }); + + expect(stdout.text()).toBe('• 1\n\n• 2 tail\n\n'); + expect(stderr.text()).toContain('Goal [complete]'); + }); + + it('does not send an invalid goal create prompt as a normal prompt', async () => { + const stdout = writer(); + const stderr = writer(); + + await expect( + runPrompt(opts({ prompt: `/goal ${'x'.repeat(4001)}` }), 'test', { + stdout, + stderr, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, + }), + ).rejects.toThrow('Goal objective is too long'); + + expect(mocks.session.createGoal).not.toHaveBeenCalled(); + expect(mocks.session.prompt).not.toHaveBeenCalled(); }); it('validates the resumed session model before creating a headless goal', async () => { @@ -255,7 +377,7 @@ describe('runPrompt headless goal mode', () => { runPrompt(opts({ session: 'ses_goal' }), 'test', { stdout, stderr, - process: { on: () => {}, off: () => {}, exit: () => undefined as never }, + process: { once: () => {}, off: () => {}, exit: () => undefined as never }, }), ).rejects.toThrow('No model configured'); diff --git a/apps/pythinker-code/test/cli/headless-exit.test.ts b/apps/pythinker-code/test/cli/headless-exit.test.ts new file mode 100644 index 00000000..a977a70d --- /dev/null +++ b/apps/pythinker-code/test/cli/headless-exit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Writable } from 'node:stream'; + +import { drainStdio, finalizeHeadlessRun, scheduleHeadlessForceExit } from '#/cli/headless-exit'; + +describe('scheduleHeadlessForceExit', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('force-exits with the lazily-resolved exit code after the grace period', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + let code = 0; + const handle = scheduleHeadlessForceExit({ exit }, () => code, 2000); + // The exit code can be set after scheduling (e.g. a goal turn maps its + // terminal status to process.exitCode); it must be read at fire time. + code = 7; + + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1999); + expect(exit).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(exit).toHaveBeenCalledWith(7); + + clearTimeout(handle); + }); + + it('schedules an unref\'d timer so a healthy run still exits naturally', () => { + // Real timers: an un-unref'd guard would itself keep the event loop alive, + // turning the fix into a regression (every healthy run would wait the full + // grace before exiting). hasRef() must be false. + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 60_000); + expect((handle as { hasRef?: () => boolean }).hasRef?.()).toBe(false); + clearTimeout(handle); + }); + + it('does not fire once cancelled via clearTimeout', () => { + vi.useFakeTimers(); + const exit = vi.fn(); + const handle = scheduleHeadlessForceExit({ exit }, () => 0, 2000); + clearTimeout(handle); + vi.advanceTimersByTime(5000); + expect(exit).not.toHaveBeenCalled(); + }); +}); + +describe('drainStdio', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves once buffered output has flushed', async () => { + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 5000).then(() => { + resolved = true; + }); + await Promise.resolve(); + expect(resolved).toBe(false); // still draining + + flush?.(); // consumer caught up + await done; + expect(resolved).toBe(true); + }); + + it('gives up after the timeout when the consumer never drains', async () => { + vi.useFakeTimers(); + // write() never invokes its flush callback — a permanently-stuck consumer. + const stream = { write: vi.fn(() => false) } as unknown as Writable; + + let resolved = false; + const done = drainStdio([stream], 3000).then(() => { + resolved = true; + }); + await vi.advanceTimersByTimeAsync(2999); + expect(resolved).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await done; + expect(resolved).toBe(true); + }); +}); + +describe('finalizeHeadlessRun', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('flushes stdio before arming the force-exit so buffered output is not truncated', async () => { + vi.useFakeTimers(); + let flush: (() => void) | undefined; + const stream = { + write: vi.fn((_chunk: string, cb: () => void) => { + flush = cb; + return false; + }), + } as unknown as Writable; + const exit = vi.fn(); + + const done = finalizeHeadlessRun({ exit }, [stream], () => 0, { + drainTimeoutMs: 5000, + graceMs: 2000, + }); + + // Output is still draining: even well past the force-exit grace, we must NOT + // have armed/fired the exit — doing so would truncate the buffered output. + await vi.advanceTimersByTimeAsync(4000); + expect(exit).not.toHaveBeenCalled(); + + // Consumer catches up → drain completes → only now is the backstop armed. + flush?.(); + await done; + expect(exit).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2000); + expect(exit).toHaveBeenCalledWith(0); + }); +}); diff --git a/apps/pythinker-code/test/cli/login.test.ts b/apps/pythinker-code/test/cli/login.test.ts index 31251ef2..fb421b12 100644 --- a/apps/pythinker-code/test/cli/login.test.ts +++ b/apps/pythinker-code/test/cli/login.test.ts @@ -1,31 +1,15 @@ /** * `pythinker login` * - * Verifies that the login sub-command is registered on the program and that - * the action drives the multi-provider login flow: the provider picker - * (`@clack/prompts`) is offered unless `--provider <id>` skips it, the device - * code is printed to stderr, and the process exits with the right code on - * success / cancellation / failure. Non-TTY stdin refuses to prompt. + * Verifies that the login sub-command is registered on the program and + * that the action drives `harness.auth.login`, prints the device code to + * stderr, and exits with the right code on success / failure. */ import { Command } from 'commander'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { password, select, text } from '@clack/prompts'; -import { - createPythinkerHarness, - fetchCatalog, - type Catalog, -} from '@pymodel/pythinker-code-sdk'; - -import { registerLoginCommand } from '#/cli/sub/login'; -import { openUrl } from '#/utils/open-url'; - const mockLogin = vi.fn(); -const mockStatus = vi.fn(); -const mockGetConfig = vi.fn(); -const mockSetConfig = vi.fn(); -const mockRemoveProvider = vi.fn(); vi.mock('@pymodel/pythinker-code-sdk', async () => { const actual = await vi.importActual<typeof import('@pymodel/pythinker-code-sdk')>( @@ -36,64 +20,17 @@ vi.mock('@pymodel/pythinker-code-sdk', async () => { createPythinkerHarness: vi.fn(() => ({ auth: { login: mockLogin, - status: mockStatus, }, - getConfig: mockGetConfig, - setConfig: mockSetConfig, - removeProvider: mockRemoveProvider, - })), - // Deterministic offline fallback: login must work from the bundled - // catalog without touching the network. - fetchCatalog: vi.fn().mockRejectedValue(new Error('offline')), - }; -}); - -vi.mock('@clack/prompts', async () => { - const actual = await vi.importActual<typeof import('@clack/prompts')>('@clack/prompts'); - // clack's log caches its own write reference at import time, which would - // bypass the per-test process.stderr.write spy; route through the live - // stream instead so the assertions below observe the output. - const logWrite = (message: string): void => { - process.stderr.write(`${message}\n`); - }; - // clack 1.7's real cancel sentinel is a private local symbol, so the mock - // pairs its own sentinel with a matching isCancel predicate. - const cancelSymbol = Symbol.for('clack:cancel'); - return { - ...actual, - isCancel: (value: unknown): boolean => value === cancelSymbol, - log: { - message: logWrite, - info: logWrite, - success: logWrite, - warn: logWrite, - error: logWrite, - }, - select: vi.fn(), - password: vi.fn(), - text: vi.fn(), - spinner: vi.fn(() => ({ - start: vi.fn(), - stop: vi.fn(), - error: vi.fn(), })), }; }); -// The oauth package is imported transitively before this module's body runs, -// so the spy has to exist at hoist time. -const { mockFetchOpenPlatformModels } = vi.hoisted(() => ({ - mockFetchOpenPlatformModels: vi.fn(), -})); +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); -vi.mock('@pymodel/pythinker-code-oauth', async () => { - const actual = await vi.importActual<typeof import('@pymodel/pythinker-code-oauth')>( - '@pymodel/pythinker-code-oauth', - ); - return { ...actual, fetchOpenPlatformModels: mockFetchOpenPlatformModels }; -}); +import { createPythinkerHarness } from '@pymodel/pythinker-code-sdk'; -vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); +import { registerLoginCommand } from '#/cli/sub/login'; +import { openUrl } from '#/utils/open-url'; class ExitCalled extends Error { constructor(public code: number | string | null | undefined) { @@ -101,251 +38,140 @@ class ExitCalled extends Error { } } -const CANCEL = Symbol.for('clack:cancel'); - describe('pythinker login', () => { let exitSpy: ReturnType<typeof vi.spyOn>; - let originalIsTTY: PropertyDescriptor | undefined; let stderrSpy: ReturnType<typeof vi.spyOn>; beforeEach(() => { mockLogin.mockReset(); - mockStatus.mockReset(); - mockGetConfig.mockReset(); - mockSetConfig.mockReset(); - mockRemoveProvider.mockReset(); vi.mocked(openUrl).mockReset(); vi.mocked(createPythinkerHarness).mockClear(); - vi.mocked(select).mockReset(); - // Every prompt and catalog mock resets too: a test that sets a persistent - // implementation (the API-key prompt, the model catalog) would otherwise - // leak it into whichever test runs next. - vi.mocked(password).mockReset(); - vi.mocked(text).mockReset(); - vi.mocked(fetchCatalog).mockReset(); - vi.mocked(fetchCatalog).mockRejectedValue(new Error('offline')); - mockFetchOpenPlatformModels.mockReset(); - // Capture the real descriptor so teardown restores it exactly; assigning - // `undefined` would leave a fake own-property behind for later suites. - originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); - Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { throw new ExitCalled(code); }) as never); - stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( - _chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - const complete = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - complete?.(); - return true; - }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); }); afterEach(() => { exitSpy.mockRestore(); stderrSpy.mockRestore(); - if (originalIsTTY === undefined) { - delete (process.stdin as { isTTY?: boolean }).isTTY; - } else { - Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); - } }); - function pickerProgram(): Command { - const program = new Command('pythinker').exitOverride(); - registerLoginCommand(program); - return program; - } - - async function runLogin(args: readonly string[]): Promise<void> { - await expect(pickerProgram().parseAsync(['node', 'pythinker', ...args])).rejects.toThrow( - ExitCalled, - ); - } - - function writtenChunks(): string[] { - return stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); - } - - /** One connectable catalog provider, enough for `buildPlatformOptions` to list it. */ - function catalogWithDeepSeek(): Catalog { - return { - deepseek: { - id: 'deepseek', - name: 'DeepSeek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.example.com/v1', - models: { - 'deepseek-chat': { - id: 'deepseek-chat', - name: 'DeepSeek Chat', - tool_call: true, - limit: { context: 128_000, output: 8_192 }, - }, - }, - }, - }; - } - - it('registers a `login` subcommand with a --provider option on the program', () => { + it('registers a `login` subcommand on the program', () => { const program = new Command('pythinker'); registerLoginCommand(program); const login = program.commands.find((c) => c.name() === 'login'); expect(login).toBeDefined(); - expect(login?.description()).toMatch(/[Ll]og in/u); - expect(login?.options.some((option) => option.attributeName() === 'provider')).toBe(true); + expect(login?.description()).toMatch(/[Aa]uthenticat/); }); + it('invokes harness.auth.login and exits 0 on success', async () => { + mockLogin.mockResolvedValue({ providerName: 'pythinker-code', ok: true }); + const program = new Command('pythinker').exitOverride(); + registerLoginCommand(program); + await expect(program.parseAsync(['node', 'pythinker', 'login'])).rejects.toThrow(ExitCalled); - - - it('--provider matches a display name case-insensitively', async () => { - mockStatus.mockResolvedValue({ providers: [] }); - mockGetConfig.mockResolvedValue({ providers: {}, models: {} }); - vi.mocked(fetchCatalog).mockResolvedValueOnce(catalogWithDeepSeek()); - vi.mocked(password).mockResolvedValue('sk-test-key'); - vi.mocked(select) - .mockResolvedValueOnce('deepseek/deepseek-chat') - .mockResolvedValueOnce('off'); - - await runLogin(['login', '-p', 'deepseek api']); - - // Positive first: the label resolved to a real platform and the flow ran - // all the way to persisting it. Without this the negative assertion below - // would also hold for a login that never started. - expect(mockSetConfig).toHaveBeenCalledWith( + expect(mockLogin).toHaveBeenCalledTimes(1); + expect(mockLogin).toHaveBeenCalledWith( + undefined, expect.objectContaining({ - providers: expect.objectContaining({ deepseek: expect.anything() }), + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), }), ); - expect(select).not.toHaveBeenCalledWith( - expect.objectContaining({ message: 'Select a provider' }), - ); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); + expect(exitSpy).toHaveBeenCalledWith(0); }); - it('--provider matches a catalog provider by its bare id', async () => { - // A catalog provider's option value carries the internal `catalog:` prefix - // and its label is a product name, so the id printed everywhere else — - // `deepseek` — used to match neither and the login failed outright. - mockStatus.mockResolvedValue({ providers: [] }); - mockGetConfig.mockResolvedValue({ providers: {}, models: {} }); - vi.mocked(fetchCatalog).mockResolvedValueOnce(catalogWithDeepSeek()); - vi.mocked(password).mockResolvedValue('sk-test-key'); - vi.mocked(select) - .mockResolvedValueOnce('deepseek/deepseek-chat') - .mockResolvedValueOnce('off'); - - await runLogin(['login', '--provider', 'DeepSeek API']); - - expect(select).not.toHaveBeenCalledWith( - expect.objectContaining({ message: 'Select a provider' }), + it('prints device code prompt to stderr', async () => { + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise<void>; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'pythinker-code', ok: true }; + }, ); - expect(mockSetConfig).toHaveBeenCalled(); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - // The bare id resolves the same option the label does. - vi.mocked(select).mockReset(); - mockSetConfig.mockClear(); - exitSpy.mockClear(); - vi.mocked(fetchCatalog).mockResolvedValueOnce(catalogWithDeepSeek()); - vi.mocked(select) - .mockResolvedValueOnce('deepseek/deepseek-chat') - .mockResolvedValueOnce('off'); + const program = new Command('pythinker').exitOverride(); + registerLoginCommand(program); - await runLogin(['login', '--provider', 'deepseek']); + await expect(program.parseAsync(['node', 'pythinker', 'login'])).rejects.toThrow(ExitCalled); - expect(select).not.toHaveBeenCalledWith( - expect.objectContaining({ message: 'Select a provider' }), + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, ); - expect(mockSetConfig).toHaveBeenCalled(); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); }); - it('persists the picked thinking effort, not just the on/off bit', async () => { - // The apply step writes config.thinking.effort, but the setConfig patch used - // to list only providers/models/defaultModel/defaultThinking — so the level - // never reached disk and every session reopened at the default. - mockStatus.mockResolvedValue({ providers: [] }); - mockGetConfig.mockResolvedValue({ providers: {}, models: {} }); - vi.mocked(fetchCatalog).mockResolvedValueOnce(catalogWithDeepSeek()); - vi.mocked(password).mockResolvedValue('sk-test-key'); - vi.mocked(select) - .mockResolvedValueOnce('deepseek/deepseek-chat') - .mockResolvedValueOnce('medium'); - - await runLogin(['login', '--provider', 'deepseek']); - - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ - defaultThinking: true, - thinking: expect.objectContaining({ effort: 'medium' }), - }), + it('still prints device code prompt when opening the browser fails', async () => { + vi.mocked(openUrl).mockImplementation(() => { + throw new Error('no browser'); + }); + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise<void>; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://example.com/v', + verificationUriComplete: 'https://example.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'pythinker-code', ok: true }; + }, ); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); - }); + const program = new Command('pythinker').exitOverride(); + registerLoginCommand(program); - it('refuses to prompt when stdin is not a TTY and exits non-zero', async () => { - Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }); - - await runLogin(['login']); + await expect(program.parseAsync(['node', 'pythinker', 'login'])).rejects.toThrow(ExitCalled); - expect(select).not.toHaveBeenCalled(); - expect(mockLogin).not.toHaveBeenCalled(); - const chunks = writtenChunks(); - // The TTY gate is unconditional — --provider cannot rescue a non-interactive - // run, so the message must not imply that it can. - expect(chunks.some((chunk: string) => chunk.includes('interactive terminal'))).toBe(true); - expect(chunks.some((chunk: string) => chunk.includes('--provider'))).toBe(false); - expect(exitSpy.mock.calls[0]?.[0]).toBe(1); + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://example.com/v'))).toBe( + true, + ); + expect(openUrl).toHaveBeenCalledWith('https://example.com/v?code=ABCD-EFGH'); + expect(exitSpy).toHaveBeenCalledWith(0); }); - it('exits 1 when the provider picker is cancelled, without writing config', async () => { - vi.mocked(select).mockResolvedValueOnce(CANCEL); - mockStatus.mockResolvedValue({ providers: [] }); + it('exits 1 when auth.login throws', async () => { + mockLogin.mockRejectedValue(new Error('boom')); - await runLogin(['login']); - - expect(mockLogin).not.toHaveBeenCalled(); - expect(mockSetConfig).not.toHaveBeenCalled(); - expect(mockRemoveProvider).not.toHaveBeenCalled(); - const chunks = writtenChunks(); - expect(chunks.some((chunk: string) => chunk.includes('Login cancelled.'))).toBe(true); - expect(exitSpy.mock.calls[0]?.[0]).toBe(1); - }); - - // Every other case here drives the OAuth path. API-key login is the path most - // providers use — and the only one left once managed OAuth is gone — so its - // exit code needs its own cover. - it('exits 0 after a successful API-key login on an open platform', async () => { - mockStatus.mockResolvedValue({ providers: [] }); - mockGetConfig.mockResolvedValue({ providers: {}, models: {} }); - vi.mocked(password).mockResolvedValue('sk-test-key'); - mockFetchOpenPlatformModels.mockResolvedValue([ - { id: 'glm-4', name: 'GLM 4', contextLength: 128_000, supportsReasoning: true }, - ]); - // The model select's value is the `<platform>/<model>` alias, not the bare id. - vi.mocked(select) - .mockResolvedValueOnce('glm-zai-coding/glm-4') - .mockResolvedValueOnce('medium'); + const program = new Command('pythinker').exitOverride(); + registerLoginCommand(program); - await runLogin(['login', '--provider', 'glm-zai-coding']); + await expect(program.parseAsync(['node', 'pythinker', 'login'])).rejects.toThrow(ExitCalled); - // This path has its own setConfig patch, which omitted `thinking` the same - // way the catalog one did, so the picked level needs asserting here too. - expect(mockSetConfig).toHaveBeenCalledWith( - expect.objectContaining({ - defaultThinking: true, - thinking: expect.objectContaining({ effort: 'medium' }), - }), - ); - expect(exitSpy.mock.calls[0]?.[0]).toBe(0); + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('boom'))).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(1); }); }); diff --git a/apps/pythinker-code/test/cli/main.test.ts b/apps/pythinker-code/test/cli/main.test.ts index 1c75680b..085dcc7f 100644 --- a/apps/pythinker-code/test/cli/main.test.ts +++ b/apps/pythinker-code/test/cli/main.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ErrorCodes, PythinkerError } from '@pymodel/pythinker-code-sdk'; -import { OptionConflictError, validateOptions } from '#/cli/options'; +import { validateOptions } from '#/cli/options'; import type { CLIOptions } from '#/cli/options'; import type * as OptionsModule from '#/cli/options'; import { runPrompt } from '#/cli/run-prompt'; import { runShell } from '#/cli/run-shell'; import { formatStartupError } from '#/cli/startup-error'; -import { activatePendingUpdate } from '#/cli/update/activation'; import { runUpdatePreflight } from '#/cli/update/preflight'; -import { dispatchUpdateHelperIfRequested } from '#/cli/update/update-helper'; import { handleMainCommand, handleUpgradeCommand, main } from '#/main'; const mocks = vi.hoisted(() => { @@ -20,10 +18,6 @@ const mocks = vi.hoisted(() => { getVersion: vi.fn(() => '0.0.1-alpha.2'), validateOptions: vi.fn(), runUpdatePreflight: vi.fn(), - activatePendingUpdate: vi.fn(), - isAutoUpdateDisabledByEnv: vi.fn(), - shouldAutoInstallUpdates: vi.fn(), - dispatchUpdateHelperIfRequested: vi.fn(), runShell: vi.fn(), runPrompt: vi.fn(), installCrashHandlers: vi.fn(), @@ -38,6 +32,8 @@ const mocks = vi.hoisted(() => { })), initializeCliTelemetry: vi.fn(), handleUpgrade: vi.fn(), + flushDiagnosticLogs: vi.fn(), + finalizeHeadlessRun: vi.fn(), log: { info: vi.fn(), warn: vi.fn(), @@ -85,6 +81,7 @@ vi.mock('@pymodel/pythinker-code-sdk', async () => { mocks.createPythinkerHarness(...args); return mocks.harness; }, + flushDiagnosticLogs: mocks.flushDiagnosticLogs, PythinkerHarness: MockPythinkerHarness, log: mocks.log, }; @@ -123,16 +120,6 @@ vi.mock('../../src/cli/options', async () => { vi.mock('../../src/cli/update/preflight', () => ({ runUpdatePreflight: mocks.runUpdatePreflight, - isAutoUpdateDisabledByEnv: mocks.isAutoUpdateDisabledByEnv, - shouldAutoInstallUpdates: mocks.shouldAutoInstallUpdates, -})); - -vi.mock('../../src/cli/update/activation', () => ({ - activatePendingUpdate: mocks.activatePendingUpdate, -})); - -vi.mock('../../src/cli/update/update-helper', () => ({ - dispatchUpdateHelperIfRequested: mocks.dispatchUpdateHelperIfRequested, })); vi.mock('../../src/cli/run-shell', () => ({ @@ -143,6 +130,10 @@ vi.mock('../../src/cli/run-prompt', () => ({ runPrompt: mocks.runPrompt, })); +vi.mock('../../src/cli/headless-exit', () => ({ + finalizeHeadlessRun: mocks.finalizeHeadlessRun, +})); + class ExitCalled extends Error { constructor(readonly code: number) { super(`exit(${code})`); @@ -153,7 +144,6 @@ function defaultOpts(): CLIOptions { return { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -161,9 +151,25 @@ function defaultOpts(): CLIOptions { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }; } +async function waitForAssertion(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + async function runHandleMainCommand(opts: CLIOptions): Promise<number | null> { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); @@ -207,53 +213,13 @@ describe('main entry command handling', () => { vi.clearAllMocks(); mocks.harness.ensureConfigFile.mockResolvedValue(undefined); mocks.harness.getConfig.mockResolvedValue({ - defaultModel: 'pythinker-k2', + defaultModel: 'kimi-k2', telemetry: true, }); mocks.harness.close.mockResolvedValue(undefined); mocks.shutdownTelemetry.mockResolvedValue(undefined); mocks.handleUpgrade.mockResolvedValue(0); - mocks.activatePendingUpdate.mockResolvedValue({ status: 'none' }); - mocks.isAutoUpdateDisabledByEnv.mockReturnValue(false); - mocks.shouldAutoInstallUpdates.mockResolvedValue(true); - mocks.dispatchUpdateHelperIfRequested.mockReturnValue(false); - }); - - it('flushes a parsed option conflict before exiting', async () => { - const opts = { ...defaultOpts(), sessionSelectorConflict: true }; - mocks.validateOptions.mockImplementation(() => { - throw new OptionConflictError('Cannot combine --session with --resume.'); - }); - let completeWrite: ((error?: Error | null) => void) | undefined; - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( - _chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - completeWrite = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - return true; - }) as never); - - try { - let settled = false; - const pending = runHandleMainCommand(opts).then((code) => { - settled = true; - return code; - }); - await vi.waitFor(() => { - expect(stderrSpy).toHaveBeenCalledWith( - 'error: Cannot combine --session with --resume.\n', - expect.any(Function), - ); - }); - expect(settled).toBe(false); - - completeWrite?.(); - await expect(pending).resolves.toBe(1); - } finally { - stderrSpy.mockRestore(); - } + mocks.flushDiagnosticLogs.mockResolvedValue(undefined); }); it('runs update preflight before starting the shell', async () => { @@ -273,79 +239,6 @@ describe('main entry command handling', () => { expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); }); - it('activates a prepared update and re-execs the new Homebrew launcher before preflight', async () => { - const opts = defaultOpts(); - mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); - mocks.activatePendingUpdate.mockResolvedValue({ - status: 'activated', - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }); - const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); - const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); - Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: true }); - Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value: true }); - const execve = vi.spyOn(process, 'execve').mockImplementation(() => { - throw new Error('re-exec'); - }); - - try { - await expect(handleMainCommand(opts, '0.4.0')).rejects.toThrow('re-exec'); - expect(activatePendingUpdate).toHaveBeenCalledWith('0.4.0', { - enabled: true, - automaticEnabled: true, - }); - expect(execve).toHaveBeenCalledWith( - '/opt/homebrew/opt/pythinker-code/bin/pythinker', - ['/opt/homebrew/opt/pythinker-code/bin/pythinker', ...process.argv.slice(2)], - process.env, - ); - expect(runUpdatePreflight).not.toHaveBeenCalled(); - expect(runShell).not.toHaveBeenCalled(); - } finally { - execve.mockRestore(); - if (stdinDescriptor === undefined) { - delete (process.stdin as { isTTY?: boolean }).isTTY; - } else { - Object.defineProperty(process.stdin, 'isTTY', stdinDescriptor); - } - if (stdoutDescriptor === undefined) { - delete (process.stdout as { isTTY?: boolean }).isTTY; - } else { - Object.defineProperty(process.stdout, 'isTTY', stdoutDescriptor); - } - } - }); - - it('does not block normal startup when pending-update state processing fails', async () => { - const opts = defaultOpts(); - mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); - mocks.activatePendingUpdate.mockRejectedValue(new Error('install state is read-only')); - mocks.runUpdatePreflight.mockResolvedValue('continue'); - mocks.runShell.mockResolvedValue(undefined); - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((( - _chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - const complete = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - complete?.(); - return true; - }) as never); - - try { - await expect(runHandleMainCommand(opts)).resolves.toBeNull(); - expect(runUpdatePreflight).toHaveBeenCalledOnce(); - expect(runShell).toHaveBeenCalledOnce(); - expect(stderrSpy).toHaveBeenCalledWith( - 'warning: unable to process a pending Pythinker Code update: install state is read-only\n', - expect.any(Function), - ); - } finally { - stderrSpy.mockRestore(); - } - }); - it('runs prompt mode without interactive update preflight', async () => { const opts: CLIOptions = { ...defaultOpts(), @@ -366,24 +259,79 @@ describe('main entry command handling', () => { expect(runShell).not.toHaveBeenCalled(); }); - it('runs init-only without mounting prompt mode', async () => { - const opts: CLIOptions = { - ...defaultOpts(), - initOnly: true, - }; + it('does not force-exit from the reusable handler in print mode', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); + + // Process disposition belongs to the entrypoint, never to this reusable, + // unit-tested handler: arming a process.exit here would kill the test runner + // or any embedding host. The handler only reports what ran. + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + expect(outcome).toEqual({ headlessCompleted: true }); + }); + + it('reports no headless completion for interactive (shell) mode', async () => { + const opts = defaultOpts(); mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' }); mocks.runUpdatePreflight.mockResolvedValue('continue'); mocks.runShell.mockResolvedValue(void 0); - const exitCode = await runHandleMainCommand(opts); + const outcome = await handleMainCommand(opts, '0.0.1-alpha.2'); - expect(exitCode).toBeNull(); - expect(runUpdatePreflight).toHaveBeenCalledWith('0.0.1-alpha.2', { - track: expect.any(Function), - isTTY: false, + expect(outcome).toEqual({ headlessCompleted: false }); + expect(mocks.finalizeHeadlessRun).not.toHaveBeenCalled(); + }); + + it('arms the force-exit fallback at the entrypoint after a completed headless run', async () => { + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockResolvedValue(void 0); + mocks.finalizeHeadlessRun.mockResolvedValue(void 0); + + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.finalizeHeadlessRun).toHaveBeenCalledTimes(1); }); - expect(runShell).toHaveBeenCalledWith(opts, '0.0.1-alpha.2'); - expect(runPrompt).not.toHaveBeenCalled(); + // The exit code is resolved lazily so a goal turn that sets process.exitCode wins. + const forceExitArgs = mocks.finalizeHeadlessRun.mock.calls[0] as unknown as unknown[]; + expect(typeof forceExitArgs[2]).toBe('function'); + }); + + it('sets the failure exit code before awaiting startup failure logging', async () => { + const originalExitCode = process.exitCode; + const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' }; + mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' }); + mocks.runUpdatePreflight.mockResolvedValue('continue'); + mocks.runPrompt.mockRejectedValue(new Error('provider failed')); + mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {})); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + throw new ExitCalled(Number(code ?? 0)); + }); + + try { + main(); + const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[]; + const mainAction = programArgs[1] as (opts: CLIOptions) => void; + mainAction(opts); + + await waitForAssertion(() => { + expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1); + }); + expect(process.exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + process.exitCode = originalExitCode; + } }); it('keeps shell mode update preflight interactive by default', async () => { @@ -411,15 +359,6 @@ describe('main entry command handling', () => { expect(mocks.parse).toHaveBeenCalledWith(process.argv); }); - it('routes the internal update helper without parsing normal commands', () => { - mocks.dispatchUpdateHelperIfRequested.mockReturnValue(true); - - main(); - - expect(dispatchUpdateHelperIfRequested).toHaveBeenCalledOnce(); - expect(mocks.parse).not.toHaveBeenCalled(); - }); - it('sets the process title during startup', () => { const originalTitle = process.title; try { @@ -468,7 +407,7 @@ describe('main entry command handling', () => { firstLaunch: false, }, config: { - defaultModel: 'pythinker-k2', + defaultModel: 'kimi-k2', telemetry: true, }, version: '0.0.1-alpha.2', diff --git a/apps/pythinker-code/test/cli/mcp.test.ts b/apps/pythinker-code/test/cli/mcp.test.ts deleted file mode 100644 index 6c087510..00000000 --- a/apps/pythinker-code/test/cli/mcp.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Command } from 'commander'; -import { describe, expect, it, vi } from 'vitest'; - -import { registerMcpCommand } from '#/cli/sub/mcp'; - -describe('pythinker mcp serve', () => { - it('starts the built-in tool server for the current directory', async () => { - const runServer = vi.fn().mockResolvedValue(undefined); - const program = new Command('pythinker'); - registerMcpCommand(program, { - cwd: () => '/workspace', - version: () => '1.2.3', - runServer, - }); - - await program.parseAsync(['node', 'pythinker', 'mcp', 'serve', '--debug', '--verbose']); - - expect(runServer).toHaveBeenCalledWith({ - workDir: '/workspace', - version: '1.2.3', - debug: true, - verbose: true, - }); - }); -}); diff --git a/apps/pythinker-code/test/cli/options.test.ts b/apps/pythinker-code/test/cli/options.test.ts index 5f7575a1..ff96d353 100644 --- a/apps/pythinker-code/test/cli/options.test.ts +++ b/apps/pythinker-code/test/cli/options.test.ts @@ -1,8 +1,15 @@ +/** + * Scenario: top-level CLI option parsing, validation, and help discovery. + * Responsibilities: accepted arguments map to CLIOptions and invalid combinations fail early. + * Wiring: Commander is real; command handlers and output sinks are local test boundaries. + * Run: pnpm -C apps/pythinker-code exec vitest run test/cli/options.test.ts + */ + import { describe, expect, it } from 'vitest'; import { createProgram } from '#/cli/commands'; import type { CLIOptions } from '#/cli/options'; -import { OptionConflictError, validateOptions } from '#/cli/options'; +import { OptionConflictError, OUTPUT_FORMAT_ENV, resolveOutputFormat, validateOptions } from '#/cli/options'; function parse(argv: string[]): CLIOptions { let captured: CLIOptions | undefined; @@ -12,6 +19,7 @@ function parse(argv: string[]): CLIOptions { (opts) => { captured = opts; }, + () => {}, ); program.exitOverride(); @@ -39,33 +47,20 @@ describe('CLI options parsing', () => { expect(opts.model).toBeUndefined(); expect(opts.outputFormat).toBeUndefined(); expect(opts.prompt).toBeUndefined(); - expect(opts.rewindFiles).toBeUndefined(); - expect(opts.init).toBe(false); - expect(opts.initOnly).toBe(false); - expect(opts.maintenance).toBe(false); expect(opts.skillsDirs).toEqual([]); - expect(opts.additionalDirs).toEqual([]); + expect(opts.agent).toBeUndefined(); + expect(opts.agentFiles).toEqual([]); + expect(opts.addDirs).toEqual([]); }); }); - it('accepts the hidden maintenance setup flag', () => { - expect(parse(['--maintenance']).maintenance).toBe(true); - }); - - it('accepts the hidden init setup flag', () => { - expect(parse(['--init']).init).toBe(true); - }); - - it('accepts the hidden init-only setup flag', () => { - expect(parse(['--init-only']).initOnly).toBe(true); - }); - describe('--version', () => { it('prints the version string and exits', () => { let output = ''; const program = createProgram( '1.2.3', () => {}, + () => {}, ); program.exitOverride(); program.configureOutput({ @@ -74,7 +69,7 @@ describe('CLI options parsing', () => { }, }); - expect(() => program.parse(['node', 'pythinker', '--version'])).toThrowErrorMatchingInlineSnapshot(`[CommanderError: 1.2.3]`); + expect(() => program.parse(['node', 'pythinker', '--version'])).toThrow(); expect(output).toContain('1.2.3'); }); @@ -83,6 +78,7 @@ describe('CLI options parsing', () => { const program = createProgram( '4.5.6', () => {}, + () => {}, ); program.exitOverride(); program.configureOutput({ @@ -91,7 +87,7 @@ describe('CLI options parsing', () => { }, }); - expect(() => program.parse(['node', 'pythinker', '-V'])).toThrowErrorMatchingInlineSnapshot(`[CommanderError: 4.5.6]`); + expect(() => program.parse(['node', 'pythinker', '-V'])).toThrow(); expect(output).toContain('4.5.6'); }); }); @@ -104,6 +100,7 @@ describe('CLI options parsing', () => { () => { throw new Error('main action should not run'); }, + () => {}, (entry, args) => { pluginRunnerCalls.push({ entry, args }); }, @@ -163,22 +160,14 @@ describe('CLI options parsing', () => { expect(parse(['-S']).session).toBe(''); }); - it.each([ - ['-S', 'first', '-r', 'second'], - ['-r', 'first', '-S', 'second'], - ['-S', '-r'], - ])('rejects conflicting canonical and legacy session flags: %j', (...argv) => { - const opts = parse(argv); - expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow( - 'Cannot combine --session with --resume.', - ); - }); - it('-C sets continue', () => { expect(parse(['-C']).continue).toBe(true); }); + it('-c is an alias for --continue', () => { + expect(parse(['-c']).continue).toBe(true); + }); + it('--continue and --session combined raises a conflict', () => { const opts = parse(['--continue', '--session', 'abc123']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -186,55 +175,6 @@ describe('CLI options parsing', () => { }); }); - describe('--rewind-files', () => { - it('parses a persisted checkpoint ID', () => { - const opts = parse([ - '--resume', - 'session-1', - '--rewind-files', - 'checkpoint-1', - ]); - - expect(opts.rewindFiles).toBe('checkpoint-1'); - expect(validateOptions(opts).uiMode).toBe('print'); - }); - - it('requires a concrete resumed session', () => { - for (const argv of [ - ['--rewind-files', 'checkpoint-1'], - ['--resume', '--rewind-files', 'checkpoint-1'], - ['--continue', '--rewind-files', 'checkpoint-1'], - ]) { - expect(() => validateOptions(parse(argv))).toThrow( - '--rewind-files requires --resume with a session ID.', - ); - } - }); - - it('rejects an empty checkpoint ID', () => { - expect(() => - validateOptions( - parse(['--resume', 'session-1', '--rewind-files', ' ']), - ), - ).toThrow('Checkpoint ID for --rewind-files cannot be empty.'); - }); - - it('cannot be combined with a prompt', () => { - expect(() => - validateOptions( - parse([ - '--resume', - 'session-1', - '--rewind-files', - 'checkpoint-1', - '--prompt', - 'hello', - ]), - ), - ).toThrow('Cannot combine --rewind-files with --prompt.'); - }); - }); - describe('--plan', () => { it('sets plan mode flag', () => { expect(parse(['--plan']).plan).toBe(true); @@ -363,21 +303,6 @@ describe('CLI options parsing', () => { expect(opts.outputFormat).toBe('text'); }); - it('parses JSON result output and a structured output schema in prompt mode', () => { - const schema = '{"type":"object","properties":{"answer":{"type":"string"}}}'; - const opts = parse([ - '-p', - 'run this', - '--output-format', - 'json', - '--json-schema', - schema, - ]); - expect(opts.outputFormat).toBe('json'); - expect(opts.jsonSchema).toBe(schema); - expect(validateOptions(opts).uiMode).toBe('print'); - }); - it('rejects --output-format outside prompt mode', () => { const opts = parse(['--output-format=stream-json']); expect(() => validateOptions(opts)).toThrow(OptionConflictError); @@ -385,14 +310,84 @@ describe('CLI options parsing', () => { 'Output format is only supported in prompt mode.', ); }); + }); - it('rejects --json-schema outside prompt mode', () => { - const opts = parse(['--json-schema', '{"type":"object"}']); - expect(() => validateOptions(opts)).toThrow(OptionConflictError); - expect(() => validateOptions(opts)).toThrow( - 'JSON Schema is only supported in prompt mode.', + describe('PYTHINKER_MODEL_OUTPUT_FORMAT', () => { + it('defaults to text when unset in prompt mode', () => { + expect(resolveOutputFormat({ prompt: 'run this', outputFormat: undefined }, {})).toBe('text'); + }); + + it('uses stream-json from the env in prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('stream-json'); + }); + + it('uses text from the env in prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'text' }, + ), + ).toBe('text'); + }); + + it('trims surrounding whitespace from the env value', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: ' stream-json ' }, + ), + ).toBe('stream-json'); + }); + + it('lets the --output-format flag override the env', () => { + expect( + resolveOutputFormat( + { prompt: 'run this', outputFormat: 'text' }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('text'); + }); + + it('ignores the env outside prompt mode', () => { + expect( + resolveOutputFormat( + { prompt: undefined, outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'stream-json' }, + ), + ).toBe('text'); + }); + + it('rejects an invalid env value', () => { + expect(() => + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'json' }, + ), + ).toThrow(OptionConflictError); + expect(() => + resolveOutputFormat( + { prompt: 'run this', outputFormat: undefined }, + { [OUTPUT_FORMAT_ENV]: 'json' }, + ), + ).toThrow('Invalid PYTHINKER_MODEL_OUTPUT_FORMAT value "json"'); + }); + + it('fails validation fast for an invalid env value in prompt mode', () => { + const opts = parse(['-p', 'run this']); + expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).toThrow( + OptionConflictError, ); }); + + it('does not validate the env outside prompt mode', () => { + const opts = parse([]); + expect(() => validateOptions(opts, { [OUTPUT_FORMAT_ENV]: 'json' })).not.toThrow(); + }); }); describe('--skills-dir', () => { @@ -404,9 +399,126 @@ describe('CLI options parsing', () => { }); }); + describe('--agent / --agent-file', () => { + it('describes agent selectors as new-session-only', () => { + const help = createProgram('0.1.0-test', () => {}, () => {}).helpInformation(); + const normalizedHelp = help.replaceAll(/\s+/g, ' '); + + expect(normalizedHelp).toContain('Agent profile to start the new session with.'); + expect(normalizedHelp).not.toContain('print-mode invocation'); + }); + + it('parses a single --agent', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer']); + expect(opts.agent).toBe('reviewer'); + expect(opts.agentFiles).toEqual([]); + }); + + it('parses a single --agent-file', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); + expect(opts.agent).toBeUndefined(); + expect(opts.agentFiles).toEqual(['a.md']); + }); + + it('rejects repeated --agent', () => { + expect(() => parse(['-p', 'hi', '--agent', 'reviewer', '--agent', 'writer'])).toThrow( + '--agent may only be specified once.', + ); + }); + + it('rejects repeated --agent-file', () => { + expect(() => + parse(['-p', 'hi', '--agent-file', 'a.md', '--agent-file', 'b.md']), + ).toThrow('--agent-file may only be specified once.'); + }); + + it('rejects combining --agent with --agent-file', () => { + expect(() => + parse(['-p', 'hi', '--agent', 'reviewer', '--agent-file', 'reviewer.md']), + ).toThrow("option '--agent <name>' cannot be used with option '--agent-file <path>'"); + }); + + it('rejects multiple agent files passed directly to validation', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); + expect(() => validateOptions({ ...opts, agentFiles: ['a.md', 'b.md'] })).toThrow( + '--agent-file may only be specified once.', + ); + }); + + it('rejects mixed agent selectors passed directly to validation', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer']); + expect(() => validateOptions({ ...opts, agentFiles: ['reviewer.md'] })).toThrow( + 'Cannot combine --agent with --agent-file.', + ); + }); + + it('rejects --agent-file with --session', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent-file with --continue', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --session', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer', '--session', 'ses_123']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects --agent with --continue in shell mode', () => { + const opts = parse(['--agent', 'reviewer', '--continue']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow( + 'Cannot combine --agent/--agent-file with --session/--continue', + ); + }); + + it('rejects empty agent values', () => { + const opts = parse(['-p', 'hi', '--agent', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Agent cannot be empty.'); + }); + + it('rejects empty agent file values', () => { + const opts = parse(['-p', 'hi', '--agent-file', ' ']); + expect(() => validateOptions(opts)).toThrow(OptionConflictError); + expect(() => validateOptions(opts)).toThrow('Agent file path cannot be empty.'); + }); + + it('accepts the flags in shell mode', () => { + expect(validateOptions(parse(['--agent', 'reviewer']), {}).uiMode).toBe('shell'); + expect(validateOptions(parse(['--agent-file', 'a.md']), {}).uiMode).toBe('shell'); + }); + + it('accepts the flags in prompt mode on the default v2 engine', () => { + const opts = parse(['-p', 'hi', '--agent-file', 'a.md']); + expect(validateOptions(opts, {}).uiMode).toBe('print'); + }); + + it('accepts the flags in prompt mode with the legacy engine flag', () => { + const opts = parse(['-p', 'hi', '--agent', 'reviewer']); + expect(validateOptions(opts, { PYTHINKER_CODE_LEGACY_FLAG: '1' }).uiMode).toBe('print'); + }); + }); + describe('--add-dir', () => { - it('collects additional working directories', () => { - expect(parse(['--add-dir', '/one', '/two']).additionalDirs).toEqual(['/one', '/two']); + it('parses one additional workspace directory', () => { + expect(parse(['--add-dir', '/shared']).addDirs).toEqual(['/shared']); + }); + + it('parses repeated additional workspace directories', () => { + expect(parse(['--add-dir', '/one', '--add-dir=/two']).addDirs).toEqual(['/one', '/two']); }); }); @@ -419,6 +531,7 @@ describe('CLI options parsing', () => { throw new Error('main action should not run'); }, () => {}, + () => {}, () => { upgradeCalls += 1; }, @@ -434,10 +547,35 @@ describe('CLI options parsing', () => { expect(upgradeCalls).toBe(1); }); + it('routes update alias to the upgrade handler', () => { + let upgradeCalls = 0; + const program = createProgram( + '0.0.0', + () => { + throw new Error('main action should not run'); + }, + () => {}, + () => {}, + () => { + upgradeCalls += 1; + }, + ); + program.exitOverride(); + program.configureOutput({ + writeOut: () => {}, + writeErr: () => {}, + }); + + program.parse(['node', 'pythinker', 'update']); + + expect(upgradeCalls).toBe(1); + }); + it('registers the visible sub-commands', () => { const program = createProgram( '0.0.0', () => {}, + () => {}, ); const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) @@ -446,15 +584,16 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', - 'mcp', - 'server', 'web', + 'server', 'login', 'doctor', - 'dashboard', + 'vis', + 'migrate', 'upgrade', ]); }); + }); describe('rejected flags', () => { @@ -467,17 +606,15 @@ describe('CLI options parsing', () => { '--thinking', '--print', '--wire', - '--agent=default', '--raw-model', '--config-file=x', '--quiet', '--final-message-only', '--input-format=text', - '--agent-file=x', '--mcp-config={}', '--mcp-config-file=/', ]) { - expect(() => parse([arg])).toThrow(/unknown option/); + expect(() => parse([arg])).toThrow(); } }); }); diff --git a/apps/pythinker-code/test/cli/output.test.ts b/apps/pythinker-code/test/cli/output.test.ts deleted file mode 100644 index 1e4729c6..00000000 --- a/apps/pythinker-code/test/cli/output.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Writable } from 'node:stream'; - -import { describe, expect, it } from 'vitest'; - -import { drainWritable, writeAndDrain } from '#/cli/output'; - -class BlockedWritable extends Writable { - #blocked = true; - #releaseCurrent: (() => void) | undefined; - - override _write( - _chunk: Buffer, - _encoding: BufferEncoding, - callback: (error?: Error | null) => void, - ): void { - if (this.#blocked) { - this.#releaseCurrent = callback; - return; - } - callback(); - } - - release(): void { - this.#blocked = false; - this.#releaseCurrent?.(); - this.#releaseCurrent = undefined; - } -} - -class FailingWritable extends Writable { - constructor(private readonly failure: Error) { - super(); - } - - override _write( - _chunk: Buffer, - _encoding: BufferEncoding, - callback: (error?: Error | null) => void, - ): void { - callback(this.failure); - } -} - -function errorWithCode(message: string, code: string): Error & { code: string } { - return Object.assign(new Error(message), { code }); -} - -describe('CLI output flushing', () => { - it('waits for a write to finish even below the high-water mark', async () => { - const stream = new BlockedWritable({ highWaterMark: 1_024 }); - let settled = false; - - const pending = writeAndDrain(stream, 'fatal output').then(() => { - settled = true; - }); - await Promise.resolve(); - - expect(stream.writableNeedDrain).toBe(false); - expect(settled).toBe(false); - - stream.release(); - await pending; - expect(settled).toBe(true); - }); - - it('uses an ordering barrier for output already queued below the high-water mark', async () => { - const stream = new BlockedWritable({ highWaterMark: 1_024 }); - expect(stream.write('queued output')).toBe(true); - let settled = false; - - const pending = drainWritable(stream).then(() => { - settled = true; - }); - await Promise.resolve(); - - expect(stream.writableNeedDrain).toBe(false); - expect(settled).toBe(false); - - stream.release(); - await pending; - expect(settled).toBe(true); - }); - - it('consumes EPIPE from both the write callback and stream error event', async () => { - const stream = new FailingWritable(errorWithCode('closed output', 'EPIPE')); - const listenersBefore = stream.listenerCount('error'); - - await expect(writeAndDrain(stream, 'ignored')).resolves.toBeUndefined(); - - expect(stream.listenerCount('error')).toBe(listenersBefore); - }); - - it('rejects unexpected callback and error-event failures without leaking listeners', async () => { - const stream = new FailingWritable(errorWithCode('disk failure', 'EIO')); - const listenersBefore = stream.listenerCount('error'); - - await expect(writeAndDrain(stream, 'fatal output')).rejects.toThrow('disk failure'); - - expect(stream.listenerCount('error')).toBe(listenersBefore); - }); - - it('rejects an error event even when the write callback has not fired', async () => { - const stream = new BlockedWritable(); - const listenersBefore = stream.listenerCount('error'); - const pending = drainWritable(stream); - - stream.emit('error', errorWithCode('stream failed', 'EIO')); - - await expect(pending).rejects.toThrow('stream failed'); - expect(stream.listenerCount('error')).toBe(listenersBefore); - stream.release(); - }); -}); diff --git a/apps/pythinker-code/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index d03a9874..57478fa3 100644 --- a/apps/pythinker-code/test/cli/provider.test.ts +++ b/apps/pythinker-code/test/cli/provider.test.ts @@ -6,10 +6,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { Command } from 'commander'; -import { - DEFAULT_CATALOG_URL, - type PythinkerConfig, -} from '@pymodel/pythinker-code-sdk'; +import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; import { handleCatalogAdd, @@ -21,6 +18,30 @@ import { type ProviderDeps, } from '#/cli/sub/provider'; +// Spy on the SDK harness factories so the default-deps engine routing can be +// asserted without booting a real engine. The real implementations stay in +// place for everything else the handlers use. +const harnessRouting = vi.hoisted(() => ({ + pythinkerHarnessConstructor: vi.fn(), + pythinkerHarnessV2Constructor: vi.fn(), + harness: undefined as unknown, +})); + +vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@pymodel/pythinker-code-sdk')>(); + return { + ...actual, + createPythinkerHarness: (...args: unknown[]) => { + harnessRouting.pythinkerHarnessConstructor(...args); + return harnessRouting.harness; + }, + createPythinkerHarnessV2: (...args: unknown[]) => { + harnessRouting.pythinkerHarnessV2Constructor(...args); + return harnessRouting.harness; + }, + }; +}); + class ExitCalled extends Error { constructor(public readonly code: number) { super(`exit(${code})`); @@ -32,6 +53,7 @@ interface FakeHarness { getConfig: () => Promise<PythinkerConfig>; setConfig: (patch: Partial<PythinkerConfig>) => Promise<PythinkerConfig>; removeProvider: (providerId: string) => Promise<PythinkerConfig>; + close: () => Promise<void>; } function makeHarness(initial: PythinkerConfig): { @@ -81,11 +103,9 @@ function makeHarness(initial: PythinkerConfig): { } persisted = { ...persisted, providers: nextProviders, models: nextModels }; if (removedDefault) persisted = { ...persisted, defaultModel: undefined }; - if (persisted.defaultProvider === providerId) { - persisted = { ...persisted, defaultProvider: undefined }; - } return structuredClone(persisted); }, + close: async () => {}, }; return { harness, @@ -231,11 +251,6 @@ const CATALOG_BODY = { }, }; -const CATALOG_ENV = { - ANTHROPIC_API_KEY: 'test-anthropic-key', - OPENAI_API_KEY: 'test-openai-key', -}; - describe('pythinker provider add', () => { it('imports providers and models from a custom registry, persisting source on each provider', async () => { const fetchMock = mockRegistryFetch(); @@ -322,44 +337,6 @@ describe('pythinker provider add', () => { expect(current().models?.['kohub/claude-opus-4-7']).toBeDefined(); }); - it('preserves a still-valid default model when re-importing its provider', async () => { - mockRegistryFetch(); - const initial: PythinkerConfig = { - providers: { - kohub: { - type: 'anthropic', - baseUrl: 'https://registry.example.test', - apiKey: 'old', - }, - }, - models: { - 'kohub/claude-opus-4-7': { - provider: 'kohub', - model: 'claude-opus-4-7', - maxContextSize: 1024, - capabilities: ['tool_use'], - }, - }, - defaultProvider: 'kohub', - defaultModel: 'kohub/claude-opus-4-7', - defaultThinking: true, - } as unknown as PythinkerConfig; - const { harness, current, setConfigCalls } = makeHarness(initial); - const { deps, exitCodes } = makeDeps(harness); - - await tryRun(() => handleProviderAdd(deps, REGISTRY_URL, { apiKey: 'sk-new' })); - - expect(exitCodes).toEqual([]); - expect(current().defaultProvider).toBe('kohub'); - expect(current().defaultModel).toBe('kohub/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); - expect(setConfigCalls[0]).toMatchObject({ - defaultProvider: 'kohub', - defaultModel: 'kohub/claude-opus-4-7', - defaultThinking: true, - }); - }); - it('preserves newly-imported providers when a later registry entry replaces an existing id', async () => { // Regression test for the codex P1: `harness.removeProvider` re-reads // from disk on each call, so applying the loop body without flushing @@ -495,10 +472,10 @@ describe('pythinker provider list', () => { apiKey: 'k', source: { kind: 'apiJson', url: REGISTRY_URL, apiKey: 'k' }, }, - 'moonshot-cn': { + 'managed:pythinker-code': { type: 'pythinker', - baseUrl: 'https://api.moonshot.cn/v1', - apiKey: 'sk-moonshot', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/pythinker-code' }, }, manual: { type: 'openai', baseUrl: 'https://y', apiKey: 'm' }, }, @@ -533,7 +510,7 @@ describe('pythinker provider list', () => { const out = stdout.join(''); expect(out).toMatch(/kohub\s+type=anthropic\s+models=2\s+source=apiJson\(/); - expect(out).toMatch(/moonshot-cn\s+type=pythinker\s+models=0\s+source=inline/u); + expect(out).toMatch(/managed:pythinker-code\s+type=pythinker\s+models=0\s+source=oauth/); expect(out).toMatch(/manual\s+type=openai\s+models=1\s+source=inline/); expect(out).toContain('Default model: kohub/a'); }); @@ -559,8 +536,8 @@ describe('pythinker provider list', () => { }; expect(Object.keys(parsed.providers).toSorted()).toEqual([ 'kohub', + 'managed:pythinker-code', 'manual', - 'moonshot-cn', ]); expect(Object.keys(parsed.models)).toContain('kohub/a'); }); @@ -717,21 +694,21 @@ describe('pythinker provider catalog add', () => { }, }, defaultModel: 'other/main', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as PythinkerConfig; const { harness, current, setConfigCalls } = makeHarness(initial); - const { deps, stdout, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, stdout, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-ant-token' }), + ); expect(exitCodes).toEqual([]); const finalConfig = current(); expect(finalConfig.providers['anthropic']).toMatchObject({ type: 'anthropic', - apiKeyEnvVar: 'ANTHROPIC_API_KEY', - source: { kind: 'modelsDev', url: DEFAULT_CATALOG_URL }, + apiKey: 'sk-ant-token', }); - expect(finalConfig.providers['anthropic']?.apiKey).toBeUndefined(); // Catalog import populates the model aliases. expect(finalConfig.models?.['anthropic/claude-opus-4-7']).toMatchObject({ provider: 'anthropic', @@ -741,7 +718,7 @@ describe('pythinker provider catalog add', () => { // The unrelated provider's model survives, and remains the default. expect(finalConfig.models?.['other/main']).toBeDefined(); expect(finalConfig.defaultModel).toBe('other/main'); - expect(finalConfig.defaultThinking).toBe(true); + expect(finalConfig.thinking?.enabled).toBe(true); // The patch sent over `setConfig` must explicitly carry the preserved default. expect(setConfigCalls[0]?.defaultModel).toBe('other/main'); expect(stdout.join('')).toContain('Imported Anthropic (anthropic)'); @@ -752,10 +729,11 @@ describe('pythinker provider catalog add', () => { const { harness, current, setConfigCalls } = makeHarness({ providers: {}, } as PythinkerConfig); - const { deps, stdout, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, stdout, exitCodes } = makeDeps(harness); await tryRun(() => handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', defaultModel: 'claude-opus-4-7', }), ); @@ -769,10 +747,11 @@ describe('pythinker provider catalog add', () => { it('rejects an unknown --default-model with a helpful hint', async () => { mockRegistryFetch(CATALOG_BODY); const { harness } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, stderr, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, stderr, exitCodes } = makeDeps(harness); await tryRun(() => handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant-token', defaultModel: 'does-not-exist', }), ); @@ -787,15 +766,15 @@ describe('pythinker provider catalog add', () => { // Regression test for the codex P2: `removeProvider` clears // `defaultModel` if it pointed at one of the provider's aliases. The // handler must capture the previous default BEFORE calling - // `removeProvider`, otherwise refreshing an already-configured provider - // would silently wipe the user's chosen default. + // `removeProvider`, otherwise rotating the api key on an already- + // configured provider would silently wipe the user's chosen default. mockRegistryFetch(CATALOG_BODY); const initial: PythinkerConfig = { providers: { anthropic: { type: 'anthropic', baseUrl: 'https://api.anthropic.com', - apiKeyEnvVar: 'ANTHROPIC_API_KEY', + apiKey: 'sk-old', }, }, models: { @@ -806,66 +785,68 @@ describe('pythinker provider catalog add', () => { capabilities: ['tool_use', 'thinking', 'image_in'], }, }, - defaultProvider: 'anthropic', defaultModel: 'anthropic/claude-opus-4-7', - defaultThinking: true, + thinking: { enabled: true }, } as unknown as PythinkerConfig; const { harness, current } = makeHarness(initial); - const { deps, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBe('ANTHROPIC_API_KEY'); - // Previous provider/model defaults and thinking flag must survive the re-import. - expect(current().defaultProvider).toBe('anthropic'); + expect(current().providers['anthropic']?.apiKey).toBe('sk-rotated'); + // Previous default and thinking flag must survive the re-import. expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); }); - it('preserves default_thinking when --default-model is supplied to a thinking-capable model', async () => { + it('preserves thinking.enabled when --default-model is supplied to a thinking-capable model', async () => { // Regression test for the codex P2: `applyCatalogProvider` always - // assigns `defaultThinking` from `options.thinking`. Hardcoding `false` + // assigns `thinking.enabled` from `options.thinking`. Hardcoding `false` // silently disabled thinking even when the user previously had it on // and is just importing a known provider. The handler now threads the // previous value through. mockRegistryFetch(CATALOG_BODY); const initial: PythinkerConfig = { providers: {}, - defaultThinking: true, + thinking: { enabled: true }, } as unknown as PythinkerConfig; const { harness, current, setConfigCalls } = makeHarness(initial); - const { deps, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, exitCodes } = makeDeps(harness); await tryRun(() => handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', defaultModel: 'claude-opus-4-7', }), ); expect(exitCodes).toEqual([]); expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); - expect(current().defaultThinking).toBe(true); - expect(setConfigCalls[0]?.defaultThinking).toBe(true); + expect(current().thinking?.enabled).toBe(true); + expect(setConfigCalls[0]?.thinking?.enabled).toBe(true); }); - it('does not persist default_thinking=false for first-time setup with --default-model', async () => { + it('does not persist thinking.enabled=false for first-time setup with --default-model', async () => { // Regression test for codex P2 follow-up: previously the handler fell - // back to `false` when `defaultThinking` was unset, but - // `resolveThinkingLevel` treats `defaultThinking === false` as an + // back to `false` when `thinking.enabled` was unset, but + // `resolveThinkingEffort` treats `thinking.enabled === false` as an // explicit "off" request. A fresh `pythinker provider catalog add // anthropic --default-model claude-opus-4-7` must NOT silently disable - // thinking — it should leave `defaultThinking` unset so the runtime + // thinking — it should leave `thinking.enabled` unset so the runtime // uses the per-model default. mockRegistryFetch(CATALOG_BODY); - // Note: `defaultThinking` is omitted on purpose to model a fresh user. + // Note: `thinking.enabled` is omitted on purpose to model a fresh user. const { harness, current, setConfigCalls } = makeHarness({ providers: {}, } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, exitCodes } = makeDeps(harness); await tryRun(() => handleCatalogAdd(deps, 'anthropic', { + apiKey: 'sk-ant', defaultModel: 'claude-opus-4-7', }), ); @@ -874,8 +855,8 @@ describe('pythinker provider catalog add', () => { expect(current().defaultModel).toBe('anthropic/claude-opus-4-7'); // Must NOT be `false`. `undefined` lets the runtime resolver pick the // per-model default; `false` would force `'off'`. - expect(current().defaultThinking).toBeUndefined(); - expect(setConfigCalls[0]?.defaultThinking).toBeUndefined(); + expect(current().thinking?.enabled).toBeUndefined(); + expect(setConfigCalls[0]?.thinking?.enabled).toBeUndefined(); }); it('drops a stale default_model when the catalog refresh no longer contains it', async () => { @@ -891,7 +872,7 @@ describe('pythinker provider catalog add', () => { anthropic: { type: 'anthropic', baseUrl: 'https://api.anthropic.com', - apiKeyEnvVar: 'ANTHROPIC_API_KEY', + apiKey: 'sk-old', }, }, models: { @@ -905,9 +886,11 @@ describe('pythinker provider catalog add', () => { defaultModel: 'anthropic/legacy-claude', } as unknown as PythinkerConfig; const { harness, current } = makeHarness(initial); - const { deps, exitCodes } = makeDeps(harness, { env: CATALOG_ENV }); + const { deps, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + await tryRun(() => + handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-rotated' }), + ); expect(exitCodes).toEqual([]); // The legacy alias must have been replaced by the catalog's models. @@ -918,54 +901,116 @@ describe('pythinker provider catalog add', () => { expect(current().defaultModel).toBeUndefined(); }); - it('allows an explicit API key environment-variable override', async () => { + it('falls back to PYTHINKER_REGISTRY_API_KEY when --api-key is omitted', async () => { mockRegistryFetch(CATALOG_BODY); const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); const { deps, exitCodes } = makeDeps(harness, { - env: { CUSTOM_OPENAI_API_KEY: 'test-openai-key' }, + env: { PYTHINKER_REGISTRY_API_KEY: 'sk-env' }, }); + await tryRun(() => handleCatalogAdd(deps, 'openai', {})); + + expect(exitCodes).toEqual([]); + expect(current().providers['openai']).toMatchObject({ apiKey: 'sk-env' }); + }); + + it('lets --base-url override the catalog-declared endpoint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness); + await tryRun(() => - handleCatalogAdd(deps, 'openai', { apiKeyEnv: 'CUSTOM_OPENAI_API_KEY' }), + handleCatalogAdd(deps, 'openai', { + apiKey: 'sk-o', + baseUrl: 'https://proxy.example.test/v1', + }), ); expect(exitCodes).toEqual([]); expect(current().providers['openai']).toMatchObject({ - apiKeyEnvVar: 'CUSTOM_OPENAI_API_KEY', + type: 'openai', + baseUrl: 'https://proxy.example.test/v1', + }); + }); + + it('strips a trailing /v1 from --base-url for Anthropic-wire imports', async () => { + mockRegistryFetch({ + 'claude-gateway': { + id: 'claude-gateway', + name: 'Claude Gateway', + npm: '@custom/claude-gateway', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + }); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness); + + await tryRun(() => + handleCatalogAdd(deps, 'claude-gateway', { + apiKey: 'sk-gw', + baseUrl: 'https://claude-gateway.example.test/v1', + }), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['claude-gateway']).toMatchObject({ + type: 'anthropic', + // The Anthropic SDK appends /v1/messages itself — persisting the /v1 + // would double it (/v1/v1/messages). + baseUrl: 'https://claude-gateway.example.test', }); - expect(current().providers['openai']?.apiKey).toBeUndefined(); }); - it.each([{}, { ANTHROPIC_API_KEY: ' ' }])( - 'exits 1 when the referenced API key is missing or blank', - async (env) => { - const fetchMock = mockRegistryFetch(CATALOG_BODY); - const { harness } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, stderr, exitCodes } = makeDeps(harness, { env }); + it('rejects an empty --base-url instead of persisting a blank endpoint', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); + await tryRun(() => handleCatalogAdd(deps, 'openai', { apiKey: 'sk-o', baseUrl: ' ' })); - expect(exitCodes).toEqual([1]); - expect(stderr.join('')).toContain( - 'Environment variable "ANTHROPIC_API_KEY" is not set or is empty.', - ); - expect(fetchMock).toHaveBeenCalledOnce(); - }, - ); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url cannot be empty'); + await expect(harness.getConfig().then((c) => c.providers['openai'])).resolves.toBeUndefined(); + }); - it('exits 1 when the catalog does not declare a credential name', async () => { + it('requires --base-url for a non-official Anthropic-compatible vendor without one', async () => { mockRegistryFetch({ - anthropic: { ...CATALOG_BODY.anthropic, env: undefined }, + 'claude-gateway': { + id: 'claude-gateway', + name: 'Claude Gateway', + npm: '@custom/claude-gateway', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + }); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'claude-gateway', { apiKey: 'sk-gw' })); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + + await tryRun(() => + handleCatalogAdd(deps, 'claude-gateway', { + apiKey: 'sk-gw', + baseUrl: 'https://claude-gateway.example.test', + }), + ); + expect(current().providers['claude-gateway']).toMatchObject({ + type: 'anthropic', + baseUrl: 'https://claude-gateway.example.test', }); + }); + + it('exits 1 when the api key is missing and skips the network', async () => { + const fetchMock = mockRegistryFetch(CATALOG_BODY); const { harness } = makeHarness({ providers: {} } as PythinkerConfig); const { deps, stderr, exitCodes } = makeDeps(harness); await tryRun(() => handleCatalogAdd(deps, 'anthropic', {})); expect(exitCodes).toEqual([1]); - expect(stderr.join('')).toContain( - 'Provider "anthropic" does not declare an API key environment variable.', - ); + expect(stderr.join('')).toMatch(/missing api key/i); + expect(fetchMock).not.toHaveBeenCalled(); }); it('exits 1 when the providerId is missing from the catalog', async () => { @@ -973,99 +1018,149 @@ describe('pythinker provider catalog add', () => { const { harness } = makeHarness({ providers: {} } as PythinkerConfig); const { deps, stderr, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'no-such-id', {})); + await tryRun(() => + handleCatalogAdd(deps, 'no-such-id', { apiKey: 'sk-x' }), + ); expect(exitCodes).toEqual([1]); expect(stderr.join('')).toContain('Provider "no-such-id" not found in catalog'); }); - it('routes --api-key-env through Commander', async () => { - mockRegistryFetch(CATALOG_BODY); + const GUESS_CATALOG_BODY = { + xai: { + id: 'xai', + name: 'xAI', + npm: '@ai-sdk/xai', + env: ['XAI_API_KEY'], + models: { + 'grok-4': { + id: 'grok-4', + limit: { context: 256_000 }, + reasoning: true, + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'medium', 'high'] }], + }, + }, + }, + bedrock: { + id: 'amazon-bedrock', + name: 'Amazon Bedrock', + npm: '@ai-sdk/amazon-bedrock', + models: { 'claude-x': { id: 'claude-x', limit: { context: 1000 } } }, + }, + azure: { + id: 'azure', + name: 'Azure', + npm: '@ai-sdk/azure', + env: ['AZURE_API_KEY'], + models: { 'gpt-x': { id: 'gpt-x', limit: { context: 1000 } } }, + }, + }; + + it('guesses openai for a vendor-specific SDK and requires --base-url', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'xai', { apiKey: 'sk-xai' })); + + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + await expect(harness.getConfig().then((c) => c.providers['xai'])).resolves.toBeUndefined(); + }); + + it('imports a guessed vendor with --base-url, carrying off_effort and a guess note', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { - env: { CUSTOM_ANTHROPIC_API_KEY: 'test-anthropic-key' }, - }); - const program = new Command('pythinker'); - registerProviderCommand(program, deps); + const { deps, stdout, exitCodes } = makeDeps(harness); await tryRun(() => - program.parseAsync( - [ - 'node', - 'pythinker', - 'provider', - 'catalog', - 'add', - 'anthropic', - '--api-key-env', - 'CUSTOM_ANTHROPIC_API_KEY', - ], - { from: 'node' }, - ), + handleCatalogAdd(deps, 'xai', { apiKey: 'sk-xai', baseUrl: 'https://api.x.ai/v1' }), ); expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBe( - 'CUSTOM_ANTHROPIC_API_KEY', - ); + expect(current().providers['xai']).toMatchObject({ + type: 'openai', + baseUrl: 'https://api.x.ai/v1', + apiKey: 'sk-xai', + }); + expect(current().models?.['xai/grok-4']).toMatchObject({ + supportEfforts: ['low', 'medium', 'high'], + offEffort: 'none', + }); + expect(stdout.join('')).toContain('guessed "openai"'); }); - it('stores a literal --api-key when the environment variable is unset', async () => { - mockRegistryFetch(CATALOG_BODY); - const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { env: {} }); + it('refuses a proprietary SDK (bedrock) instead of guessing', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); + const { harness } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, stderr, exitCodes } = makeDeps(harness); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); + await tryRun(() => handleCatalogAdd(deps, 'bedrock', { apiKey: 'sk-x' })); - expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('proprietary'); }); - it('prefers a literal --api-key over a set environment variable', async () => { - mockRegistryFetch(CATALOG_BODY); + it('requires --base-url for a vendor with no catalog endpoint (azure shape)', async () => { + mockRegistryFetch(GUESS_CATALOG_BODY); const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { - env: { ANTHROPIC_API_KEY: 'from-env' }, + const { deps, stderr, exitCodes } = makeDeps(harness); + + await tryRun(() => handleCatalogAdd(deps, 'azure', { apiKey: 'sk-az' })); + expect(exitCodes).toEqual([1]); + expect(stderr.join('')).toContain('--base-url'); + + await tryRun(() => + handleCatalogAdd(deps, 'azure', { apiKey: 'sk-az', baseUrl: 'https://res.example.test/openai/v1' }), + ); + expect(current().providers['azure']).toMatchObject({ + type: 'openai', + baseUrl: 'https://res.example.test/openai/v1', }); + }); +}); - await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); +describe('pythinker provider engine routing', () => { + beforeEach(() => { + harnessRouting.pythinkerHarnessConstructor.mockClear(); + harnessRouting.pythinkerHarnessV2Constructor.mockClear(); + harnessRouting.harness = makeHarness({ providers: {} } as PythinkerConfig).harness; + }); - expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + afterEach(() => { + vi.unstubAllEnvs(); }); - it('stores a literal --api-key even when the catalog declares no credential name', async () => { - mockRegistryFetch({ - anthropic: { ...CATALOG_BODY.anthropic, env: undefined }, + function registerWithDefaultHarness(program: Command): void { + registerProviderCommand(program, { + stdout: { write: () => true }, + stderr: { write: () => true }, + env: {}, + exit: ((code: number) => { + throw new ExitCalled(code); + }) as ProviderDeps['exit'], }); - const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { env: {} }); + } - await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); + it('builds the v2 harness by default', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); + const program = new Command('pythinker'); + registerWithDefaultHarness(program); - expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + await program.parseAsync(['node', 'pythinker', 'provider', 'list'], { from: 'node' }); + + expect(harnessRouting.pythinkerHarnessV2Constructor).toHaveBeenCalledTimes(1); + expect(harnessRouting.pythinkerHarnessConstructor).not.toHaveBeenCalled(); }); - it('routes --api-key through Commander', async () => { - mockRegistryFetch(CATALOG_BODY); - const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); - const { deps, exitCodes } = makeDeps(harness, { env: {} }); + it('builds the legacy harness when the legacy flag is truthy', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); const program = new Command('pythinker'); - registerProviderCommand(program, deps); + registerWithDefaultHarness(program); - await tryRun(() => - program.parseAsync( - ['node', 'pythinker', 'provider', 'catalog', 'add', 'anthropic', '--api-key', 'sk-flag'], - { from: 'node' }, - ), - ); + await program.parseAsync(['node', 'pythinker', 'provider', 'list'], { from: 'node' }); - expect(exitCodes).toEqual([]); - expect(current().providers['anthropic']?.apiKey).toBe('sk-flag'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + expect(harnessRouting.pythinkerHarnessConstructor).toHaveBeenCalledTimes(1); + expect(harnessRouting.pythinkerHarnessV2Constructor).not.toHaveBeenCalled(); }); }); diff --git a/apps/pythinker-code/test/cli/run-prompt.test.ts b/apps/pythinker-code/test/cli/run-prompt.test.ts index 5ccb6004..e69cffc7 100644 --- a/apps/pythinker-code/test/cli/run-prompt.test.ts +++ b/apps/pythinker-code/test/cli/run-prompt.test.ts @@ -1,7 +1,19 @@ +/** + * Scenario: print-mode session startup and resume routing. + * Responsibilities: CLI options are translated into the SDK session contract and output is rendered. + * Wiring: the SDK/telemetry/process boundaries are mocked; the print driver is real. + * Run: pnpm -C apps/pythinker-code exec vitest run test/cli/run-prompt.test.ts + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import type { createPythinkerDeviceId as createPythinkerDeviceIdFn } from '@pymodel/pythinker-code-oauth'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runPrompt } from '#/cli/run-prompt'; +import { PROMPT_CLEANUP_TIMEOUT_MS } from '#/constant/app'; type CreatePythinkerDeviceId = typeof createPythinkerDeviceIdFn; @@ -19,13 +31,6 @@ const mocks = vi.hoisted(() => { setPermission: vi.fn(), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), - addWorkspaceDirectory: vi.fn(async (path: string) => ({ path, source: 'session' as const })), - restoreFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-1', - recoveryCheckpointId: 'recovery-1', - restoredPaths: ['/workspace/a.ts'], - deletedPaths: ['/workspace/new.ts'], - })), getStatus: vi.fn( async (): Promise<{ readonly permission: string; readonly model?: string }> => ({ permission: 'manual', @@ -45,6 +50,10 @@ const mocks = vi.hoisted(() => { handler(mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); } }), + waitForBackgroundTasksOnPrint: vi.fn(async () => {}), + getGoal: vi.fn(async () => ({ goal: null })), + getCronTasks: vi.fn(async () => ({ tasks: [] })), + handlePrintMainTurnCompleted: vi.fn(async (): Promise<'finish' | 'continue'> => 'finish'), }; return { @@ -69,6 +78,42 @@ const mocks = vi.hoisted(() => { harnessClose: vi.fn(), harnessTrack: vi.fn(), harnessGetCachedAccessToken: vi.fn(), + runV2Print: vi.fn( + async ( + opts: { readonly outputFormat?: string }, + version: string, + io?: { + readonly stdout?: { write(chunk: string): boolean }; + readonly stderr?: { write(chunk: string): boolean }; + }, + ) => { + // Mirror the native runner's output protocol so the version-banner + // assertions stay meaningful: version first, then the assistant + // message, then the resume hint — in the active output format. + const stdout = io?.stdout ?? process.stdout; + const stderr = io?.stderr ?? process.stderr; + const outputFormat = opts?.outputFormat ?? 'text'; + if (outputFormat === 'stream-json') { + stdout.write( + `${JSON.stringify({ role: 'meta', type: 'system.version', version })}\n`, + ); + stdout.write(`${JSON.stringify({ role: 'assistant', content: 'hello world' })}\n`); + stdout.write( + `${JSON.stringify({ + role: 'meta', + type: 'session.resume_hint', + session_id: 'ses_prompt', + command: 'pythinker -r ses_prompt', + content: 'To resume this session: pythinker -r ses_prompt', + })}\n`, + ); + return; + } + stderr.write(`pythinker version ${version}\n`); + stdout.write('• hello world\n\n'); + stderr.write('To resume this session: pythinker -r ses_prompt\n'); + }, + ), initializeTelemetry: vi.fn(), setCrashPhase: vi.fn(), shutdownTelemetry: vi.fn(), @@ -118,6 +163,7 @@ vi.mock('@pymodel/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, + PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', }; }); @@ -130,6 +176,13 @@ vi.mock('@pymodel/pythinker-telemetry', () => ({ withTelemetryContext: mocks.withTelemetryContext, })); +// The v2 engine is loaded via a dynamic import from run-prompt.ts when the +// legacy engine flag is absent. Mock the native v2 runner so routing tests can +// exercise the dispatch without pulling in the real agent-core-v2 graph. +vi.mock('../../src/cli/v2/run-v2-print', () => ({ + runV2Print: mocks.runV2Print, +})); + function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { return { session: undefined, @@ -139,10 +192,11 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) { plan: false, model: undefined, outputFormat: undefined, - jsonSchema: undefined, prompt: 'say hello', - rewindFiles: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], + addDirs: [], ...overrides, }; } @@ -155,7 +209,6 @@ function writer(columns?: number) { text += chunk; return true; }), - flush: vi.fn(async () => {}), text: () => text, }; } @@ -163,7 +216,7 @@ function writer(columns?: number) { function fakeProcess() { const listeners = new Map<NodeJS.Signals, () => Promise<void> | void>(); return { - on: vi.fn((signal: NodeJS.Signals, listener: () => Promise<void> | void) => { + once: vi.fn((signal: NodeJS.Signals, listener: () => Promise<void> | void) => { listeners.set(signal, listener); }), off: vi.fn((signal: NodeJS.Signals, listener: () => Promise<void> | void) => { @@ -191,8 +244,17 @@ async function waitForAssertion(assertion: () => void): Promise<void> { } describe('runPrompt', () => { + beforeEach(() => { + // Pin the legacy engine for the SDK-mocked cases. The v2 routing cases below + // clear this flag explicitly. + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', ''); + vi.stubEnv('PYTHINKER_MODEL_OUTPUT_FORMAT', ''); + }); + afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); mocks.eventHandlers.clear(); mocks.createPythinkerDeviceId.mockImplementation(() => 'device-1'); mocks.resolvePythinkerHome.mockImplementation( @@ -214,51 +276,133 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'k2', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.session.setPermission).not.toHaveBeenCalled(); expect(mocks.session.setApprovalHandler).toHaveBeenCalledWith(expect.any(Function)); expect(mocks.session.setQuestionHandler).toHaveBeenCalledWith(expect.any(Function)); - expect(mocks.session.prompt).toHaveBeenCalledWith('say hello', { - outputSchema: undefined, - }); + expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); expect(stdout.text()).toBe('• hello world\n\n'); expect(stderr.text()).toBe('To resume this session: pythinker -r ses_prompt\n'); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'ses_prompt' }), + ); expect(mocks.shutdownTelemetry).toHaveBeenCalled(); expect(mocks.harnessClose).toHaveBeenCalled(); }); - it('passes the maintenance Setup trigger into prompt session startup', async () => { - await runPrompt(opts({ maintenance: true }), '1.2.3-test', { - stdout: writer(), - stderr: writer(), - }); - - expect(mocks.harnessCreateSession).toHaveBeenCalledWith( - expect.objectContaining({ setupTrigger: 'maintenance' }), + it('selects the profile declared by an explicit agent file for a fresh v1 session', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-run-prompt-agent-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: reviewer\ndescription: Reviews code.\n---\n\nReview the requested change.\n', + 'utf-8', ); + + try { + await runPrompt(opts({ agentFiles: [agentFile] }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith( + expect.objectContaining({ + agentProfile: 'reviewer', + agentFiles: [agentFile], + }), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); - it('passes the init Setup trigger into prompt session startup', async () => { - await runPrompt(opts({ init: true }), '1.2.3-test', { - stdout: writer(), - stderr: writer(), - }); + it('completes even if harness.close() never resolves (cleanup is time-bounded)', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Simulate a shutdown step that hangs (e.g. a wedged SessionEnd hook or a + // blackholed connection in a firewalled sandbox). A completed headless run + // must not stay alive forever waiting on cleanup. + mocks.harnessClose.mockReturnValueOnce(new Promise<void>(() => {})); + + let settled = false; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then(() => { + settled = true; + }); - expect(mocks.harnessCreateSession).toHaveBeenCalledWith( - expect.objectContaining({ setupTrigger: 'init' }), - ); + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + + expect(settled).toBe(true); + expect(mocks.harnessClose).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } }); - it('applies additional working directories before running the prompt', async () => { + it('propagates a cleanup failure that settles before the timeout', async () => { const stdout = writer(); const stderr = writer(); + // A cleanup step that fails fast (e.g. a permission restore or harness close + // hitting a persistence error) must surface — not be silently swallowed by + // the timeout guard — otherwise the run reports success while shutdown + // actually failed (e.g. a resumed session left in `auto`). + mocks.harnessClose.mockRejectedValueOnce(new Error('close failed')); - await runPrompt(opts({ additionalDirs: ['/tmp/extra'] }), '1.2.3-test', { - stdout, - stderr, - }); + await expect( + runPrompt(opts(), '1.2.3-test', { stdout, stderr, process: fakeProcess() }), + ).rejects.toThrow('close failed'); + }); - expect(mocks.session.addWorkspaceDirectory).toHaveBeenCalledWith('/tmp/extra'); + it('ignores a cleanup rejection that lands after the timeout', async () => { + vi.useFakeTimers(); + try { + const stdout = writer(); + const stderr = writer(); + // Cleanup overruns the bound and only rejects later. The run already gave + // up waiting and resolved; that late rejection must not flip it to a + // failure (nor surface as an unhandled rejection). + mocks.harnessClose.mockReturnValueOnce( + new Promise<void>((_, reject) => { + const timer = setTimeout( + () => reject(new Error('late close')), + PROMPT_CLEANUP_TIMEOUT_MS + 5000, + ); + timer.unref?.(); + }), + ); + + let settled: 'resolved' | 'rejected' | undefined; + const done = runPrompt(opts(), '1.2.3-test', { + stdout, + stderr, + process: fakeProcess(), + }).then( + () => { + settled = 'resolved'; + }, + () => { + settled = 'rejected'; + }, + ); + + await vi.advanceTimersByTimeAsync(PROMPT_CLEANUP_TIMEOUT_MS + 100); + await done; + expect(settled).toBe('resolved'); + + await vi.advanceTimersByTimeAsync(5000); + await Promise.resolve(); + expect(settled).toBe('resolved'); + } finally { + vi.useRealTimers(); + } }); it('stops prompt startup when session creation fails', async () => { @@ -287,12 +431,29 @@ describe('runPrompt', () => { workDir: process.cwd(), model: 'pythinker-code/k2.5', permission: 'auto', + additionalDirs: undefined, + drainAgentTasksOnStop: true, }); expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ model: 'pythinker-code/k2.5' }), ); }); + it('passes the CLI additional directory when creating a fresh prompt session', async () => { + await runPrompt(opts({ addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ + workDir: process.cwd(), + model: 'k2', + permission: 'auto', + additionalDirs: ['../shared', '/tmp/extra'], + drainAgentTasksOnStop: true, + }); + }); + it('tracks first launch in prompt mode before harness construction can create the device id', async () => { mocks.harnessCreatesDeviceIdOnConstruction = true; const createdHomes = new Set<string>(); @@ -487,32 +648,32 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); - it('rewinds a resumed session without starting a prompt turn', async () => { - const stdout = writer(); - const stderr = writer(); - + it('passes the CLI additional directories when resuming a concrete session', async () => { await runPrompt( - opts({ - session: 'session-1', - prompt: undefined, - rewindFiles: 'checkpoint-1', - }), + opts({ session: 'ses_existing', addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', - { stdout, stderr }, + { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }, ); - expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'session-1' }); - expect(mocks.session.restoreFileCheckpoint).toHaveBeenCalledWith('checkpoint-1'); - expect(mocks.session.getStatus).not.toHaveBeenCalled(); - expect(mocks.session.setPermission).not.toHaveBeenCalled(); - expect(mocks.session.onEvent).not.toHaveBeenCalled(); - expect(mocks.session.prompt).not.toHaveBeenCalled(); - expect(stdout.text()).toBe( - 'Files rewound to checkpoint checkpoint-1.\n' + - 'Recovery checkpoint: recovery-1.\n' + - 'Restored: 1. Deleted: 1.\n', - ); - expect(stderr.text()).toBe(''); + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_existing', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + + it('does not forward an agent profile when resuming a concrete v1 session', async () => { + // validateOptions rejects --agent with --session; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ session: 'ses_existing', agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_existing' }); }); it('allows resuming a concrete session when Windows workdir uses backslashes', async () => { @@ -610,118 +771,138 @@ describe('runPrompt', () => { ); }); - it('writes one JSON result with validated structured output', async () => { + it('emits a stream-json meta line on retry and discards the failed attempt output', async () => { mocks.session.prompt.mockImplementationOnce(async () => { for (const handler of mocks.eventHandlers) { - handler( - mocks.mainEvent({ type: 'turn.started', turnId: 9, origin: { kind: 'user' } }), - ); - handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'working' })); - handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 9, step: 2 })); - handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'done' })); + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'partial attempt' })); handler( mocks.mainEvent({ - type: 'turn.ended', - turnId: 9, - reason: 'completed', - structuredOutput: { answer: 'done' }, + type: 'turn.step.retrying', + turnId: 10, + step: 1, + stepId: 'step-uuid', + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + delayMs: 300, + errorName: 'APIProviderRateLimitError', + errorMessage: 'llmproxy/openai/responses/resp_abc.json status_code=429', + statusCode: 429, }), ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); } }); const stdout = writer(); const stderr = writer(); - const schema = '{"type":"object","properties":{"answer":{"type":"string"}}}'; - await runPrompt( - opts({ outputFormat: 'json', jsonSchema: schema }), - '1.2.3-test', - { stdout, stderr }, - ); + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { stdout, stderr }); - expect(mocks.session.prompt).toHaveBeenCalledWith('say hello', { - outputSchema: { - type: 'object', - properties: { answer: { type: 'string' } }, - }, + const retryMeta = JSON.stringify({ + role: 'meta', + type: 'turn.step.retrying', + failed_attempt: 1, + next_attempt: 2, + max_attempts: 3, + delay_ms: 300, + error_name: 'APIProviderRateLimitError', + error_message: 'llmproxy/openai/responses/resp_abc.json status_code=429', + status_code: 429, }); expect(stdout.text()).toBe( - '{"type":"result","subtype":"success","is_error":false,"result":"done","structured_output":{"answer":"done"},"session_id":"ses_prompt"}\n', + [ + retryMeta, + '{"role":"assistant","content":"final answer"}', + '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"pythinker -r ses_prompt","content":"To resume this session: pythinker -r ses_prompt"}', + '', + ].join('\n'), ); - expect(stderr.text()).toBe('To resume this session: pythinker -r ses_prompt\n'); + // The failed attempt's partial text must not leak as an assistant line. + expect(stdout.text()).not.toContain('partial attempt'); + expect(stderr.text()).toBe(''); }); - it('writes a terminal structured result in stream-json mode', async () => { + it('flushes stream-json assistant output before waiting for background tasks', async () => { + let releaseWait: () => void = () => {}; + const waitGate = new Promise<void>((resolve) => { + releaseWait = resolve; + }); + mocks.session.waitForBackgroundTasksOnPrint.mockImplementationOnce(async () => waitGate); + mocks.session.prompt.mockImplementationOnce(async () => { for (const handler of mocks.eventHandlers) { - handler( - mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } }), - ); - handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'working' })); - handler(mocks.mainEvent({ type: 'turn.step.started', turnId: 10, step: 2 })); - handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'done' })); - handler( - mocks.mainEvent({ - type: 'turn.ended', - turnId: 10, - reason: 'completed', - structuredOutput: { answer: 'done' }, - }), - ); + handler(mocks.mainEvent({ type: 'turn.started', turnId: 9, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 9, delta: 'final answer' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 9, reason: 'completed' })); } }); + const stdout = writer(); const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); - await runPrompt( - opts({ - outputFormat: 'stream-json', - jsonSchema: '{"type":"object"}', - }), - '1.2.3-test', - { stdout, stderr }, - ); + // The assistant message must be flushed even while the background wait is pending. + await waitForAssertion(() => { + expect(stdout.text()).toContain('{"role":"assistant","content":"final answer"}'); + }); - expect(stdout.text()).toBe( - [ - '{"role":"assistant","content":"working"}', - '{"role":"assistant","content":"done"}', - '{"type":"result","subtype":"success","is_error":false,"result":"done","structured_output":{"answer":"done"},"session_id":"ses_prompt"}', - '{"role":"meta","type":"session.resume_hint","session_id":"ses_prompt","command":"pythinker -r ses_prompt","content":"To resume this session: pythinker -r ses_prompt"}', - '', - ].join('\n'), - ); - expect(stderr.text()).toBe(''); + releaseWait(); + await runPromise; }); - it('rejects malformed --json-schema before creating a harness', async () => { - await expect( - runPrompt(opts({ jsonSchema: '{invalid' }), '1.2.3-test'), - ).rejects.toThrow('Invalid --json-schema JSON'); + it('follows a background-steered second main turn before finishing in steer mode', async () => { + // First end-of-turn: stay alive (a background task is still pending). + // Second end-of-turn: finish. + mocks.session.handlePrintMainTurnCompleted + .mockResolvedValueOnce('continue') + .mockResolvedValueOnce('finish'); - expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); - }); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 10, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 10, delta: 'first' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 10, reason: 'completed' })); + } + }); - it('rejects non-object --json-schema values before creating a harness', async () => { - await expect( - runPrompt(opts({ jsonSchema: '["not", "an", "object"]' }), '1.2.3-test'), - ).rejects.toThrow('Invalid --json-schema JSON: expected a JSON object.'); + const stdout = writer(); + const stderr = writer(); + const runPromise = runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); - expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); - }); + // The first turn's assistant message must be flushed and the end-of-turn + // policy consulted, while the run stays alive (action === 'continue'). + await waitForAssertion(() => { + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(1); + expect(stdout.text()).toContain('{"role":"assistant","content":"first"}'); + }); - it('rejects structured output for headless goal prompts', async () => { - await expect( - runPrompt( - opts({ - prompt: '/goal finish the migration', - jsonSchema: '{"type":"object"}', + // Simulate a background-task completion steering the main agent into a new + // turn (the runtime does this via turn.steer; here we drive the events + // directly to verify the driver follows and finishes only after it). + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 11, + origin: { kind: 'background_task' }, }), - '1.2.3-test', - ), - ).rejects.toThrow('Cannot combine --json-schema with a headless goal prompt.'); + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 11, delta: 'second' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 11, reason: 'completed' })); + } - expect(mocks.session.prompt).not.toHaveBeenCalled(); + await runPromise; + + expect(mocks.session.handlePrintMainTurnCompleted).toHaveBeenCalledTimes(2); + expect(stdout.text()).toContain('{"role":"assistant","content":"second"}'); }); it('resumes a concrete session without a configured default model', async () => { @@ -754,6 +935,30 @@ describe('runPrompt', () => { expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); }); + it('passes the CLI additional directories when continuing the previous session', async () => { + await runPrompt(opts({ continue: true, addDirs: ['../shared', '/tmp/extra'] }), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ + id: 'ses_previous', + additionalDirs: ['../shared', '/tmp/extra'], + }); + expect(mocks.harnessCreateSession).not.toHaveBeenCalled(); + }); + + it('does not forward an agent profile when continuing a previous v1 session', async () => { + // validateOptions rejects --agent with --continue; runPrompt must not + // forward a profile to resume even if a caller hands one over. + await runPrompt(opts({ continue: true, agent: 'reviewer' }), '1.2.3-test', { + stdout: writer(), + stderr: writer(), + }); + + expect(mocks.harnessResumeSession).toHaveBeenCalledWith({ id: 'ses_previous' }); + }); + it('continues a previous session without a configured default model', async () => { mocks.harnessGetConfig.mockResolvedValueOnce({ providers: {}, telemetry: true }); mocks.session.getStatus.mockResolvedValueOnce({ permission: 'manual', model: 'saved-model' }); @@ -886,19 +1091,28 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalledTimes(1); }); - it('forces immediate exit on a second signal while graceful cleanup is pending', async () => { + it('waits for the pending auto permission write before signal restore', async () => { let releaseAutoPermission!: () => void; + let releasePrompt!: () => void; mocks.session.setPermission.mockImplementationOnce(async () => { await new Promise<void>((resolve) => { releaseAutoPermission = resolve; }); }); + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ type: 'turn.started', turnId: 7, origin: { kind: 'user' } }), + ); + } + await new Promise<void>((resolve) => { + releasePrompt = resolve; + }); + }); const processMock = fakeProcess(); - const stdout = writer(); - const stderr = writer(); const run = runPrompt(opts({ session: 'ses_existing' }), '1.2.3-test', { - stdout, - stderr, + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, process: processMock, } as Parameters<typeof runPrompt>[2] & { process: ReturnType<typeof fakeProcess> }); @@ -906,33 +1120,29 @@ describe('runPrompt', () => { expect(processMock.listener('SIGINT')).toBeDefined(); expect(mocks.session.setPermission).toHaveBeenCalledWith('auto'); }); - expect(processMock.on.mock.invocationCallOrder[0]).toBeLessThan( + expect(processMock.once.mock.invocationCallOrder[0]).toBeLessThan( mocks.session.setPermission.mock.invocationCallOrder[0]!, ); - const signalCleanup = processMock.listener('SIGINT')!(); + const signalCleanup = processMock.listener('SIGINT')?.(); await Promise.resolve(); expect(mocks.session.setPermission).toHaveBeenCalledTimes(1); - expect(processMock.exit).not.toHaveBeenCalled(); - - const forceExit = processMock.listener('SIGTERM')!(); - - expect(processMock.exit).toHaveBeenCalledOnce(); - expect(processMock.exit).toHaveBeenCalledWith(143); - expect(processMock.listener('SIGINT')).toBeUndefined(); - expect(processMock.listener('SIGTERM')).toBeUndefined(); releaseAutoPermission(); - await Promise.all([signalCleanup, forceExit]); - await run; + await signalCleanup; expect(mocks.session.setPermission).toHaveBeenNthCalledWith(2, 'manual'); - expect(mocks.shutdownTelemetry).toHaveBeenCalledTimes(1); - expect(mocks.harnessClose).toHaveBeenCalledTimes(1); - expect(stdout.flush).toHaveBeenCalledOnce(); - expect(stderr.flush).toHaveBeenCalledOnce(); - expect(processMock.exit).toHaveBeenCalledOnce(); + expect(processMock.exit).toHaveBeenCalledWith(130); + + await waitForAssertion(() => { + expect(mocks.session.prompt).toHaveBeenCalledWith('say hello'); + }); + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 7, reason: 'completed' })); + } + releasePrompt(); + await run; }); it('uses auto permission so headless mode can bypass plan approval and questions', async () => { @@ -989,6 +1199,37 @@ describe('runPrompt', () => { expect(mocks.harnessClose).toHaveBeenCalled(); }); + it('rejects with a friendly message when the provider filters the response', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 2, origin: { kind: 'user' } })); + handler( + mocks.mainEvent({ + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { + code: 'provider.filtered', + message: 'Provider safety policy blocked the response.', + name: 'ProviderFilteredError', + retryable: false, + }, + }), + ); + } + }); + + await expect( + runPrompt(opts(), '1.2.3-test', { + stdout: { write: vi.fn(() => true) }, + stderr: { write: vi.fn(() => true) }, + }), + ).rejects.toThrow('Provider safety policy blocked the response.'); + + expect(mocks.shutdownTelemetry).toHaveBeenCalled(); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); + it('approval fallback approves if an unexpected approval request reaches SDK', async () => { await runPrompt(opts(), '1.2.3-test', { stdout: { write: vi.fn(() => true) }, @@ -1008,4 +1249,216 @@ describe('runPrompt', () => { const handler = mocks.session.setQuestionHandler.mock.calls[0]![0] as () => unknown; expect(handler()).toBeNull(); }); + + it('emits the version first in text mode on the default v2 engine', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', ''); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + // The v2 engine is selected by default and the version banner is the very + // first write, ahead of any assistant output or the resume hint. + expect(mocks.runV2Print).toHaveBeenCalled(); + expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); + expect(stderr.write).toHaveBeenNthCalledWith(1, 'pythinker version 1.2.3-test\n'); + expect(stderr.text().startsWith('pythinker version 1.2.3-test\n')).toBe(true); + expect(stdout.text()).toBe('• hello world\n\n'); + }); + + it('emits the version first in stream-json mode on the default v2 engine', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', ''); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '1'); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts({ outputFormat: 'stream-json' }), '1.2.3-test', { + stdout, + stderr, + }); + + expect(mocks.runV2Print).toHaveBeenCalled(); + expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); + const lines = stdout.text().split('\n'); + expect(lines[0]).toBe( + '{"role":"meta","type":"system.version","version":"1.2.3-test"}', + ); + expect(stderr.text()).toBe(''); + }); + + it('uses the legacy engine when legacy wins over the experimental flag', async () => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '1'); + const stdout = writer(); + const stderr = writer(); + + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(mocks.runV2Print).not.toHaveBeenCalled(); + expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalled(); + expect(stderr.text()).not.toContain('pythinker version'); + }); + + it('does not settle on end_turn while a goal is still active', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'created a goal' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // First evaluation (after turn 1) sees an active goal; the continuation + // turn's evaluation sees the goal gone (completed → record cleared). + mocks.session.getGoal.mockResolvedValueOnce({ goal: { status: 'active' } } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getGoal).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + // The goal driver launches the continuation turn on its own; the run + // streams it and settles only once no goal is active anymore. + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'system_trigger' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'goal work' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + + await run; + expect(settled).toBe(true); + expect(stdout.text()).toContain('goal work'); + }); + + it('settles when the goal reaches a terminal state between turns with no trailing turn.ended', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'working' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // Turn 1's evaluation sees the goal still active; the terminal + // goal.updated (e.g. the driver blocked it on a hard budget) arrives with + // no further turn.ended and must settle the run itself. + mocks.session.getGoal + .mockResolvedValueOnce({ goal: { status: 'active' } } as never) + .mockResolvedValue({ goal: { status: 'blocked' } } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getGoal).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'goal.updated', + snapshot: { status: 'blocked' }, + change: { kind: 'blocked' }, + }), + ); + } + + await run; + expect(settled).toBe(true); + }); + + it('does not settle on end_turn while a cron task is pending, then lets the fire drive a turn', async () => { + mocks.session.prompt.mockImplementationOnce(async () => { + for (const handler of mocks.eventHandlers) { + handler(mocks.mainEvent({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 1, delta: 'scheduled a reminder' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + } + }); + // Turn 1 leaves a pending one-shot cron task; its fire steers turn 2, and + // by turn 2's evaluation the task has fired and been removed. + mocks.session.getCronTasks + .mockResolvedValueOnce({ + tasks: [ + { + id: '3f9a1c2e', + cron: '*/5 * * * *', + recurring: false, + createdAt: 1, + lastFiredAt: undefined, + nextFireAt: Date.now() + 60_000, + }, + ], + } as never) + .mockResolvedValue({ tasks: [] } as never); + + const stdout = writer(); + const stderr = writer(); + let settled = false; + const run = runPrompt(opts(), '1.2.3-test', { stdout, stderr }).then(() => { + settled = true; + }); + + await waitForAssertion(() => { + expect(mocks.session.getCronTasks).toHaveBeenCalledTimes(1); + }); + expect(settled).toBe(false); + + // The cron fire steers a fresh turn; the run streams it and settles once + // no pending tasks remain. + for (const handler of mocks.eventHandlers) { + handler( + mocks.mainEvent({ + type: 'turn.started', + turnId: 2, + origin: { kind: 'cron_job' }, + }), + ); + handler(mocks.mainEvent({ type: 'assistant.delta', turnId: 2, delta: 'cron ran' })); + handler(mocks.mainEvent({ type: 'turn.ended', turnId: 2, reason: 'completed' })); + } + + await run; + expect(settled).toBe(true); + expect(stdout.text()).toContain('cron ran'); + }); + + it('does not wait for cron tasks whose expression has no future fire', async () => { + mocks.session.getCronTasks.mockResolvedValue({ + tasks: [ + { + id: '3f9a1c2e', + cron: '0 0 31 2 *', + recurring: true, + createdAt: 1, + lastFiredAt: undefined, + nextFireAt: null, + }, + ], + } as never); + + const stdout = writer(); + const stderr = writer(); + await runPrompt(opts(), '1.2.3-test', { stdout, stderr }); + + expect(stdout.text()).toBe('• hello world\n\n'); + expect(mocks.harnessClose).toHaveBeenCalled(); + }); }); diff --git a/apps/pythinker-code/test/cli/run-shell.test.ts b/apps/pythinker-code/test/cli/run-shell.test.ts index aa3115c4..9297f114 100644 --- a/apps/pythinker-code/test/cli/run-shell.test.ts +++ b/apps/pythinker-code/test/cli/run-shell.test.ts @@ -1,23 +1,25 @@ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; import type { createPythinkerDeviceId as createPythinkerDeviceIdFn } from '@pymodel/pythinker-code-oauth'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runShell } from '#/cli/run-shell'; -import { - DEFAULT_STATUS_LINE_CONFIG, - type TuiConfig, -} from '#/tui/config'; import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/process'; type CreatePythinkerDeviceId = typeof createPythinkerDeviceIdFn; const mocks = vi.hoisted(() => { + type TuiConfigFallback = { + theme: 'dark' | 'light' | 'auto'; + editorCommand: string | null; + notifications: { enabled: boolean; condition: 'unfocused' | 'always' }; + }; + class TuiConfigParseError extends Error { - readonly fallback: TuiConfig; + readonly fallback: TuiConfigFallback; - constructor(fallback: TuiConfig) { + constructor(fallback: TuiConfigFallback) { super('Invalid TUI config in ~/.pythinker-code/tui.toml; using defaults.'); this.fallback = fallback; } @@ -29,6 +31,7 @@ const mocks = vi.hoisted(() => { loadTuiConfig: vi.fn(), detectTerminalTheme: vi.fn(), pythinkerHarnessConstructor: vi.fn(), + pythinkerHarnessV2Constructor: vi.fn(), harnessEnsureConfigFile: vi.fn(), harnessGetConfig: vi.fn(async () => ({ providers: {}, @@ -37,8 +40,8 @@ const mocks = vi.hoisted(() => { })), harnessGetConfigDiagnostics: vi.fn(async () => ({ warnings: [] as readonly string[] })), harnessGetCachedAccessToken: vi.fn(), - harnessCreateSession: vi.fn(), harnessClose: vi.fn(), + detectPendingMigration: vi.fn<() => Promise<unknown>>(async () => null), harnessTrack: vi.fn(), pythinkerTuiConstructor: vi.fn(), tuiStart: vi.fn(), @@ -56,30 +59,36 @@ const mocks = vi.hoisted(() => { track: lifecycleTrack, })), resolvePythinkerHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/pythinker-code-test-home'), + flushDiagnosticLogsSync: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, - execSync: vi.fn(), + execFileSync: vi.fn(() => ''), + spawnSync: vi.fn(), + resolveCommandPath: vi.fn(() => '/bin/stty' as string | undefined), TuiConfigParseError, }; }); -function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig { - return { - theme: 'dark', - layout: 'fixed', - copyFullResponse: false, - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - ...overrides, - statusLine: overrides.statusLine ?? DEFAULT_STATUS_LINE_CONFIG, - }; -} - vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { const actual = await importOriginal<typeof import('@pymodel/pythinker-code-sdk')>(); + const makeHarnessStub = (args: unknown[]) => { + const options = args[0] as { readonly homeDir?: string } | undefined; + const homeDir = options?.homeDir ?? '/tmp/pythinker-code-test-home'; + return { + homeDir, + auth: { + getCachedAccessToken: mocks.harnessGetCachedAccessToken, + }, + ensureConfigFile: mocks.harnessEnsureConfigFile, + getConfig: mocks.harnessGetConfig, + getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, + close: mocks.harnessClose, + track: mocks.harnessTrack, + }; + }; return { ...actual, resolvePythinkerHome: mocks.resolvePythinkerHome, + flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, createPythinkerHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/pythinker-code-test-home'; @@ -87,18 +96,11 @@ vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { mocks.createPythinkerDeviceId(homeDir); } mocks.pythinkerHarnessConstructor(...args); - return { - homeDir, - auth: { - getCachedAccessToken: mocks.harnessGetCachedAccessToken, - }, - ensureConfigFile: mocks.harnessEnsureConfigFile, - getConfig: mocks.harnessGetConfig, - getConfigDiagnostics: mocks.harnessGetConfigDiagnostics, - createSession: mocks.harnessCreateSession, - close: mocks.harnessClose, - track: mocks.harnessTrack, - }; + return makeHarnessStub(args); + }, + createPythinkerHarnessV2: (...args: unknown[]) => { + mocks.pythinkerHarnessV2Constructor(...args); + return makeHarnessStub(args); }, }; }); @@ -110,6 +112,7 @@ vi.mock('@pymodel/pythinker-code-oauth', async () => { return { ...actual, createPythinkerDeviceId: mocks.createPythinkerDeviceId, + PYTHINKER_CODE_PROVIDER_NAME: 'pythinker-code', }; }); @@ -122,21 +125,17 @@ vi.mock('@pymodel/pythinker-telemetry', () => ({ withTelemetryContext: mocks.withTelemetryContext, })); -vi.mock('../../src/tui/config', async () => { - const actual = await vi.importActual<typeof import('../../src/tui/config.js')>( - '../../src/tui/config.js', - ); - return { - ...actual, - loadTuiConfig: mocks.loadTuiConfig, - TuiConfigParseError: mocks.TuiConfigParseError, - }; -}); +vi.mock('../../src/tui/config', () => ({ + loadTuiConfig: mocks.loadTuiConfig, + TuiConfigParseError: mocks.TuiConfigParseError, +})); vi.mock('../../src/tui/index', () => ({ PythinkerTUI: class { onExit?: () => Promise<void>; + readonly state = { ui: { mode: 'regular' as const } }; + constructor(...args: unknown[]) { mocks.pythinkerTuiConstructor(this, ...args); } @@ -152,13 +151,27 @@ vi.mock('../../src/tui/theme/detect', () => ({ detectTerminalTheme: mocks.detectTerminalTheme, })); +vi.mock('../../src/migration/index', () => ({ + detectPendingMigration: mocks.detectPendingMigration, +})); + vi.mock('node:child_process', () => ({ - execSync: mocks.execSync, + execFileSync: mocks.execFileSync, + spawnSync: mocks.spawnSync, +})); + +vi.mock('../../src/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, })); describe('runShell', () => { + beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_LEGACY_FLAG', '1'); + }); + afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); mocks.harnessGetConfig.mockResolvedValue({ providers: {}, defaultModel: 'k2', @@ -171,18 +184,95 @@ describe('runShell', () => { mocks.resolvePythinkerHome.mockImplementation( (homeDir?: string) => homeDir ?? '/tmp/pythinker-code-test-home', ); + mocks.resolveCommandPath.mockImplementation(() => '/bin/stty'); mocks.harnessCreatesDeviceIdOnConstruction = false; }); - it('constructs PythinkerHarness and PythinkerTUI with startup input', async () => { - const loadedTuiConfig = tuiConfig({ - statusLine: { - ...DEFAULT_STATUS_LINE_CONFIG, - showGit: false, - showModes: false, + const minimalCliOptions = { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }; + + function stubTuiStartup(): void { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + } + + function withEnv(patch: Record<string, string | undefined>, fn: () => Promise<void>): Promise<void> { + const saved: Record<string, string | undefined> = {}; + for (const key of Object.keys(patch)) { + saved[key] = process.env[key]; + const value = patch[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return fn().finally(() => { + for (const key of Object.keys(patch)) { + const value = saved[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + } + + it('builds the v2 harness by default', async () => { + stubTuiStartup(); + await withEnv( + { PYTHINKER_CODE_LEGACY_FLAG: undefined, PYTHINKER_CODE_EXPERIMENTAL_FLAG: undefined }, + async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }, + ); + expect(mocks.pythinkerHarnessV2Constructor).toHaveBeenCalledTimes(1); + expect(mocks.pythinkerHarnessConstructor).not.toHaveBeenCalled(); + }); + + it('uses the legacy harness when the legacy flag is truthy', async () => { + stubTuiStartup(); + await withEnv({ PYTHINKER_CODE_LEGACY_FLAG: '1' }, async () => { + await runShell(minimalCliOptions, '1.2.3-test'); + }); + expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalledTimes(1); + expect(mocks.pythinkerHarnessV2Constructor).not.toHaveBeenCalled(); + }); + + it('lets the legacy flag take priority over the experimental master switch', async () => { + stubTuiStartup(); + await withEnv( + { PYTHINKER_CODE_LEGACY_FLAG: '1', PYTHINKER_CODE_EXPERIMENTAL_FLAG: '1' }, + async () => { + await runShell(minimalCliOptions, '1.2.3-test'); }, + ); + expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalledTimes(1); + expect(mocks.pythinkerHarnessV2Constructor).not.toHaveBeenCalled(); + }); + + it('constructs PythinkerHarness and PythinkerTUI with startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, }); - mocks.loadTuiConfig.mockResolvedValue(loadedTuiConfig); mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetStartupMcpMs.mockResolvedValue(47); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-startup'); @@ -190,7 +280,6 @@ describe('runShell', () => { const cliOptions = { session: undefined, continue: false, - rewindFiles: undefined, yolo: true, auto: false, plan: true, @@ -198,6 +287,9 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], + addDirs: ['../shared', '/tmp/extra'], }; await runShell(cliOptions, '1.2.3-test'); @@ -205,16 +297,26 @@ describe('runShell', () => { expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ - userAgentProduct: 'pythinker-code-cli', + productName: 'pythinker-code-cli', version: '1.2.3-test', }), + sessionStartedProperties: { yolo: true, auto: false, plan: true, afk: false }, }), ); expect(mocks.harnessEnsureConfigFile).toHaveBeenCalledOnce(); expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessGetConfig.mock.invocationCallOrder[0]!, ); - expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' }); + // stty is resolved to an absolute path before the trust gate and skipped + // entirely on Windows (a bare `stty` name would resolve into the + // untrusted cwd). + if (process.platform !== 'win32') { + expect(execFileSync).toHaveBeenCalledWith('/bin/stty', ['-ixon'], { + stdio: ['inherit', 'ignore', 'ignore'], + }); + } else { + expect(execFileSync).not.toHaveBeenCalled(); + } expect(mocks.pythinkerTuiConstructor).toHaveBeenCalledTimes(1); expect(mocks.createPythinkerDeviceId).toHaveBeenCalledWith( '/tmp/pythinker-code-test-home', @@ -228,6 +330,8 @@ describe('runShell', () => { version: '1.2.3-test', uiMode: 'shell', model: 'k2', + sessionId: undefined, + getAccessToken: expect.any(Function), }); expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); @@ -235,60 +339,112 @@ describe('runShell', () => { expect(harness).toBeTypeOf('object'); expect(startupInput).toMatchObject({ cliOptions, + additionalDirs: ['../shared', '/tmp/extra'], + tuiConfig: { + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }, version: '1.2.3-test', workDir: process.cwd(), }); - expect(startupInput.tuiConfig).toEqual(loadedTuiConfig); expect(mocks.tuiStart).toHaveBeenCalledOnce(); - expect(mocks.harnessTrack).not.toHaveBeenCalledWith('started', expect.anything()); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: false, - yolo: true, - auto: false, - plan: true, - afk: false, - }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { duration_ms: expect.any(Number), config_ms: expect.any(Number), init_ms: expect.any(Number), mcp_ms: 47, + tui_mode: 'regular', }); }); - it('runs init and startup hooks without mounting the TUI in init-only mode', async () => { + it('never runs stty on Windows, where it would resolve into the untrusted cwd', async () => { + stubTuiStartup(); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + await runShell(minimalCliOptions, '1.2.3-test'); + expect(execFileSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('skips stty when it cannot be resolved outside the untrusted cwd', async () => { + stubTuiStartup(); + if (process.platform === 'win32') return; + mocks.resolveCommandPath.mockReturnValue(undefined); + await runShell(minimalCliOptions, '1.2.3-test'); + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('stty'); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + it('resolves the --agent profile into the TUI startup input', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + await runShell( { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, - init: false, - initOnly: true, - maintenance: false, plan: false, model: undefined, outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: 'reviewer', + agentFiles: [], }, '1.2.3-test', ); - expect(mocks.harnessCreateSession).toHaveBeenCalledWith({ - workDir: process.cwd(), - model: 'k2', - setupTrigger: 'init', + const [, , startupInput] = mocks.pythinkerTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ agentProfile: 'reviewer' }); + }); + + it('forwards skillsDirs from CLI options to the harness', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, }); - expect(mocks.harnessClose).toHaveBeenCalledOnce(); - expect(mocks.pythinkerTuiConstructor).not.toHaveBeenCalled(); - expect(execSync).not.toHaveBeenCalled(); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: ['/skills'], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + ); + + expect(mocks.pythinkerHarnessConstructor).toHaveBeenCalledWith( + expect.objectContaining({ skillDirs: ['/skills'] }), + ); }); it('tracks first launch when device id creation reports first launch', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockResolvedValue(undefined); mocks.createPythinkerDeviceId.mockImplementationOnce((homeDir, options) => { const deviceId = `device-for-${homeDir}`; @@ -300,7 +456,6 @@ describe('runShell', () => { { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -308,6 +463,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -320,7 +477,11 @@ describe('runShell', () => { }); it('registers first launch before harness construction can create the device id', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockResolvedValue(undefined); mocks.harnessCreatesDeviceIdOnConstruction = true; const createdHomes = new Set<string>(); @@ -337,7 +498,6 @@ describe('runShell', () => { { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -345,6 +505,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -363,16 +525,24 @@ describe('runShell', () => { expect(mocks.harnessTrack).toHaveBeenCalledWith('first_launch'); }); - it('marks resumed lifecycle starts from session flags', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + it('binds startup_perf to the session captured before MCP metrics resolve', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockResolvedValue(undefined); - mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); + let currentSessionId = 'ses-startup'; + mocks.tuiGetCurrentSessionId.mockImplementation(() => currentSessionId); + mocks.tuiGetStartupMcpMs.mockImplementation(async () => { + currentSessionId = 'ses-later'; + return 47; + }); await runShell( { - session: 'ses-1', + session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -380,34 +550,35 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); - expect(mocks.lifecycleTrack).toHaveBeenCalledWith('started', { - resumed: true, - yolo: false, - auto: false, - plan: false, - afk: false, + expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-startup' }); + expect(mocks.withTelemetryContext).not.toHaveBeenCalledWith({ sessionId: 'ses-later' }); + expect(mocks.lifecycleTrack).toHaveBeenCalledWith('startup_perf', { + duration_ms: expect.any(Number), + config_ms: expect.any(Number), + init_ms: expect.any(Number), + mcp_ms: 47, + tui_mode: 'regular', }); }); - it('binds startup_perf to the session captured before MCP metrics resolve', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); - mocks.tuiStart.mockResolvedValue(undefined); - let currentSessionId = 'ses-startup'; - mocks.tuiGetCurrentSessionId.mockImplementation(() => currentSessionId); - mocks.tuiGetStartupMcpMs.mockImplementation(async () => { - currentSessionId = 'ses-later'; - return 47; + it('bridges OAuth refresh outcomes to telemetry', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, }); + mocks.tuiStart.mockResolvedValue(undefined); await runShell( { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -415,29 +586,44 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(1, { sessionId: 'ses-startup' }); - expect(mocks.withTelemetryContext).toHaveBeenNthCalledWith(2, { sessionId: 'ses-startup' }); - expect(mocks.lifecycleTrack).toHaveBeenNthCalledWith(2, 'startup_perf', { - duration_ms: expect.any(Number), - config_ms: expect.any(Number), - init_ms: expect.any(Number), - mcp_ms: 47, + const [harnessOptions] = mocks.pythinkerHarnessConstructor.mock.calls[0] as [ + { + readonly onOAuthRefresh: ( + outcome: + | { readonly success: true } + | { readonly success: false; readonly reason: 'unauthorized' | 'network_or_other' }, + ) => void; + }, + ]; + + harnessOptions.onOAuthRefresh({ success: true }); + harnessOptions.onOAuthRefresh({ success: false, reason: 'unauthorized' }); + harnessOptions.onOAuthRefresh({ success: false, reason: 'network_or_other' }); + + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { outcome: 'success' }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { + outcome: 'error', + reason: 'unauthorized', + }); + expect(mocks.telemetryTrack).toHaveBeenCalledWith('oauth_refresh', { + outcome: 'error', + reason: 'network_or_other', }); }); - it('detects auto theme and forwards config parse warnings as startup notice', async () => { - const fallbackTuiConfig = tuiConfig({ - theme: 'auto', - editorCommand: 'vim', - notifications: { enabled: true, condition: 'always' }, - }); mocks.loadTuiConfig.mockRejectedValue( - new mocks.TuiConfigParseError(fallbackTuiConfig), + new mocks.TuiConfigParseError({ + theme: 'auto', + editorCommand: 'vim', + notifications: { enabled: true, condition: 'always' }, + }), ); mocks.detectTerminalTheme.mockResolvedValue('light'); mocks.tuiStart.mockResolvedValue(undefined); @@ -446,7 +632,6 @@ describe('runShell', () => { { session: '', continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -454,6 +639,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -462,12 +649,20 @@ describe('runShell', () => { const [, , startupInput] = mocks.pythinkerTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ startupNotice: 'Invalid TUI config in ~/.pythinker-code/tui.toml; using defaults.', + tuiConfig: { + theme: 'auto', + editorCommand: 'vim', + notifications: { enabled: true, condition: 'always' }, + }, }); - expect(startupInput.tuiConfig).toEqual(fallbackTuiConfig); }); - it('forwards config.toml diagnostics as startup notices', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + it('leaves config.toml diagnostics to the TUI instead of the startup notice', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.harnessGetConfigDiagnostics.mockResolvedValue({ warnings: ['Ignored invalid config in config.toml: loop_control.'], }); @@ -477,7 +672,6 @@ describe('runShell', () => { { session: '', continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -485,30 +679,38 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); + // Diagnostics render in warning yellow via `showConfigWarningsIfAny` at + // `finishStartup`; the (dim) startup notice stays reserved for things like + // tui.toml parse errors, so the same warning is not shown twice. const [, , startupInput] = mocks.pythinkerTuiConstructor.mock.calls[0]!; expect(startupInput).toMatchObject({ - startupNotice: 'Ignored invalid config in config.toml: loop_control.', + startupNotice: undefined, }); }); - it('surfaces an invalid target config as an error, not silently', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); - mocks.harnessGetConfig.mockRejectedValue( - new Error('Invalid configuration in ~/.pythinker-code/config.toml'), - ); + it('flushes diagnostic logs synchronously before exiting on a runtime crash', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); - // A broken config.toml must fail loudly before the TUI starts — otherwise the - // user never learns their config is broken. - await expect( - runShell( + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + const exitSpy = mockProcessExit(); + + try { + await runShell( { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -516,15 +718,87 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', - ), - ).rejects.toThrow('Invalid configuration'); - expect(mocks.tuiStart).not.toHaveBeenCalled(); + ); + + const handler = processOnSpy.mock.calls.find( + ([event]) => event === 'uncaughtException', + )?.[1] as ((error: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + // The async log sink cannot flush before process.exit() runs, so the + // crash handler must force a synchronous flush or the crash reason is + // lost (regression: uncaughtException logs never reached disk). + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } + }); + + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + const processOnSpy = vi.spyOn(process, 'on'); + const stdout = captureProcessWrite('stdout'); + const exitSpy = mockProcessExit(); + + try { + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + ); + + const handler = processOnSpy.mock.calls.find( + ([event]) => event === 'unhandledRejection', + )?.[1] as ((reason: unknown) => void) | undefined; + expect(handler).toBeDefined(); + + expect(() => handler?.(new Error('boom'))).toThrow(ExitCalled); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!).toBeLessThan( + exitSpy.mock.invocationCallOrder[0]!, + ); + } finally { + processOnSpy.mockRestore(); + exitSpy.mockRestore(); + stdout.restore(); + } }); it('closes the harness when TUI startup fails', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockRejectedValue(new Error('boom')); await expect( @@ -532,7 +806,6 @@ describe('runShell', () => { { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -540,19 +813,28 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ), ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_s: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { + duration_ms: expect.any(Number), + tui_mode: 'regular', + }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); it('tracks exit and prints resume instructions from the TUI exit handler', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); mocks.tuiHasSessionContent.mockReturnValue(true); @@ -566,7 +848,6 @@ describe('runShell', () => { { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -574,6 +855,8 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); @@ -589,7 +872,8 @@ describe('runShell', () => { expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { - duration_s: expect.any(Number), + duration_ms: expect.any(Number), + tui_mode: 'regular', }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); @@ -603,7 +887,11 @@ describe('runShell', () => { }); it('prints the opened web URL from the TUI exit handler when set', async () => { - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); mocks.tuiStart.mockResolvedValue(undefined); mocks.tuiGetCurrentSessionId.mockReturnValue('ses-1'); mocks.tuiHasSessionContent.mockReturnValue(true); @@ -617,7 +905,6 @@ describe('runShell', () => { { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -625,11 +912,13 @@ describe('runShell', () => { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, '1.2.3-test', ); const [tui] = mocks.pythinkerTuiConstructor.mock.calls[0]!; - const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1'; + const openedUrl = 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1'; (tui as { exitOpenUrl?: string }).exitOpenUrl = openedUrl; await expect((tui as { onExit: () => Promise<void> }).onExit()).rejects.toBeInstanceOf( @@ -646,4 +935,38 @@ describe('runShell', () => { } }); + it('surfaces an invalid target config as an error for pythinker migrate, not silently', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.detectPendingMigration.mockResolvedValue({ totalSessions: 1 }); + mocks.harnessGetConfig.mockRejectedValue( + new Error('Invalid configuration in ~/.pythinker-code/config.toml'), + ); + + // A broken config.toml must fail loudly — `pythinker migrate` must not swallow + // it and proceed, or the user never learns their config is broken. + await expect( + runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + { migrateOnly: true }, + ), + ).rejects.toThrow('Invalid configuration'); + expect(mocks.tuiStart).not.toHaveBeenCalled(); + }); }); diff --git a/apps/pythinker-code/test/cli/run-v2-print.test.ts b/apps/pythinker-code/test/cli/run-v2-print.test.ts new file mode 100644 index 00000000..5c87141e --- /dev/null +++ b/apps/pythinker-code/test/cli/run-v2-print.test.ts @@ -0,0 +1,504 @@ +import { PRINT_WAIT_CEILING_S_DEFAULT } from '@pymodel/agent-core-v2'; +import { describe, expect, it, vi } from 'vitest'; + +import { + applyPrintBackgroundPolicy, + createPrintTurnEndings, + PrintSteeredTurnFailedError, + type PrintTurnEnding, + type PrintTurnEndings, +} from '#/cli/v2/run-v2-print'; + +function ending( + turnId: number, + reason: PrintTurnEnding['reason'] = 'completed', +): PrintTurnEnding { + return { type: 'turn.ended', turnId, reason } as unknown as PrintTurnEnding; +} + +interface ScriptedEntry { + readonly event: PrintTurnEnding; + /** Side effect applied when this entry is consumed (e.g. mutate pending). */ + readonly apply?: () => void; +} + +/** + * Scripted `PrintTurnEndings`: replays queued endings (honouring `skipTurnId`), + * then resolves `null` once the script is exhausted (the wait "timed out"). + */ +function scriptedTurnEndings(entries: ScriptedEntry[]): PrintTurnEndings { + const queue = [...entries]; + return { + next: async (_remainingMs: number, skipTurnId: number) => { + while (queue.length > 0) { + const entry = queue.shift()!; + if (entry.event.turnId === skipTurnId) continue; + entry.apply?.(); + return entry.event; + } + return null; + }, + }; +} + +describe('applyPrintBackgroundPolicy', () => { + it('exit returns immediately without draining or waiting', async () => { + const drain = vi.fn(async () => {}); + const countPending = vi.fn(() => 1); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 60, + maxTurns: 50, + countPending, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).not.toHaveBeenCalled(); + expect(countPending).not.toHaveBeenCalled(); + }); + + it('drain drains once and returns', async () => { + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain, + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('steer returns once background tasks are quiescent', async () => { + let pending = 1; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => pending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // The main turn's own buffered ending is skipped. + { event: ending(1) }, + // A background task completed and steered a new turn; it finished and + // no tasks remain. + { event: ending(2), apply: () => { pending = 0; } }, + ]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer finishes with a warning when max turns is reached', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 2, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([{ event: ending(2) }, { event: ending(3) }]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('max turns'); + }); + + it('steer finishes with a warning when the ceiling is reached', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 10, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { now = 10_001; } }, + ]), + skipTurnId: 1, + warn, + now: () => now, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('ceiling'); + }); + + it('steer returns when the wait times out with tasks still pending', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + // Empty script: no further turn ends before the deadline. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => Date.now(), + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('steer throws when a steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: 60, + maxTurns: 50, + countPending: () => 1, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); + + it('waits for goal continuation turns before applying the mode', async () => { + let active = true; + let consumed = 0; + const drain = vi.fn(async () => {}); + await applyPrintBackgroundPolicy({ + mode: 'drain', + ceilingS: 60, + maxTurns: 50, + countPending: () => 0, + drain, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { consumed += 1; } }, + { + event: ending(3), + apply: () => { + consumed += 1; + active = false; + }, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + goalActive: () => active, + }); + // Both continuation turns ended before the mode ('drain') ran. + expect(consumed).toBe(2); + expect(drain).toHaveBeenCalledTimes(1); + }); + + it('warns and returns when the goal wait hits the ceiling', async () => { + let now = 0; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 10, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // No continuation turn ever ends; the poll interval elapses each time. + turnEndings: { + next: async () => { + now = 10_001; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => now, + goalActive: () => true, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('goal wait ceiling'); + }); + + it('exits the goal wait promptly when the goal settles without a turn ending', async () => { + let active = true; + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // Poll interval elapses; the goal settles (paused/blocked) mid-wait + // without producing a turn.ended. + turnEndings: { + next: async () => { + active = false; + return null; + }, + }, + skipTurnId: 1, + warn, + now: () => Date.now(), + goalActive: () => active, + }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('keeps an exit-mode run alive until a pending cron fire steered a turn', async () => { + let nextFire: number | null = 60_000; + let fireTurnEnded = false; + const cronNextFireAt = vi.fn(() => nextFire); + const countPending = vi.fn(() => 0); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + // The cron fire steered this turn; once it ends the one-shot task + // is gone. + event: ending(2), + apply: () => { + fireTurnEnded = true; + nextFire = null; + }, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt, + }); + // The policy waited for the fire turn's ending instead of returning + // immediately, then re-read the (now empty) cron schedule. + expect(fireTurnEnded).toBe(true); + expect(cronNextFireAt).toHaveBeenCalledTimes(2); + // 'exit' never consults background tasks. + expect(countPending).not.toHaveBeenCalled(); + }); + + it('throws when a cron-fire steered turn does not complete', async () => { + await expect( + applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { + event: { + type: 'turn.ended', + turnId: 2, + reason: 'failed', + error: { code: 'provider.overloaded', message: 'try later' }, + } as PrintTurnEnding, + }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt: () => 60_000, + }), + ).rejects.toThrow(PrintSteeredTurnFailedError); + }); + + it('keeps waiting while a recurring cron advances its next fire time', async () => { + let nextFire: number | null = 10_000; + const cronNextFireAt = vi.fn(() => nextFire); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + // First fire: the recurring task advances to its next slot. + { event: ending(2), apply: () => { nextFire = 20_000; } }, + // Second fire: the task is deleted, no future fire remains. + { event: ending(3), apply: () => { nextFire = null; } }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + cronNextFireAt, + }); + expect(cronNextFireAt).toHaveBeenCalledTimes(3); + }); + + it('re-reads the cron schedule when the fire wait times out without a turn', async () => { + let nextFire: number | null = 10_000; + const cronNextFireAt = vi.fn(() => { + const value = nextFire; + // The task was removed between the first query and the re-check. + nextFire = null; + return value; + }); + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // Empty script: the fire produced no turn before the grace elapsed. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => 0, + cronNextFireAt, + }); + expect(cronNextFireAt).toHaveBeenCalledTimes(2); + expect(warn).not.toHaveBeenCalled(); + }); + + it('warns and stops cron waiting when the next fire time is stuck in the past', async () => { + const warn = vi.fn(); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + // No turn ever ends: the tick is wedged and never fires the overdue task. + turnEndings: scriptedTurnEndings([]), + skipTurnId: 1, + warn, + now: () => 1_000, + cronNextFireAt: () => 500, + }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain('cron'); + }); + + it('finishes goal waiting before consulting the cron schedule', async () => { + let active = true; + let consumed = 0; + let nextFire: number | null = 60_000; + let cronFirstCallAtConsumed = -1; + const cronNextFireAt = vi.fn(() => { + if (cronFirstCallAtConsumed === -1) cronFirstCallAtConsumed = consumed; + return nextFire; + }); + await applyPrintBackgroundPolicy({ + mode: 'exit', + ceilingS: 3600, + maxTurns: 50, + countPending: () => 0, + drain: async () => {}, + turnEndings: scriptedTurnEndings([ + { event: ending(2), apply: () => { consumed += 1; } }, + { + event: ending(3), + apply: () => { + consumed += 1; + active = false; + }, + }, + // The pending cron fire steered this turn. + { event: ending(4), apply: () => { nextFire = null; } }, + ]), + skipTurnId: 1, + warn: () => {}, + now: () => 0, + goalActive: () => active, + cronNextFireAt, + }); + // Both goal continuation turns ended before the cron schedule was read. + expect(cronFirstCallAtConsumed).toBe(2); + expect(cronNextFireAt).toHaveBeenCalled(); + }); + + it('steer keeps waiting under the default ceiling with tasks pending', async () => { + let pending = 1; + const turnEndings = createPrintTurnEndings(); + const policy = applyPrintBackgroundPolicy({ + mode: 'steer', + ceilingS: PRINT_WAIT_CEILING_S_DEFAULT, + maxTurns: 50, + countPending: () => pending, + drain: async () => {}, + turnEndings, + skipTurnId: 1, + warn: () => {}, + now: () => Date.now(), + }); + // The default ceiling is ~24.8 days: with tasks still pending the wait + // must not return null early and end the run here. + const early = await Promise.race([ + policy.then(() => 'returned' as const), + new Promise<'waiting'>((resolve) => setTimeout(() => { + resolve('waiting'); + }, 50)), + ]); + expect(early).toBe('waiting'); + // A background task completion steered a new turn; once it ends and no + // tasks remain, the policy returns. + pending = 0; + turnEndings.push(ending(2)); + await policy; + }); +}); + +describe('createPrintTurnEndings', () => { + it('buffers events pushed before next() and skips the given turn id', async () => { + const endings = createPrintTurnEndings(); + endings.push(ending(1)); + endings.push(ending(2)); + await expect(endings.next(1000, 1)).resolves.toMatchObject({ turnId: 2 }); + }); + + it('delivers a pushed event to a pending next()', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(3)); + await expect(pending).resolves.toMatchObject({ turnId: 3 }); + }); + + it('resolves null when the remaining time elapses', async () => { + const endings = createPrintTurnEndings(); + await expect(endings.next(5, 1)).resolves.toBeNull(); + }); + + it('keeps waiting when only the skipped turn ends', async () => { + const endings = createPrintTurnEndings(); + const pending = endings.next(1000, 1); + endings.push(ending(1)); + endings.push(ending(4)); + await expect(pending).resolves.toMatchObject({ turnId: 4 }); + }); + + it('does not resolve null early when the budget exceeds the timer ceiling', async () => { + const endings = createPrintTurnEndings(); + // 10 years in ms — beyond Node's 2^31-1 ms setTimeout ceiling, which an + // explicit `print_wait_ceiling_s` can still reach. + const pending = endings.next(10 * 365 * 24 * 3600 * 1000, 1); + // Node clamps a >2^31-1 ms setTimeout to 1ms; the wait must ride out the + // overflow in chunks instead of resolving null at once. + const early = await Promise.race([ + pending, + new Promise<'waiting'>((resolve) => setTimeout(() => { + resolve('waiting'); + }, 50)), + ]); + expect(early).toBe('waiting'); + endings.push(ending(7)); + await expect(pending).resolves.toMatchObject({ turnId: 7 }); + }); +}); diff --git a/apps/pythinker-code/test/cli/server/server.test.ts b/apps/pythinker-code/test/cli/server/server.test.ts deleted file mode 100644 index 9529dbf0..00000000 --- a/apps/pythinker-code/test/cli/server/server.test.ts +++ /dev/null @@ -1,987 +0,0 @@ -/** - * Tests for `pythinker server run` and `pythinker web` Commander wiring. - * - * These tests don't actually start the server — they verify the parsed shape - * (option flags, --open default) and that the `web` alias defers to the same - * underlying handler with `defaultOpen` flipped to true. - * - * Foreground startup behavior is exercised end-to-end in `server-e2e/`. - */ - -import type { ChildProcess } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { createServer, type Server } from 'node:net'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; - -import chalk, { Chalk } from 'chalk'; -import { Command } from 'commander'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { registerServerCommand } from '#/cli/sub/server'; -import { addLifecycleCommands } from '#/cli/sub/server/lifecycle'; -import type { KillCommandDeps } from '#/cli/sub/server/kill'; -import { PYTHINKER_LOGO_COLORS } from '#/tui/components/chrome/pythinker-logo'; -import { darkColors } from '#/tui/theme/colors'; - -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal<typeof import('node:child_process')>(); - return { ...actual, spawn: vi.fn() }; -}); - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -function makeProgram(): Command { - // `commander` exitOverride avoids killing the test runner when --help/error fires. - const program = new Command('pythinker').exitOverride(); - registerServerCommand(program); - return program; -} - -describe('pythinker server', () => { - it('declares pino-pretty as a CLI runtime dependency', () => { - const packageJson = JSON.parse( - readFileSync(new URL('../../../package.json', import.meta.url), 'utf-8'), - ) as { optionalDependencies?: Record<string, string> }; - - expect(packageJson.optionalDependencies).toHaveProperty('pino-pretty'); - }); - - it('registers the expected `server` subcommands while lifecycle commands are hidden', () => { - const program = makeProgram(); - const server = program.commands.find((c) => c.name() === 'server'); - expect(server).toBeDefined(); - const subs = server?.commands.map((c) => c.name()).toSorted(); - expect(subs).toEqual(['kill', 'ps', 'run']); - }); - - it('`server run` exposes local-only foreground options', () => { - const program = makeProgram(); - const run = program.commands - .find((c) => c.name() === 'server') - ?.commands.find((c) => c.name() === 'run'); - expect(run).toBeDefined(); - const longs = run!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); - expect(longs).toContain('--port'); - expect(longs).toContain('--log-level'); - expect(longs).toContain('--debug-endpoints'); - expect(longs).toContain('--foreground'); - // run defaults to NOT opening the browser → option is the positive --open - expect(longs).toContain('--open'); - }); - - it('`server install` exposes local-only service options', () => { - // Lifecycle commands are no longer registered via `registerServerCommand`, - // but the builder still lives in `./lifecycle` — exercise it directly. - const server = new Command('server'); - addLifecycleCommands(server); - const install = server.commands.find((c) => c.name() === 'install'); - expect(install).toBeDefined(); - const longs = install!.options.map((o) => o.long).filter(Boolean); - expect(longs).not.toContain('--host'); - expect(longs).toContain('--port'); - expect(longs).toContain('--log-level'); - expect(longs).toContain('--force'); - expect(longs).toContain('--no-open'); - expect(longs).toContain('--json'); - }); - - it('the top-level `pythinker web` alias is registered and defaults to opening the browser', () => { - const program = makeProgram(); - const web = program.commands.find((c) => c.name() === 'web'); - expect(web).toBeDefined(); - const longs = web!.options.map((o) => o.long).filter(Boolean); - // web defaults to opening → the option is the negative form --no-open - expect(longs).toContain('--no-open'); - expect(longs).not.toContain('--host'); - expect(longs).toContain('--port'); - }); -}); - -describe('`pythinker server` lifecycle exits with ESERVICE_UNSUPPORTED on unsupported platforms', () => { - it('the dispatcher returns a friendly error manager for unknown platforms', async () => { - // darwin / linux / win32 have real backends (launchd / systemd / schtasks). - // The remaining platforms fall through to the stub that throws - // `ServiceUnsupportedError` — pin that contract so a future addition - // (freebsd, etc.) needs a deliberate decision instead of silently working. - const { resolveServiceManager, ServiceUnsupportedError } = await import('@pymodel/server'); - const mgr = resolveServiceManager('freebsd'); - await expect( - mgr.install({ host: '127.0.0.1', port: 58627, logLevel: 'info' }), - ).rejects.toBeInstanceOf(ServiceUnsupportedError); - await expect(mgr.status()).rejects.toBeInstanceOf(ServiceUnsupportedError); - }); -}); - -describe('`pythinker server` lifecycle handles unavailable service managers', () => { - it('prints a friendly JSON error and exits 2', async () => { - const { ServiceUnavailableError } = await import('@pymodel/server'); - const program = new Command('pythinker').exitOverride(); - const server = program.command('server'); - let stdout = ''; - let stderr = ''; - const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { - throw new Error(`process.exit(${String(code)})`); - }) as typeof process.exit); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async () => { - throw new ServiceUnavailableError( - 'linux', - 'systemd --user is not available in this environment.', - ); - }, - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'unused' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ platform: 'linux', installed: false, running: false }), - }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write(chunk: string | Uint8Array) { - stderr += String(chunk); - return true; - }, - }, - }); - - await expect( - program.parseAsync(['node', 'pythinker', 'server', 'install', '--json']), - ).rejects.toThrow('process.exit(2)'); - - exit.mockRestore(); - expect(stderr).toBe(''); - expect(JSON.parse(stdout)).toMatchObject({ - ok: false, - action: 'unavailable', - platform: 'linux', - message: expect.stringContaining('server run --port <port>'), - }); - }); - - it('preserves exit 2 when output draining rejects', async () => { - const { ServiceUnavailableError } = await import('@pymodel/server'); - const program = new Command('pythinker').exitOverride(); - const server = program.command('server'); - const exit = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { - throw new Error(`process.exit(${String(code)})`); - }) as typeof process.exit); - const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation((( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { - const complete = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - if (String(chunk).length === 0) complete?.(new Error('drain failed')); - return true; - }) as never); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async () => { - throw new ServiceUnavailableError('linux', 'service manager unavailable'); - }, - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'unused' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ platform: 'linux', installed: false, running: false }), - }), - openUrl: vi.fn(), - stdout: process.stdout, - stderr: { write: () => true }, - }); - - try { - await expect( - program.parseAsync(['node', 'pythinker', 'server', 'install', '--json']), - ).rejects.toThrow('process.exit(2)'); - expect(exit).toHaveBeenCalledWith(2); - } finally { - stdoutWrite.mockRestore(); - exit.mockRestore(); - } - }); -}); - -describe('`pythinker server` lifecycle output', () => { - it('install passes --force/--port, prints the URL, and opens it when running', async () => { - const program = new Command('pythinker').exitOverride(); - const server = program.command('server'); - let stdout = ''; - let stderr = ''; - let installArgs: unknown; - const openUrl = vi.fn(); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async (args) => { - installArgs = args; - return { - status: 'replaced', - message: 'Pythinker server LaunchAgent replaced at /tmp/pythinker.plist (port 9999).', - plistPath: '/tmp/pythinker.plist', - }; - }, - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'unused' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ - platform: 'darwin', - installed: true, - running: true, - host: '127.0.0.1', - port: 9999, - logPath: '/tmp/server.log', - label: 'ai.pythoughts.pythinker-server', - }), - }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write(chunk: string | Uint8Array) { - stderr += String(chunk); - return true; - }, - }, - }); - - await program.parseAsync([ - 'node', - 'pythinker', - 'server', - 'install', - '--force', - '--port', - '9999', - ]); - - expect(stderr).toBe(''); - expect(installArgs).toMatchObject({ port: 9999, force: true }); - expect(stdout).toContain('URL: http://127.0.0.1:9999'); - expect(stdout).toContain('Status: running'); - expect(stdout).toContain('Log: /tmp/server.log'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:9999'); - }); - - it('start prints URL and diagnostics when launchd did not keep the service running', async () => { - const program = new Command('pythinker').exitOverride(); - const server = program.command('server'); - let stdout = ''; - const openUrl = vi.fn(); - - addLifecycleCommands(server, { - resolveManager: () => ({ - install: async () => ({ status: 'installed', message: 'unused' }), - uninstall: async () => ({ ok: true, message: 'unused' }), - start: async () => ({ ok: true, message: 'Pythinker server started (ai.pythoughts.pythinker-server).' }), - stop: async () => ({ ok: true, message: 'unused' }), - restart: async () => ({ ok: true, message: 'unused' }), - status: async () => ({ - platform: 'darwin', - installed: true, - running: false, - host: '127.0.0.1', - port: 58627, - logPath: '/tmp/server.log', - label: 'ai.pythoughts.pythinker-server', - notes: ['launchd state: spawn scheduled', 'last exit code: 78 EX_CONFIG'], - }), - }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }); - - await program.parseAsync(['node', 'pythinker', 'server', 'start']); - - expect(stdout).toContain('URL: http://127.0.0.1:58627'); - expect(stdout).toContain('Status: not running'); - expect(stdout).toContain('launchd state: spawn scheduled'); - expect(stdout).toContain('last exit code: 78 EX_CONFIG'); - expect(openUrl).not.toHaveBeenCalled(); - }); -}); - -describe('`pythinker server run` background start', () => { - it('defaults the daemon log level to silent', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(parsed).toMatchObject({ logLevel: 'silent' }); - }); - - it('passes --log-level through to the background daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let parsed: unknown; - - await handleRunCommand( - { port: '58627', logLevel: 'debug' }, - { - startServerBackground: async (options) => { - parsed = options; - return { origin: 'http://127.0.0.1:58627' }; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(parsed).toMatchObject({ logLevel: 'debug' }); - }); - - it('prints a TUI-style ready panel once the daemon is up', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('╭'); - expect(plain).toContain('╰'); - expect(plain).toContain('●'); - expect(plain).toContain('◉'); - expect(plain).toContain('Pythinker server ready'); - expect(plain).toContain('URL:'); - expect(plain).toContain('http://127.0.0.1:58627/'); - expect(plain).toContain('Network:'); - expect(plain).toContain('local only'); - expect(plain).toContain('Logs:'); - expect(plain).toContain('off'); - expect(plain).toContain('Stop:'); - expect(plain).toContain('pythinker server kill'); - expect(plain).not.toContain('➜'); - expect(plain).not.toContain('Pythinker server:'); - }); - - it('uses the TUI dark palette for the ready banner', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const previousChalkLevel = chalk.level; - chalk.level = 3; - - try { - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl: vi.fn(), - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - } finally { - chalk.level = previousChalkLevel; - } - - const color = new Chalk({ level: 3 }); - // The antenna bulb is deliberately coral against the periwinkle chrome. - expect(stdout).toContain(color.hex(PYTHINKER_LOGO_COLORS.antenna)('●')); - expect(stdout).toContain(color.bold.hex(darkColors.primary)('Pythinker server ready')); - expect(stdout).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); - expect(stdout).toContain(color.bold.hex(darkColors.textDim)('URL: ')); - expect(stdout).toContain(color.hex(darkColors.textMuted)('local only')); - }); -}); - -describe('`pythinker server run --foreground`', () => { - it.each([ - ['idle' as const, 0], - ['SIGHUP' as const, 129], - ['SIGINT' as const, 130], - ['SIGTERM' as const, 143], - ])('maps %s shutdown to exit status %i', async (reason, exitCode) => { - const { serverShutdownExitCode } = await import('#/cli/sub/server/run'); - - expect(serverShutdownExitCode(reason)).toBe(exitCode); - }); - - it('forces immediate exit on a second signal while graceful shutdown is pending', async () => { - const { installServerTerminationHandlers } = await import('#/cli/sub/server/run'); - const listeners = new Map<NodeJS.Signals, () => void>(); - const shutdown = vi.fn(); - const exit = vi.fn(); - const remove = installServerTerminationHandlers(shutdown, { - on(signal, listener) { - listeners.set(signal, listener); - }, - off(signal, listener) { - if (listeners.get(signal) === listener) listeners.delete(signal); - }, - exit, - }); - - listeners.get('SIGINT')?.(); - - expect(shutdown).toHaveBeenCalledOnce(); - expect(shutdown).toHaveBeenCalledWith('SIGINT'); - expect(exit).not.toHaveBeenCalled(); - expect(listeners.has('SIGINT')).toBe(true); - expect(listeners.has('SIGTERM')).toBe(true); - - listeners.get('SIGTERM')?.(); - - expect(shutdown).toHaveBeenCalledOnce(); - expect(exit).toHaveBeenCalledOnce(); - expect(exit).toHaveBeenCalledWith(143); - expect(listeners.size).toBe(0); - - remove(); - expect(listeners.size).toBe(0); - }); - - it('runs the server in-process instead of spawning a background daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let foregroundOptions: unknown; - let backgroundCalled = false; - - await handleRunCommand( - { port: '58627', foreground: true }, - { - startServerBackground: async () => { - backgroundCalled = true; - return { origin: 'http://127.0.0.1:58627' }; - }, - startServerForeground: async (options) => { - foregroundOptions = options; - return undefined as unknown as never; - }, - openUrl: vi.fn(), - stdout: { - write() { - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(backgroundCalled).toBe(false); - expect(foregroundOptions).toMatchObject({ port: 58627, logLevel: 'silent' }); - }); - - it('prints the ready banner and opens the browser once listening', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - await handleRunCommand( - { port: '58627', foreground: true, open: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - startServerForeground: async (options, hooks) => { - void options; - hooks?.onReady?.('http://127.0.0.1:58627'); - return undefined as unknown as never; - }, - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - const plain = stripAnsi(stdout); - expect(plain).toContain('Pythinker server ready'); - expect(plain).toContain('http://127.0.0.1:58627/'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); - }); -}); - -describe('`pythinker server` does not register a legacy `daemon` command', () => { - it('hard-deletes the old name', () => { - const program = makeProgram(); - const daemon = program.commands.find((c) => c.name() === 'daemon'); - expect(daemon).toBeUndefined(); - }); -}); - -describe('shared parsers stay strict', () => { - it('rejects out-of-range --port', async () => { - const { parsePort } = await import('#/cli/sub/server/shared'); - expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); - expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); - expect(parsePort(undefined, '--port', 58627)).toBe(58627); - expect(parsePort('8080', '--port', 58627)).toBe(8080); - }); - - it('rejects unknown --log-level values', async () => { - const { parseLogLevel } = await import('#/cli/sub/server/shared'); - expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); - expect(parseLogLevel(undefined)).toBe('info'); - expect(parseLogLevel('debug')).toBe('debug'); - }); -}); - -describe('server web asset directory resolution', () => { - it('uses extracted SEA web assets when available', async () => { - const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); - expect(resolveServerWebAssetsDir('/cache/pythinker/dist-web')).toBe('/cache/pythinker/dist-web'); - }); - - it('falls back to package dist-web outside SEA mode', async () => { - const { resolveServerWebAssetsDir } = await import('#/cli/sub/server/run'); - expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); - }); -}); - -function listenOnce(host: string, port: number): Promise<Server> { - return new Promise((resolve, reject) => { - const server = createServer(); - server.once('error', reject); - server.listen({ host, port }, () => resolve(server)); - }); -} - -function closeServer(server: Server): Promise<void> { - return new Promise((resolve) => server.close(() => resolve())); -} - -async function allocateFreePort(host = '127.0.0.1'): Promise<number> { - const server = await listenOnce(host, 0); - const address = server.address(); - const port = typeof address === 'object' && address !== null ? address.port : 0; - await closeServer(server); - return port; -} - -/** - * Find the start of a run of `count` consecutive free ports - * (`start`, `start + 1`, …, `start + count - 1` all bindable). - */ -async function allocateAdjacentFreeRun(count: number, host = '127.0.0.1'): Promise<number> { - for (let i = 0; i < 50; i++) { - const start = await allocateFreePort(host); - if (start <= 0 || start + count - 1 > 65535) continue; - const held: Server[] = []; - let ok = true; - for (let offset = 1; offset < count; offset++) { - const probe = await listenOnce(host, start + offset).catch(() => null); - if (probe === null) { - ok = false; - break; - } - held.push(probe); - } - for (const server of held) await closeServer(server); - if (ok) return start; - } - throw new Error('could not allocate a run of adjacent free ports'); -} - -describe('resolveDaemonPort', () => { - it('returns the preferred port when it is free', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const free = await allocateFreePort(); - await expect(resolveDaemonPort('127.0.0.1', free)).resolves.toBe(free); - }); - - it('falls back to a different free port when the preferred port is busy', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const busy = await allocateFreePort(); - const holder = await listenOnce('127.0.0.1', busy); - try { - const port = await resolveDaemonPort('127.0.0.1', busy); - expect(port).not.toBe(busy); - expect(port).toBeGreaterThan(0); - } finally { - await closeServer(holder); - } - }); - - it('walks to preferred+1 when only the preferred port is busy', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const start = await allocateAdjacentFreeRun(2); - const holder = await listenOnce('127.0.0.1', start); - try { - const port = await resolveDaemonPort('127.0.0.1', start); - expect(port).toBe(start + 1); - } finally { - await closeServer(holder); - } - }); - - it('skips past a run of busy ports to the first free one', async () => { - const { resolveDaemonPort } = await import('#/cli/sub/server/daemon'); - const start = await allocateAdjacentFreeRun(3); - // Hold both `start` and `start+1`; the resolver should land on `start+2`. - const holderA = await listenOnce('127.0.0.1', start); - const holderB = await listenOnce('127.0.0.1', start + 1); - try { - const port = await resolveDaemonPort('127.0.0.1', start); - expect(port).toBe(start + 2); - } finally { - await closeServer(holderA); - await closeServer(holderB); - } - }); -}); - -describe('resolveDaemonProgram', () => { - it('uses the absolute script path outside SEA mode', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['node', '/opt/pythinker/dist/cli.mjs'], '/tmp', '/usr/bin/node', false)).toBe('/opt/pythinker/dist/cli.mjs'); - }); - - it('normalizes a relative executable path against cwd outside SEA mode', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['node', './pythinker'], '/tmp/pythinker-bin', '/usr/bin/node', false)).toBe('/tmp/pythinker-bin/pythinker'); - }); - - it('returns execPath in SEA mode when argv[1] is a bare command name', async () => { - // Reproduces `pythinker web` from the shell: argv[1] is the invoked command - // name (`pythinker`), not a path. Resolving it against cwd produced `<cwd>/pythinker` - // and crashed the spawn with ENOENT. - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['/Users/x/.pythinker-code/bin/pythinker', 'pythinker', 'web'], '/Users/x', '/Users/x/.pythinker-code/bin/pythinker', true)).toBe('/Users/x/.pythinker-code/bin/pythinker'); - }); - - it('returns execPath in SEA mode for a spawned `server` child', async () => { - const { resolveDaemonProgram } = await import('#/cli/sub/server/daemon'); - expect(resolveDaemonProgram(['/Users/x/.pythinker-code/bin/pythinker', 'server', 'run'], '/Users/x', '/Users/x/.pythinker-code/bin/pythinker', true)).toBe('/Users/x/.pythinker-code/bin/pythinker'); - }); -}); - -describe('spawnDaemonChild', () => { - let workDir: string; - let prevHome: string | undefined; - - beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), 'pythinker-daemon-cwd-')); - prevHome = process.env['PYTHINKER_CODE_HOME']; - process.env['PYTHINKER_CODE_HOME'] = workDir; - vi.resetModules(); - }); - - afterEach(() => { - if (prevHome === undefined) { - delete process.env['PYTHINKER_CODE_HOME']; - } else { - process.env['PYTHINKER_CODE_HOME'] = prevHome; - } - rmSync(workDir, { recursive: true, force: true }); - }); - - it('spawns the daemon with cwd set to the server log directory', async () => { - const { spawn } = await import('node:child_process'); - const spawnMock = vi.mocked(spawn); - spawnMock.mockClear(); - spawnMock.mockReturnValue({ unref: vi.fn(), once: vi.fn() } as unknown as ChildProcess); - - const { spawnDaemonChild, daemonLogPath } = await import('#/cli/sub/server/daemon'); - spawnDaemonChild({ port: 58627, logLevel: 'info' }); - - expect(spawnMock).toHaveBeenCalledOnce(); - const [program, args, options] = spawnMock.mock.calls[0]!; - expect(program).toBeTruthy(); - expect(args).toEqual(expect.arrayContaining(['server', 'run', '--daemon'])); - expect(options).toMatchObject({ detached: true, cwd: dirname(daemonLogPath()) }); - expect(options?.cwd).not.toBe(process.cwd()); - }); -}); - -describe('createIdleShutdownHandler', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it('does not arm before any client connects', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(2000); - expect(onIdle).not.toHaveBeenCalled(); - }); - - it('fires onIdle after the grace once the last client leaves', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(999); - expect(onIdle).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - expect(onIdle).toHaveBeenCalledTimes(1); - }); - - it('cancels a pending exit when a client reconnects during the grace', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 1000, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(0); - vi.advanceTimersByTime(500); - handler.onConnectionCountChange(1); // reconnect - vi.advanceTimersByTime(2000); - expect(onIdle).not.toHaveBeenCalled(); - }); - - it('only the final drop to zero arms the timer with multiple clients', async () => { - const { createIdleShutdownHandler } = await import('#/cli/sub/server/run'); - const onIdle = vi.fn(); - const handler = createIdleShutdownHandler({ graceMs: 500, onIdle }); - handler.onConnectionCountChange(1); - handler.onConnectionCountChange(2); - handler.onConnectionCountChange(1); // still one connected - vi.advanceTimersByTime(1000); - expect(onIdle).not.toHaveBeenCalled(); - handler.onConnectionCountChange(0); // now none - vi.advanceTimersByTime(500); - expect(onIdle).toHaveBeenCalledTimes(1); - }); -}); - -describe('pythinker web (shares `server run` call stack)', () => { - it('prints the ready banner and opens the browser by default', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - let stdout = ''; - const openUrl = vi.fn(); - - await handleRunCommand( - { port: '58627', open: true }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:58627' }), - openUrl, - stdout: { - write(chunk: string | Uint8Array) { - stdout += String(chunk); - return true; - }, - }, - stderr: { - write() { - return true; - }, - }, - }, - ); - - expect(stripAnsi(stdout)).toContain('Pythinker server ready'); - expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); - }); - - it('does not open the browser when open is false', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const openUrl = vi.fn(); - await handleRunCommand( - { port: '58627' }, - { - startServerBackground: async () => ({ origin: 'http://127.0.0.1:9000' }), - openUrl, - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ); - expect(openUrl).not.toHaveBeenCalled(); - }); - - it('rejects an invalid --log-level before touching the daemon', async () => { - const { handleRunCommand } = await import('#/cli/sub/server/run'); - const startServerBackground = vi.fn(); - await expect( - handleRunCommand( - { logLevel: 'shout' }, - { - startServerBackground, - openUrl: vi.fn(), - stdout: { write: () => true }, - stderr: { write: () => true }, - }, - ), - ).rejects.toThrow(/invalid --log-level/); - expect(startServerBackground).not.toHaveBeenCalled(); - }); -}); - -function makeKillDeps(overrides: Partial<KillCommandDeps> = {}): { - deps: KillCommandDeps; - writes: string[]; - signals: Array<{ pid: number; signal: NodeJS.Signals }>; - state: { shutdownCalls: number }; - clock: { t: number }; -} { - const writes: string[] = []; - const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; - const state = { shutdownCalls: 0 }; - const clock = { t: 0 }; - const deps: KillCommandDeps = { - getLiveLock: () => undefined, - requestShutdown: async () => { - state.shutdownCalls += 1; - }, - signalPid: (pid, signal) => { - signals.push({ pid, signal }); - return true; - }, - pidAlive: () => false, - sleep: async (ms) => { - clock.t += ms; - }, - stdout: { - write(chunk: string | Uint8Array) { - writes.push(String(chunk)); - return true; - }, - }, - now: () => clock.t, - ...overrides, - }; - return { deps, writes, signals, state, clock }; -} - -describe('`pythinker server kill`', () => { - const liveLock = { pid: 1234, started_at: '2026-06-17T00:00:00.000Z', port: 58627 }; - - it('prints "No running Pythinker server." and sends no signal when no live lock exists', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals } = makeKillDeps({ getLiveLock: () => undefined }); - - await handleKillCommand(deps); - - expect(writes.join('')).toContain('No running Pythinker server.'); - expect(signals).toEqual([]); - }); - - it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals, state, clock } = makeKillDeps({ - getLiveLock: () => liveLock, - pidAlive: () => clock.t < 50, - }); - - await handleKillCommand(deps); - - expect(state.shutdownCalls).toBe(1); - expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); - expect(writes.join('')).toContain('pid 1234'); - expect(writes.join('')).toContain('stopped.'); - }); - - it('escalates to SIGKILL when the pid survives SIGTERM', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps, writes, signals, clock } = makeKillDeps({ - getLiveLock: () => ({ ...liveLock, pid: 5678 }), - // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. - pidAlive: () => clock.t < 3100, - }); - - await handleKillCommand(deps); - - expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); - expect(writes.join('')).toContain('pid 5678'); - expect(writes.join('')).toContain('killed.'); - }); - - it('throws a permissions error when the pid survives SIGKILL', async () => { - const { handleKillCommand } = await import('#/cli/sub/server/kill'); - const { deps } = makeKillDeps({ - getLiveLock: () => ({ ...liveLock, pid: 9999 }), - pidAlive: () => true, - }); - - await expect(handleKillCommand(deps)).rejects.toThrow(/insufficient permissions/); - }); -}); - -// Silence vi import for cases where the file is built before tests reference vi. -void vi; diff --git a/apps/pythinker-code/test/cli/session-flag-picker.test.ts b/apps/pythinker-code/test/cli/session-flag-picker.test.ts index b4a336e8..8652db93 100644 --- a/apps/pythinker-code/test/cli/session-flag-picker.test.ts +++ b/apps/pythinker-code/test/cli/session-flag-picker.test.ts @@ -11,6 +11,7 @@ function parse(argv: string[]): CLIOptions { (opts) => { captured = opts; }, + () => {}, ); program.exitOverride(); program.configureOutput({ diff --git a/apps/pythinker-code/test/cli/telemetry.test.ts b/apps/pythinker-code/test/cli/telemetry.test.ts index e90d54c8..53028989 100644 --- a/apps/pythinker-code/test/cli/telemetry.test.ts +++ b/apps/pythinker-code/test/cli/telemetry.test.ts @@ -15,7 +15,7 @@ const mocks = vi.hoisted(() => ({ config: { defaultModel?: string; telemetry?: boolean }; fileError: Error | undefined; } => ({ - config: { defaultModel: 'pythinker-k2', telemetry: true }, + config: { defaultModel: 'kimi-k2', telemetry: true }, fileError: undefined, }), ), @@ -29,10 +29,16 @@ vi.mock('@pymodel/pythinker-telemetry', () => ({ withTelemetryContext: vi.fn(), })); -vi.mock('@pymodel/pythinker-code-oauth', () => ({ - createPythinkerDeviceId: mocks.createPythinkerDeviceId, - KIMI_CODE_PROVIDER_NAME: 'managed:kimi-code', -})); +vi.mock('@pymodel/pythinker-code-oauth', async (importOriginal) => { + // Spread the real module: the SDK's v2 client pulls agent-core-v2 into the + // import graph, which subclasses PythinkerOAuthToolkit from this package. + const actual = await importOriginal<typeof import('@pymodel/pythinker-code-oauth')>(); + return { + ...actual, + createPythinkerDeviceId: mocks.createPythinkerDeviceId, + PYTHINKER_CODE_PROVIDER_NAME: 'managed:pythinker-code', + }; +}); vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { const actual = await importOriginal<typeof import('@pymodel/pythinker-code-sdk')>(); @@ -52,23 +58,20 @@ describe('initializeServerTelemetry', () => { mocks.initializeTelemetry.mockClear(); mocks.loadRuntimeConfigSafe.mockClear(); mocks.loadRuntimeConfigSafe.mockReturnValue({ - config: { defaultModel: 'pythinker-k2', telemetry: true }, + config: { defaultModel: 'kimi-k2', telemetry: true }, fileError: undefined, }); }); - // 30s: the dynamic import stalls well past the 5s default when the full - // suite saturates the machine (local-under-load x6 sizing rule). - it('configures the sink with ui_mode="web" and the CLI product identity', { timeout: 30_000 }, async () => { + it('configures the sink with ui_mode="web" and the CLI product identity', async () => { const { initializeServerTelemetry } = await import('#/cli/telemetry'); const client = initializeServerTelemetry({ version: '1.2.3' }); - expect(mocks.initializeTelemetry).toHaveBeenCalledWith( expect.objectContaining({ appName: 'pythinker-code-cli', version: '1.2.3', uiMode: 'web', - model: 'pythinker-k2', + model: 'kimi-k2', enabled: true, deviceId: 'device-123', homeDir: '/home/.pythinker-code', @@ -83,11 +86,14 @@ describe('initializeServerTelemetry', () => { setContext: expect.any(Function), }), ); - }); + // The first dynamic import pulls in the whole SDK/oauth chain (~3s idle, + // more under full-suite transform contention) — give it headroom past the + // 5s default timeout. + }, 20000); it('disables telemetry when config.toml sets telemetry = false', async () => { mocks.loadRuntimeConfigSafe.mockReturnValue({ - config: { defaultModel: 'pythinker-k2', telemetry: false }, + config: { defaultModel: 'kimi-k2', telemetry: false }, fileError: undefined, }); const { initializeServerTelemetry } = await import('#/cli/telemetry'); diff --git a/apps/pythinker-code/test/cli/update/activation.test.ts b/apps/pythinker-code/test/cli/update/activation.test.ts deleted file mode 100644 index bb4be333..00000000 --- a/apps/pythinker-code/test/cli/update/activation.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -import { createHash } from 'node:crypto'; - -import { describe, expect, it, vi } from 'vitest'; - -import { activatePendingUpdate } from '#/cli/update/activation'; -import { - activateHomebrewUpdate, - prepareHomebrewUpdate, - PreparedHomebrewUpdateInvalidError, - type HomebrewCommandRunner, -} from '#/cli/update/homebrew'; -import type { UpdateInstallState, UpdatePreparedHomebrew } from '#/cli/update/types'; - -function preparedHomebrewUpdate(): UpdatePreparedHomebrew { - return { - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - source: 'homebrew', - version: '0.5.0', - preparedAt: '2026-08-04T08:00:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: 'b'.repeat(64), - artifactPath: '/tmp/homebrew-cache/pythinker-code-0.5.0.tgz', - }; -} - -function installState(pending: UpdatePreparedHomebrew): UpdateInstallState { - return { - active: null, - pending, - lastFailure: null, - lastSuccess: null, - }; -} - -describe('pending update activation', () => { - it('activates an exactly prepared Homebrew update and leaves finalization to the new process', async () => { - const pending = preparedHomebrewUpdate(); - const readState = vi.fn().mockResolvedValue(installState(pending)); - const writeState = vi.fn().mockResolvedValue(undefined); - const release = vi.fn().mockResolvedValue(undefined); - const activateHomebrew = vi.fn().mockResolvedValue({ - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }); - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: true, - deps: { - readState, - writeState, - acquireLock: vi.fn().mockResolvedValue({ - filePath: '/tmp/install.lock', - release, - }), - activateHomebrew, - detectSource: vi.fn().mockResolvedValue('homebrew'), - now: () => new Date('2026-08-04T08:05:00.000Z'), - pid: 42_424, - }, - })).resolves.toEqual({ - status: 'activated', - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }); - - expect(activateHomebrew).toHaveBeenCalledWith(pending); - expect(writeState).toHaveBeenNthCalledWith(1, expect.objectContaining({ - pending, - active: expect.objectContaining({ - version: '0.5.0', - source: 'homebrew', - operation: 'activate', - jobId: pending.jobId, - pid: 42_424, - }), - })); - expect(writeState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - pending, - lastFailure: null, - lastSuccess: null, - })); - expect(release).toHaveBeenCalledOnce(); - }); - - it('clears the pending record once the activation failure limit is reached', async () => { - const pending = preparedHomebrewUpdate(); - const state: UpdateInstallState = { - ...installState(pending), - lastFailure: { - version: pending.version, - failedAt: '2026-08-04T07:00:00.000Z', - attempts: 2, - operation: 'activate', - message: 'brew upgrade failed', - }, - }; - const writeState = vi.fn().mockResolvedValue(undefined); - const acquireLock = vi.fn(); - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: true, - deps: { - readState: vi.fn().mockResolvedValue(state), - writeState, - acquireLock, - detectSource: vi.fn().mockResolvedValue('homebrew'), - }, - })).resolves.toEqual({ - status: 'failed', - version: pending.version, - message: 'Automatic activation failed 2 times', - }); - - // Terminal: pending is dropped (lastFailure retained) so later launches - // stop retrying and stop reporting an in-progress update. - expect(writeState).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ - pending: null, - lastFailure: expect.objectContaining({ operation: 'activate', attempts: 2 }), - })); - expect(acquireLock).not.toHaveBeenCalled(); - }); - - it('finalizes a prepared update only after the target version starts', async () => { - const pending = preparedHomebrewUpdate(); - const readState = vi.fn().mockResolvedValue({ - ...installState(pending), - active: { - version: '0.5.0', - source: 'homebrew', - operation: 'activate', - jobId: pending.jobId, - startedAt: '2026-08-04T08:04:00.000Z', - pid: 42_424, - }, - }); - const writeState = vi.fn().mockResolvedValue(undefined); - - await expect(activatePendingUpdate('0.5.0', { - enabled: true, - automaticEnabled: true, - deps: { - readState, - writeState, - detectSource: vi.fn().mockResolvedValue('homebrew'), - now: () => new Date('2026-08-04T08:05:00.000Z'), - }, - })).resolves.toEqual({ status: 'finalized', version: '0.5.0' }); - - expect(writeState).toHaveBeenCalledWith({ - active: null, - pending: null, - lastFailure: null, - lastSuccess: { - version: '0.5.0', - installedAt: '2026-08-04T08:05:00.000Z', - notifiedAt: null, - }, - }); - }); - - it('discards a prepared Homebrew update when the active installation source changed', async () => { - const pending = preparedHomebrewUpdate(); - const writeState = vi.fn().mockResolvedValue(undefined); - const activateHomebrew = vi.fn(); - - await expect(activatePendingUpdate('0.5.0', { - enabled: true, - automaticEnabled: true, - deps: { - readState: vi.fn().mockResolvedValue(installState(pending)), - writeState, - detectSource: vi.fn().mockResolvedValue('npm-global'), - activateHomebrew, - }, - })).resolves.toEqual({ status: 'invalidated', version: '0.5.0' }); - - expect(writeState).toHaveBeenCalledWith({ - ...installState(pending), - active: null, - pending: null, - }); - expect(activateHomebrew).not.toHaveBeenCalled(); - }); - - it('invalidates stale prepared metadata so preflight can prepare the current formula', async () => { - const pending = preparedHomebrewUpdate(); - const writeState = vi.fn().mockResolvedValue(undefined); - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: true, - deps: { - readState: vi.fn().mockResolvedValue(installState(pending)), - writeState, - acquireLock: vi.fn().mockResolvedValue({ - filePath: '/tmp/install.lock', - release: vi.fn().mockResolvedValue(undefined), - }), - activateHomebrew: vi.fn().mockRejectedValue( - new PreparedHomebrewUpdateInvalidError('formula changed'), - ), - detectSource: vi.fn().mockResolvedValue('homebrew'), - now: () => new Date('2026-08-04T08:05:00.000Z'), - }, - })).resolves.toEqual({ status: 'invalidated', version: '0.5.0' }); - - expect(writeState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - pending: null, - lastFailure: expect.objectContaining({ - operation: 'prepare', - message: 'formula changed', - }), - })); - }); - - it('keeps an automatic update pending when automatic installation was disabled', async () => { - const pending = preparedHomebrewUpdate(); - const detectSource = vi.fn(); - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: false, - deps: { - readState: vi.fn().mockResolvedValue(installState(pending)), - detectSource, - }, - })).resolves.toEqual({ status: 'none' }); - - expect(detectSource).not.toHaveBeenCalled(); - }); - - it('activates a manually requested update even when automatic installation is disabled', async () => { - const pending: UpdatePreparedHomebrew = { - ...preparedHomebrewUpdate(), - requestedBy: 'manual', - }; - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: false, - deps: { - readState: vi.fn().mockResolvedValue(installState(pending)), - writeState: vi.fn().mockResolvedValue(undefined), - acquireLock: vi.fn().mockResolvedValue({ - filePath: '/tmp/install.lock', - release: vi.fn().mockResolvedValue(undefined), - }), - activateHomebrew: vi.fn().mockResolvedValue({ - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }), - detectSource: vi.fn().mockResolvedValue('homebrew'), - }, - })).resolves.toEqual(expect.objectContaining({ status: 'activated' })); - }); - - it('does not read update state outside an interactive shell', async () => { - const readState = vi.fn(); - - await expect(activatePendingUpdate('0.4.0', { - enabled: false, - automaticEnabled: true, - deps: { readState }, - })).resolves.toEqual({ status: 'none' }); - - expect(readState).not.toHaveBeenCalled(); - }); -}); - -function homebrewInfo(linkedVersion: string | null, version = '0.5.0'): string { - return JSON.stringify({ - formulae: [{ - name: 'pythinker-code', - versions: { stable: version }, - urls: { - stable: { - url: `https://registry.example.com/pythinker-code-${version}.tgz`, - checksum: 'a'.repeat(64), - }, - }, - linked_keg: linkedVersion, - pinned: false, - }], - }); -} - -function homebrewRunner(formulaVersion = '0.5.0'): { - run: HomebrewCommandRunner; - calls: { args: readonly string[]; options: unknown }[]; -} { - let upgraded = false; - const calls: { args: readonly string[]; options: unknown }[] = []; - const run: HomebrewCommandRunner = vi.fn(async (args, options) => { - calls.push({ args, options }); - const command = args.join(' '); - if (command === 'update' || command.startsWith('fetch ')) return { stdout: '', stderr: '' }; - if (command === 'upgrade --formula --build-from-source --no-ask pythinker-code') { - upgraded = true; - return { stdout: '', stderr: '' }; - } - if (command === 'info --json=v2 pythinker-code') { - return { - stdout: homebrewInfo(upgraded ? formulaVersion : '0.4.0', formulaVersion), - stderr: '', - }; - } - if (command === 'formula pythinker-code') { - return { stdout: '/tmp/tap/Formula/pythinker-code.rb\n', stderr: '' }; - } - if (command === '--cache --build-from-source --formula pythinker-code') { - return { stdout: '/tmp/cache/pythinker-code-0.5.0.tgz\n', stderr: '' }; - } - if (command === '--prefix pythinker-code') { - return { stdout: '/opt/homebrew/opt/pythinker-code\n', stderr: '' }; - } - throw new Error(`unexpected brew command: ${command}`); - }); - return { run, calls }; -} - -const FORMULA_SOURCE = 'class PythinkerCode < Formula\nend\n'; - -function homebrewDeps(run: HomebrewCommandRunner) { - return { - run, - hashFile: vi.fn().mockResolvedValue('a'.repeat(64)), - readFormula: vi.fn().mockResolvedValue(FORMULA_SOURCE), - ensureExecutable: vi.fn().mockResolvedValue(undefined), - now: () => new Date('2026-08-04T08:00:00.000Z'), - }; -} - -describe('Homebrew update adapter', () => { - it('refuses to prepare a formula version outside the selected rollout target', async () => { - const { run, calls } = homebrewRunner('0.6.0'); - - await expect(prepareHomebrewUpdate({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - requestedVersion: '0.5.0', - requestedBy: 'automatic', - }, { deps: homebrewDeps(run) })).rejects.toThrow( - 'Homebrew formula 0.6.0 does not match requested update 0.5.0', - ); - expect(calls.some(({ args }) => args[0] === 'fetch')).toBe(false); - }); - - it('prepares and verifies the exact source artifact in the background', async () => { - const { run, calls } = homebrewRunner(); - const deps = homebrewDeps(run); - - const prepared = await prepareHomebrewUpdate({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - requestedVersion: '0.5.0', - requestedBy: 'automatic', - }, { deps }); - - expect(prepared).toEqual({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - source: 'homebrew', - version: '0.5.0', - preparedAt: '2026-08-04T08:00:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: createHash('sha256').update(FORMULA_SOURCE).digest('hex'), - artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', - }); - expect(calls.some(({ args }) => - args.join(' ') === 'fetch --build-from-source --retry --formula pythinker-code' - )).toBe(true); - expect(deps.hashFile).toHaveBeenCalledWith('/tmp/cache/pythinker-code-0.5.0.tgz'); - }); - - it('freezes Homebrew metadata, installs, verifies the linked keg, and returns the new executable', async () => { - const { run, calls } = homebrewRunner(); - const deps = homebrewDeps(run); - const prepared = await prepareHomebrewUpdate({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - requestedVersion: '0.5.0', - requestedBy: 'automatic', - }, { deps }); - - await expect(activateHomebrewUpdate(prepared, { deps })).resolves.toEqual({ - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }); - - const upgrade = calls.find(({ args }) => args[0] === 'upgrade'); - expect(upgrade).toEqual(expect.objectContaining({ - options: expect.objectContaining({ - inheritOutput: true, - env: expect.objectContaining({ - HOMEBREW_NO_AUTO_UPDATE: '1', - HOMEBREW_NO_INSTALL_CLEANUP: '1', - }), - }), - })); - expect(deps.ensureExecutable).toHaveBeenCalledWith( - '/opt/homebrew/opt/pythinker-code/bin/pythinker', - ); - }); - - it('refuses activation when the prepared artifact checksum changes', async () => { - const { run } = homebrewRunner(); - const deps = homebrewDeps(run); - const prepared = await prepareHomebrewUpdate({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - requestedVersion: '0.5.0', - requestedBy: 'automatic', - }, { deps }); - deps.hashFile.mockResolvedValue('c'.repeat(64)); - - await expect(activateHomebrewUpdate(prepared, { deps })).rejects.toThrow( - 'Prepared Homebrew artifact failed SHA-256 verification', - ); - expect(run).not.toHaveBeenCalledWith( - expect.arrayContaining(['upgrade']), - expect.anything(), - ); - }); - - it('refuses activation when the formula changed after preparation', async () => { - const { run } = homebrewRunner(); - const deps = homebrewDeps(run); - const prepared = await prepareHomebrewUpdate({ - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - requestedVersion: '0.5.0', - requestedBy: 'automatic', - }, { deps }); - deps.readFormula.mockResolvedValue('class ChangedFormula < Formula\nend\n'); - - await expect(activateHomebrewUpdate(prepared, { deps })).rejects.toThrow( - 'Homebrew formula changed after the update was prepared', - ); - expect(run).not.toHaveBeenCalledWith( - expect.arrayContaining(['upgrade']), - expect.anything(), - ); - }); -}); diff --git a/apps/pythinker-code/test/cli/update/cache.test.ts b/apps/pythinker-code/test/cli/update/cache.test.ts index 9cc11545..f8013fba 100644 --- a/apps/pythinker-code/test/cli/update/cache.test.ts +++ b/apps/pythinker-code/test/cli/update/cache.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,11 +11,7 @@ import { } from '#/cli/update/install-state'; import { readUpdateCache, writeUpdateCache } from '#/cli/update/cache'; import { emptyUpdateCache, type UpdateInstallState } from '#/cli/update/types'; -import { - getUpdateInstallLogFile, - getUpdateInstallStateFile, - getUpdateStateFile, -} from '#/utils/paths'; +import { getUpdateInstallStateFile, getUpdateStateFile } from '#/utils/paths'; const originalEnv = { ...process.env }; @@ -144,42 +140,12 @@ describe('update install state', () => { await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); }); - it('reads a legacy install.json whose active record has no pid', async () => { - const legacyState: UpdateInstallState = { - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: '2026-04-23T08:00:00.000Z', - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }; - mkdirSync(join(dir, 'updates'), { recursive: true }); - writeFileSync(getUpdateInstallStateFile(), JSON.stringify(legacyState), 'utf-8'); - - await expect(readUpdateInstallState()).resolves.toEqual(legacyState); - }); - it('writes and reads back the install state from updates/install.json', async () => { const state: UpdateInstallState = { active: { version: '0.5.0', source: 'npm-global', startedAt: '2026-04-23T08:00:00.000Z', - pid: 42_424, - }, - pending: { - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - source: 'homebrew', - version: '0.5.0', - preparedAt: '2026-04-23T08:05:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: 'b'.repeat(64), - artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', }, lastFailure: { version: '0.4.0', @@ -196,11 +162,6 @@ describe('update install state', () => { await writeUpdateInstallState(state); expect(getUpdateInstallStateFile()).toBe(join(dir, 'updates', 'install.json')); - expect(getUpdateInstallLogFile()).toBe(join(dir, 'updates', 'install.log')); - const persisted = JSON.parse(readFileSync(getUpdateInstallStateFile(), 'utf-8')) as { - readonly active: { readonly pid?: number } | null; - }; - expect(persisted.active?.pid).toBe(42_424); await expect(readUpdateInstallState()).resolves.toEqual(state); }); }); diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index 566925f2..fe9237d4 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -1,7 +1,23 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchUpdateManifest, manifestArtifactAvailability } from '#/cli/update/cdn'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; +import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; + +function mockFetchOk(body: string): typeof fetch { + return vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => body, + })) as unknown as typeof fetch; +} + +function mockFetchStatus(status: number): typeof fetch { + return vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + text: async () => '', + })) as unknown as typeof fetch; +} type Route = { readonly status?: number; readonly body?: string } | Error; @@ -33,17 +49,52 @@ const MANIFEST_BODY = JSON.stringify({ ], }); -describe('fetchUpdateManifest', () => { +describe('fetchLatestVersionFromCdn', () => { + it('returns the trimmed semver returned by CDN /latest', async () => { + const f = mockFetchOk(' 0.5.0\n'); + await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); + expect(f).toHaveBeenCalledWith( + PYTHINKER_CODE_CDN_LATEST_URL, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('throws when response is non-2xx', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchStatus(404))).rejects.toThrow(/HTTP 404/); + }); + + it('throws when body is not valid semver', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchOk('not-a-version'))).rejects.toThrow( + /invalid semver/, + ); + }); + + it('throws when body is empty', async () => { + await expect(fetchLatestVersionFromCdn(mockFetchOk(' '))).rejects.toThrow(/invalid semver/); + }); + + it('propagates the underlying fetch error', async () => { + const f = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); + }); +}); + +describe('fetchLatestFromCdn', () => { it('parses latest.json and returns the manifest', async () => { const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); - await expect(fetchUpdateManifest(f)).resolves.toEqual({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [ - { percent: 30, delaySeconds: 0 }, - { percent: 30, delaySeconds: 43_200 }, - { percent: 40, delaySeconds: 86_400 }, - ], + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '2.0.0', + manifest: { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], + }, }); expect(f).toHaveBeenCalledWith( PYTHINKER_CODE_CDN_LATEST_JSON_URL, @@ -61,8 +112,8 @@ describe('fetchUpdateManifest', () => { futureField: { nested: true }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result).toEqual({ + const result = await fetchLatestFromCdn(f); + expect(result.manifest).toEqual({ version: '2.0.0', publishedAt: '2026-06-12T00:00:00.000Z', rollout: [], @@ -75,151 +126,91 @@ describe('fetchUpdateManifest', () => { publishedAt: '2026-06-12T00:00:00.000Z', }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.rollout).toEqual([]); + const result = await fetchLatestFromCdn(f); + expect(result.manifest?.rollout).toEqual([]); }); - it('drops a platforms entry with an invalid sha256 but keeps the manifest', async () => { - const body = JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - platforms: { - 'darwin-arm64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'nope', - }, - }, - }); - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.version).toBe('2.0.0'); - expect(result.platforms).toBeUndefined(); - expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); - }); + const fallbackCases: ReadonlyArray<readonly [string, Route]> = [ + ['latest.json is missing (HTTP 404)', { status: 404 }], + ['latest.json fetch throws', new Error('network down')], + ['body is not valid JSON', { body: 'not json {' }], + ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], + ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], + ['a batch percent is out of range', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }], + ['a batch delay is negative', { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }], + ]; - it('drops a platforms entry with a non-URL url but keeps the manifest', async () => { - const body = JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - platforms: { - 'darwin-arm64': { url: 'not-a-url', sha256: 'a'.repeat(64) }, - }, + for (const [name, route] of fallbackCases) { + it(`falls back to plain /latest when ${name}`, async () => { + const f = mockRoutedFetch({ + [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route, + [PYTHINKER_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, + }); + await expect(fetchLatestFromCdn(f)).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); }); - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.version).toBe('2.0.0'); - expect(result.platforms).toBeUndefined(); - expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); - }); + } - it('carries a well-formed minRequiredVersion onto the parsed manifest', async () => { - const body = JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - minRequiredVersion: '1.5.0', + it('throws when both latest.json and plain /latest fail', async () => { + const f = mockRoutedFetch({ + [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, + [PYTHINKER_CODE_CDN_LATEST_URL]: { status: 500 }, }); - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.version).toBe('2.0.0'); - expect(result.minRequiredVersion).toBe('1.5.0'); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); }); - it('drops a malformed minRequiredVersion but keeps the manifest', async () => { - const body = JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - minRequiredVersion: 'nope', + it('propagates the plain /latest error when the fallback also breaks', async () => { + const f = mockRoutedFetch({ + [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), + [PYTHINKER_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, }); - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.version).toBe('2.0.0'); - expect(result.minRequiredVersion).toBeUndefined(); + await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); }); - it('carries a well-formed platforms record onto the parsed manifest', async () => { - const body = JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - platforms: { - 'darwin-arm64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'a'.repeat(64), - }, - 'linux-x64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', - sha256: 'b'.repeat(64), - }, - }, - }); - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchUpdateManifest(f); - expect(result.version).toBe('2.0.0'); - expect(result.platforms).toEqual({ - 'darwin-arm64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'a'.repeat(64), - }, - 'linux-x64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', - sha256: 'b'.repeat(64), - }, - }); - }); + it('falls back to plain /latest when latest.json hangs past the request timeout', async () => { + vi.useFakeTimers(); + try { + const f = vi.fn(async (input: string | URL, init?: RequestInit) => { + if (String(input) === PYTHINKER_CODE_CDN_LATEST_JSON_URL) { + return new Promise<Response>((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + } + if (String(input) === PYTHINKER_CODE_CDN_LATEST_URL) { + return { ok: true, status: 200, text: async () => '1.9.0\n' }; + } + return { ok: false, status: 404, text: async () => '' }; + }) as unknown as typeof fetch; - // No fallback: the plain-text `/latest` carries no per-platform artifact data, - // so reading it after a bad manifest would report an unverifiable target as - // verified. Every one of these must reject and leave the cached answer alone. - const rejectCases: ReadonlyArray<readonly [string, Route, RegExp]> = [ - ['latest.json is missing (HTTP 404)', { status: 404 }, /HTTP 404/u], - ['latest.json fetch throws', new Error('network down'), /network down/u], - ['body is not valid JSON', { body: 'not json {' }, /JSON/iu], - [ - 'version is not semver', - { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }, - /invalid semver/u, - ], - [ - 'publishedAt is unparseable', - { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }, - /invalid timestamp/u, - ], - [ - 'a batch percent is out of range', - { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 150, delaySeconds: 0 }], - }), - }, - // Name the field: `/./` matched any non-empty message, so a JSON.parse - // failure would have satisfied it just as well as the schema rejection. - /percent/u, - ], - [ - 'a batch delay is negative', - { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 100, delaySeconds: -1 }], - }), - }, - /delaySeconds/u, - ], - ]; + const result = fetchLatestFromCdn(f); + await vi.advanceTimersByTimeAsync(3_000); - for (const [name, route, message] of rejectCases) { - it(`rejects when ${name}`, async () => { - const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route }); - await expect(fetchUpdateManifest(f)).rejects.toThrow(message); - }); - } + await expect(result).resolves.toEqual({ + latest: '1.9.0', + manifest: null, + }); + } finally { + vi.useRealTimers(); + } + }); - it('rejects when latest.json hangs past the request timeout', async () => { + it('rejects when plain /latest also hangs past the request timeout', async () => { vi.useFakeTimers(); try { const f = vi.fn(async (_input: string | URL, init?: RequestInit) => { @@ -230,9 +221,9 @@ describe('fetchUpdateManifest', () => { }); }) as unknown as typeof fetch; - const result = fetchUpdateManifest(f); - const expectation = expect(result).rejects.toThrow(/aborted/u); - await vi.advanceTimersByTimeAsync(3_000); + const result = fetchLatestFromCdn(f); + const expectation = expect(result).rejects.toThrow(/aborted/); + await vi.advanceTimersByTimeAsync(6_000); await expectation; } finally { @@ -240,73 +231,3 @@ describe('fetchUpdateManifest', () => { } }); }); - -describe('manifestArtifactAvailability', () => { - it('treats a null manifest as available (unknown is not a denial)', () => { - expect(manifestArtifactAvailability(null)).toBe('available'); - }); - - it('treats a manifest without platforms as available', () => { - const manifest = { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - }; - expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); - }); - - it('is available when platforms has an own entry for the target', () => { - const manifest = { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - platforms: { - 'darwin-arm64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'a'.repeat(64), - }, - }, - }; - expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); - }); - - it('is unavailable when platforms omits the target', () => { - const manifest = { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - platforms: { - 'darwin-arm64': { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'a'.repeat(64), - }, - }, - }; - expect(manifestArtifactAvailability(manifest, 'linux-x64')).toBe('unavailable'); - }); - - it('is unavailable for an empty platforms object', () => { - const manifest = { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - platforms: {}, - }; - expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('unavailable'); - }); - - it('defaults the target to the running platform', () => { - const manifest = { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [], - platforms: { - [`${process.platform}-${process.arch}`]: { - url: 'https://github.com/PyModel/pythinker-code/releases/download/%40pymodel%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', - sha256: 'a'.repeat(64), - }, - }, - }; - expect(manifestArtifactAvailability(manifest)).toBe('available'); - }); -}); diff --git a/apps/pythinker-code/test/cli/update/install-lock.test.ts b/apps/pythinker-code/test/cli/update/install-lock.test.ts index aef85b1f..546bbd77 100644 --- a/apps/pythinker-code/test/cli/update/install-lock.test.ts +++ b/apps/pythinker-code/test/cli/update/install-lock.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -11,13 +11,6 @@ const originalEnv = { ...process.env }; let dir: string; -function writeLock(contents: unknown): string { - const filePath = getUpdateInstallLockFile(); - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(contents)}\n`, 'utf-8'); - return filePath; -} - beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'pythinker-update-install-lock-')); process.env['PYTHINKER_CODE_HOME'] = dir; @@ -44,146 +37,6 @@ describe('update install lock', () => { await third?.release(); }); - it('does not reclaim a lock with a live pid just under the 6-hour pid ceiling', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'live-owner', - pid: process.pid, - startedAt: '2026-08-03T00:00:00.000Z', - }); - - await expect(tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T05:59:00.000Z'), - })).resolves.toBeNull(); - }); - - it('reclaims a lock with a live pid just over the 6-hour pid ceiling', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'live-owner', - pid: process.pid, - startedAt: '2026-08-03T00:00:00.000Z', - }); - - const lock = await tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T06:01:00.000Z'), - }); - - expect(lock).not.toBeNull(); - await lock?.release(); - }); - - it('reclaims a lock with a live pid and no startedAt', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'untimestamped-owner', - pid: process.pid, - }); - - const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); - - expect(lock).not.toBeNull(); - await lock?.release(); - }); - - it('expires a pid-less lock at 30 minutes, not the 6-hour pid ceiling', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'legacy-owner', - startedAt: '2026-08-03T00:00:00.000Z', - }); - - // 29 minutes: still honoured. - await expect(tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T00:29:00.000Z'), - })).resolves.toBeNull(); - - // 31 minutes: stale, well before the pid ceiling. - const lock = await tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T00:31:00.000Z'), - }); - expect(lock).not.toBeNull(); - await lock?.release(); - }); - - it('honours a lock 2 minutes in the future while its owner process is alive', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'skewed-owner', - pid: process.pid, - startedAt: '2026-08-03T00:02:00.000Z', - }); - - await expect(tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T00:00:00.000Z'), - })).resolves.toBeNull(); - }); - - it('honours a pid-less lock 2 minutes in the future', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'skewed-legacy-owner', - startedAt: '2026-08-03T00:02:00.000Z', - }); - - await expect(tryAcquireUpdateInstallLock({ - version: '0.5.0', - now: new Date('2026-08-03T00:00:00.000Z'), - })).resolves.toBeNull(); - }); - - it('reclaims a lock whose owner process is no longer running', async () => { - writeLock({ - version: '0.5.0', - ownerId: 'dead-owner', - pid: 999_999_999, - startedAt: new Date().toISOString(), - }); - - const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); - - expect(lock).not.toBeNull(); - await lock?.release(); - }); - - it('does not let an old handle unlink a replacement owner', async () => { - const old = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); - expect(old).not.toBeNull(); - const filePath = getUpdateInstallLockFile(); - writeFileSync(filePath, `${JSON.stringify({ - version: '0.5.0', - ownerId: 'replacement-owner', - pid: process.pid, - startedAt: new Date().toISOString(), - })}\n`, 'utf-8'); - - await old?.release(); - - expect(JSON.parse(readFileSync(filePath, 'utf-8'))).toMatchObject({ - ownerId: 'replacement-owner', - }); - }); - - it('allows only one concurrent replacement of a stale lock', async () => { - writeLock({ - version: '0.5.0', - startedAt: '2026-01-01T00:00:00.000Z', - }); - const now = new Date('2026-08-03T00:00:00.000Z'); - - const attempts = await Promise.all(Array.from({ length: 16 }, () => - tryAcquireUpdateInstallLock({ version: '0.5.0', now }))); - const holders = attempts.filter((lock) => lock !== null); - - expect(holders).toHaveLength(1); - await holders[0]?.release(); - }); - it('recovers from a corrupt lock file', async () => { const filePath = getUpdateInstallLockFile(); mkdirSync(dirname(filePath), { recursive: true }); diff --git a/apps/pythinker-code/test/cli/update/install-state.test.ts b/apps/pythinker-code/test/cli/update/install-state.test.ts deleted file mode 100644 index 3a78d2dc..00000000 --- a/apps/pythinker-code/test/cli/update/install-state.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - emptyUpdateInstallState, - readUpdateInstallState, - reconcileAbandonedInstall, - writeUpdateInstallState, -} from '#/cli/update/install-state'; -import type { - UpdateInstallState, - UpdateInstallSuccess, - UpdatePreparedHomebrew, -} from '#/cli/update/types'; -import { getUpdateInstallStateFile } from '#/utils/paths'; - -const originalEnv = { ...process.env }; - -let dir: string; - -beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'pythinker-install-state-')); - process.env['PYTHINKER_CODE_HOME'] = dir; -}); - -afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - process.env = { ...originalEnv }; -}); - -describe('update install state', () => { - it('round-trips an active record carrying installer progress', async () => { - const state: UpdateInstallState = { - active: { - version: '0.5.0', - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - pid: 42_424, - progress: { - state: 'downloading', - percent: 42, - transferred: 5_320_000, - total: 12_600_000, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }; - - await writeUpdateInstallState(state); - - await expect(readUpdateInstallState()).resolves.toEqual(state); - }); - - it('round-trips progress without a total (unknown download size)', async () => { - const state: UpdateInstallState = { - active: { - version: '0.5.0', - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'downloading', - transferred: 5_320_000, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }; - - await writeUpdateInstallState(state); - - await expect(readUpdateInstallState()).resolves.toEqual(state); - }); - - it('falls back to an empty state when the active record has malformed progress', async () => { - mkdirSync(join(dir, 'updates'), { recursive: true }); - writeFileSync( - getUpdateInstallStateFile(), - JSON.stringify({ - active: { - version: '0.5.0', - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { state: 'bogus', updatedAt: '2026-04-23T08:01:00.000Z' }, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }), - 'utf-8', - ); - - await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); - }); -}); - -describe('reconcileAbandonedInstall', () => { - const now = new Date('2026-04-23T09:00:00.000Z'); - const fixedNowIso = now.toISOString(); - - function doomedActiveInstall(): UpdateInstallState { - return { - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: '2026-04-23T08:00:00.000Z', - // Outside any plausible pid range: the owner is gone. - pid: 999_999_999, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }; - } - - it('clears an abandoned active record and records the first failure attempt', async () => { - const reconciled = await reconcileAbandonedInstall(doomedActiveInstall(), now); - - expect(reconciled).toEqual({ - active: null, - pending: null, - lastFailure: { - version: '0.5.0', - failedAt: fixedNowIso, - attempts: 1, - message: expect.any(String), - }, - lastSuccess: null, - }); - // The reconciled state is what the next launch reads. - await expect(readUpdateInstallState()).resolves.toEqual(reconciled); - }); - - it('reaches the parking threshold after two abandoned installs of the same version', async () => { - const first = await reconcileAbandonedInstall(doomedActiveInstall(), now); - expect(first.lastFailure?.attempts).toBe(1); - - // The next launch records a fresh active record on top of the previous - // failure, exactly like the background lifecycle does. - const second = await reconcileAbandonedInstall( - { ...doomedActiveInstall(), lastFailure: first.lastFailure }, - now, - ); - expect(second.lastFailure?.attempts).toBe(2); - }); - - it('leaves an active record with a live installer pid exactly as it is', async () => { - const state: UpdateInstallState = { - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - pid: process.pid, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }; - - const reconciled = await reconcileAbandonedInstall(state); - - expect(reconciled).toBe(state); - expect(existsSync(getUpdateInstallStateFile())).toBe(false); - }); - - it('leaves a state without an active record exactly as it is', async () => { - const state = emptyUpdateInstallState(); - - const reconciled = await reconcileAbandonedInstall(state, now); - - expect(reconciled).toBe(state); - expect(existsSync(getUpdateInstallStateFile())).toBe(false); - }); - - it('starts a fresh failure counter when an abandoned prepare follows install failures', async () => { - const state: UpdateInstallState = { - active: { - version: '0.5.0', - source: 'homebrew', - operation: 'prepare', - startedAt: '2026-04-23T08:00:00.000Z', - pid: 999_999_999, - }, - pending: null, - lastFailure: { - version: '0.5.0', - failedAt: '2026-04-22T08:00:00.000Z', - attempts: 2, - operation: 'install', - message: 'npm exited with code 1', - }, - lastSuccess: null, - }; - - const reconciled = await reconcileAbandonedInstall(state, now); - - expect(reconciled.lastFailure).toEqual({ - version: '0.5.0', - failedAt: fixedNowIso, - attempts: 1, - operation: 'prepare', - message: expect.any(String), - }); - }); - - it('leaves lastSuccess and pending untouched while reconciling', async () => { - const lastSuccess: UpdateInstallSuccess = { - version: '0.4.9', - installedAt: '2026-04-21T08:00:00.000Z', - notifiedAt: null, - }; - const pending: UpdatePreparedHomebrew = { - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - source: 'homebrew', - version: '0.6.0', - preparedAt: '2026-04-22T08:00:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.6.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: 'b'.repeat(64), - artifactPath: '/tmp/cache/pythinker-code-0.6.0.tgz', - }; - const state: UpdateInstallState = { - ...doomedActiveInstall(), - pending, - lastSuccess, - }; - - const reconciled = await reconcileAbandonedInstall(state, now); - - expect(reconciled.lastSuccess).toBe(lastSuccess); - expect(reconciled.pending).toBe(pending); - }); - - it('returns the reconciled state even when persisting it fails', async () => { - // Plant a file where the data directory would be created, so the state - // write fails with ENOTDIR and startup must not break. - const blocked = join(dir, 'blocked'); - writeFileSync(blocked, 'not a directory', 'utf-8'); - process.env['PYTHINKER_CODE_HOME'] = blocked; - - await expect(reconcileAbandonedInstall(doomedActiveInstall(), now)).resolves.toEqual({ - active: null, - pending: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 1, - }), - lastSuccess: null, - }); - }); -}); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index d396fd5d..afb13b8b 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -10,13 +10,7 @@ import { readUpdateInstallState, writeUpdateInstallState, } from '#/cli/update/install-state'; -import { - canAutoInstall, - isWindowsShim, - runUpdatePreflight, - spawnForSource, - startManualUpdate, -} from '#/cli/update/preflight'; +import { runUpdatePreflight, spawnForSource } from '#/cli/update/preflight'; import { promptForInstallChoice } from '#/cli/update/prompt'; import type * as PromptModule from '#/cli/update/prompt'; import { refreshUpdateCache } from '#/cli/update/refresh'; @@ -28,13 +22,8 @@ import { type UpdateCache, type UpdateInstallState, type UpdateManifest, - type UpdatePreparedHomebrew, } from '#/cli/update/types'; -import { - DEFAULT_STATUS_LINE_CONFIG, - type TuiConfig, -} from '#/tui/config'; -import { getUpdateInstallStateFile } from '#/utils/paths'; +import type { TuiConfig } from '#/tui/config'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -47,10 +36,14 @@ const mocks = vi.hoisted(() => ({ refreshUpdateCache: vi.fn(), resolveUpdateDeviceId: vi.fn(), appendRolloutDecisionLog: vi.fn(), - readJsonFile: vi.fn(), - writeJsonFile: vi.fn(), spawn: vi.fn(), - verifyInstalledVersion: vi.fn(), + // Identity by default: resolution is covered by resolve-command.test.ts; + // here we only care which command string reaches spawn(). + resolveCommandPath: vi.fn((cmd: string) => cmd as string | undefined), +})); + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, })); vi.mock('../../../src/cli/update/cache', () => ({ @@ -61,45 +54,27 @@ vi.mock('../../../src/cli/update/install-lock', () => ({ tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, })); -// Only the file IO is faked: `hasFreshActiveInstall` is the lease rule under -// test in several cases below, so it must be the real one. -vi.mock('../../../src/cli/update/install-state', async () => { - const actual = await vi.importActual< - typeof import('../../../src/cli/update/install-state.js') - >('../../../src/cli/update/install-state'); - return { - ...actual, - readUpdateInstallState: mocks.readUpdateInstallState, - writeUpdateInstallState: mocks.writeUpdateInstallState, - }; -}); - -// The reconciliation lives inside install-state.ts and calls its own module's -// writer directly, which the module mock above cannot rewire. Mocking the -// persistence layer catches those writes too — and keeps them off the real -// home directory. -vi.mock('../../../src/utils/persistence', () => ({ - readJsonFile: mocks.readJsonFile, - writeJsonFile: mocks.writeJsonFile, +vi.mock('../../../src/cli/update/install-state', () => ({ + emptyUpdateInstallState: () => ({ + active: null, + lastFailure: null, + lastSuccess: null, + }), + readUpdateInstallState: mocks.readUpdateInstallState, + writeUpdateInstallState: mocks.writeUpdateInstallState, })); -vi.mock('../../../src/tui/config', async () => { - const actual = await vi.importActual<typeof import('../../../src/tui/config.js')>( - '../../../src/tui/config.js', - ); - return { - ...actual, - loadTuiConfig: mocks.loadTuiConfig, - TuiConfigParseError: class TuiConfigParseError extends Error { - readonly fallback: TuiConfig; - - constructor(fallback: TuiConfig) { - super('Invalid client preferences in ~/.pythinker-code/tui.toml; using defaults.'); - this.fallback = fallback; - } - }, - }; -}); +vi.mock('../../../src/tui/config', () => ({ + loadTuiConfig: mocks.loadTuiConfig, + TuiConfigParseError: class TuiConfigParseError extends Error { + readonly fallback: TuiConfig; + + constructor(fallback: TuiConfig) { + super('Invalid client preferences in ~/.pythinker-code/tui.toml; using defaults.'); + this.fallback = fallback; + } + }, +})); vi.mock('../../../src/cli/update/source', () => ({ detectInstallSource: mocks.detectInstallSource, @@ -131,19 +106,6 @@ vi.mock('../../../src/cli/update/rollout', async () => { }; }); -// Post-install verification runs a real probe (a `--version` spawn, or a -// package.json read) — stubbed here so these tests exercise the reporting, -// with its own suite in verify-install.test.ts. -vi.mock('../../../src/cli/update/verify-install', async () => { - const actual = await vi.importActual< - typeof import('../../../src/cli/update/verify-install.js') - >('../../../src/cli/update/verify-install.js'); - return { - ...actual, - verifyInstalledVersion: mocks.verifyInstalledVersion, - }; -}); - vi.mock('node:child_process', async () => { const actual = await vi.importActual<typeof ChildProcess>('node:child_process'); return { @@ -194,66 +156,23 @@ function releasedForEveryone(version: string): UpdateManifest { }); } -/** A manifest advertising an artifact for a platform other than the running one. */ -function manifestOmittingRunningTarget(version: string): UpdateManifest { - const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; - return manifestFor(version, { - platforms: { - [`${process.platform}-${otherArch}`]: { - url: `https://code.pythinker.com/pythinker-code-${version}.zip`, - sha256: 'a'.repeat(64), - }, - }, - }); -} - -/** A manifest advertising an artifact for the running platform. */ -function manifestForRunningTarget(version: string): UpdateManifest { - return manifestFor(version, { - platforms: { - [`${process.platform}-${process.arch}`]: { - url: `https://code.pythinker.com/pythinker-code-${version}.zip`, - sha256: 'a'.repeat(64), - }, - }, - }); -} - function installState(overrides: Partial<UpdateInstallState> = {}): UpdateInstallState { return { active: null, - pending: null, lastFailure: null, lastSuccess: null, ...overrides, }; } -function preparedHomebrewUpdate(): UpdatePreparedHomebrew { - return { - jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', - source: 'homebrew', - version: '0.5.0', - preparedAt: '2026-08-04T08:00:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: 'b'.repeat(64), - artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', - }; -} - function tuiConfig(overrides: Partial<TuiConfig> = {}): TuiConfig { return { theme: 'auto', - layout: 'fixed', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, ...overrides, - copyFullResponse: overrides.copyFullResponse ?? false, }; } @@ -301,121 +220,12 @@ function captureLogger(): { function mockSpawnExit(code: number, signal: NodeJS.Signals | null = null): void { mocks.spawn.mockImplementation(() => { - const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); + const child = Object.assign(new EventEmitter(), { unref: vi.fn() }); queueMicrotask(() => { child.emit('exit', code, signal); }); return child; }); } -/** - * Like mockSpawnExit, but the installer also writes to stderr. The child only - * exposes a stderr stream when the caller actually asked for a pipe — the real - * `stdio: 'ignore'` gives none — so a regression back to discarded output makes - * the message assertion fail instead of silently still passing. - */ -function mockSpawnExitWithStderr(code: number, stderrText: string): void { - mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { - const stdio = options?.stdio; - const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; - const stderr = Object.assign(new EventEmitter(), { - setEncoding: vi.fn(), - unref: vi.fn(), - }); - const child = Object.assign(new EventEmitter(), { - pid: 42_424, - unref: vi.fn(), - stderr: stderrPiped ? stderr : null, - }); - queueMicrotask(() => { - if (stderrPiped) stderr.emit('data', stderrText); - child.emit('exit', code, null); - }); - return child; - }); -} - -/** - * Like mockSpawnExitWithStderr, but stderr arrives as several separate chunks - * so the line reader has to reassemble a progress line split mid-way across - * 'data' events. - */ -function mockSpawnExitWithChunkedStderr(code: number, chunks: string[]): void { - mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { - const stdio = options?.stdio; - const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; - const stderr = Object.assign(new EventEmitter(), { - setEncoding: vi.fn(), - unref: vi.fn(), - }); - const child = Object.assign(new EventEmitter(), { - pid: 42_424, - unref: vi.fn(), - stderr: stderrPiped ? stderr : null, - }); - queueMicrotask(() => { - if (stderrPiped) { - for (const chunk of chunks) stderr.emit('data', chunk); - } - child.emit('exit', code, null); - }); - return child; - }); -} - -/** - * Like mockSpawnExitWithStderr, but stderr chunks and the exit arrive on real - * timers, so the parent's write throttle sees realistic time deltas. - */ -function mockSpawnExitWithTimedStderr( - code: number, - chunks: Array<{ atMs: number; text: string }>, - exitAtMs: number, -): void { - mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { - const stdio = options?.stdio; - const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; - const stderr = Object.assign(new EventEmitter(), { - setEncoding: vi.fn(), - unref: vi.fn(), - }); - const child = Object.assign(new EventEmitter(), { - pid: 42_424, - unref: vi.fn(), - stderr: stderrPiped ? stderr : null, - }); - for (const chunk of chunks) { - setTimeout(() => { - if (stderrPiped) stderr.emit('data', chunk.text); - }, chunk.atMs); - } - setTimeout(() => { child.emit('exit', code, null); }, exitAtMs); - return child; - }); -} - -/** The failure messages written by the background-install finalizer, in order. */ -function progressFailureMessages(): string[] { - return mocks.writeUpdateInstallState.mock.calls - .map((call) => call[0]) - .filter((state) => state !== undefined && state !== null && state.lastFailure !== undefined && state.lastFailure !== null) - .map((state) => state.lastFailure.message); -} - -/** The states written with an active record carrying progress, in order. */ -function progressActiveStates(): unknown[] { - return mocks.writeUpdateInstallState.mock.calls - .map((call) => call[0]) - .filter((state) => state !== undefined && state !== null && state.active?.progress !== undefined); -} - -/** The terminal success records written by the finalizer, in order. */ -function successOutcomeStates(): unknown[] { - return mocks.writeUpdateInstallState.mock.calls - .map((call) => call[0]) - .filter((state) => state !== undefined && state !== null - && state.active === null && state.lastSuccess !== undefined && state.lastSuccess !== null); -} - async function flushBackgroundInstall(): Promise<void> { await new Promise<void>((resolve) => { setImmediate(resolve); @@ -424,18 +234,20 @@ async function flushBackgroundInstall(): Promise<void> { describe('runUpdatePreflight', () => { beforeEach(() => { + // Pin the experimental flag off so rollout gating is deterministic + // regardless of the host environment (the flag bypasses batch holds). + // Tests that exercise the bypass opt back in with `vi.stubEnv(..., '1')`. + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', ''); mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); - mocks.readJsonFile.mockResolvedValue(null); - mocks.writeJsonFile.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); - mocks.verifyInstalledVersion.mockResolvedValue({ ok: true }); mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ filePath: '/tmp/pythinker-update-install.lock', release: vi.fn().mockResolvedValue(undefined), }); + mocks.resolveCommandPath.mockImplementation((cmd: string) => cmd); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); @@ -483,7 +295,7 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).toHaveBeenCalledWith( expect.stringMatching(/^npm(\.cmd)?$/), ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + { detached: true, stdio: 'ignore' }, ); }); @@ -604,119 +416,6 @@ describe('runUpdatePreflight', () => { } }); - it('starts the background install for the refreshed version, never the cached one', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.11.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - expect(mocks.spawn).not.toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.10.0'], - expect.anything(), - ); - }); - - it('starts nothing when the refresh offers no newer version than the current one', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.9.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); - - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - }); - - it('falls back to the cached target when the refresh rejects', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockRejectedValue(new Error('offline')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.10.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - }); - - it('falls back to the cached target when the refresh hangs past the 1-second budget', async () => { - vi.useFakeTimers(); - try { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockReturnValue(new Promise(() => {})); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - - const result = runUpdatePreflight('0.9.0', options); - await vi.advanceTimersByTimeAsync(1_000); - - await expect(result).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.10.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - } finally { - vi.useRealTimers(); - } - }); - - it('native: offers and installs nothing when the refreshed manifest omits the running platform', async () => { - const cached = cacheWithManifest(manifestForRunningTarget('0.10.0')); - const refreshed = cacheWithManifest(manifestOmittingRunningTarget('0.11.0')); - mocks.readUpdateCache.mockResolvedValue(cached); - mocks.refreshUpdateCache.mockResolvedValue(refreshed); - mocks.detectInstallSource.mockResolvedValue('native'); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); - - expect(stdout.join('')).toBe(''); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('decides from the cache and from the refresh exactly once each per launch', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - const phases = mocks.appendRolloutDecisionLog.mock.calls.map((call) => call[0].phase); - expect(phases.filter((phase) => phase === 'startup-cache')).toHaveLength(1); - expect(phases.filter((phase) => phase === 'prompt-refresh')).toHaveLength(1); - expect(phases.filter((phase) => phase === 'background-refresh')).toHaveLength(0); - }); - it('pnpm-global: spawns pnpm add -g', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -733,6 +432,29 @@ describe('runUpdatePreflight', () => { ); }); + it('pnpm-global on win32: spawns pnpm.cmd through a shell', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('pnpm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await runUpdatePreflight('0.4.0', options); + expect(mocks.spawn).toHaveBeenCalledWith( + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"pnpm.cmd"', + ['add', '-g', '@pymodel/pythinker-code@0.5.0'], + { stdio: 'inherit', shell: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('yarn-global: spawns yarn global add', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -765,48 +487,17 @@ describe('runUpdatePreflight', () => { ); }); - it('homebrew: prepares the update in a detached helper for activation on restart', async () => { + it('homebrew: prints manual brew upgrade command, does not spawn', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('homebrew'); - const release = vi.fn().mockResolvedValue(undefined); - mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, - }); - const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); - mocks.spawn.mockImplementation(() => { - queueMicrotask(() => { child.emit('spawn'); }); - return child; - }); const { stdout, options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(stdout).toEqual([]); + expect(stdout.join('')).toContain('brew upgrade pythinker-code'); + expect(stdout.join('')).toContain('Third-party sources may lag behind the official release.'); + expect(stdout.join('')).toContain('https://www.kimi.com/code'); expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).toHaveBeenCalledWith( - process.execPath, - [ - process.argv[1], - '__update_helper', - 'prepare-homebrew', - expect.any(String), - '0.5.0', - 'automatic', - ], - expect.objectContaining({ detached: true, stdio: 'ignore' }), - ); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - version: '0.5.0', - source: 'homebrew', - operation: 'prepare', - jobId: expect.any(String), - }), - })); - expect(child.unref).toHaveBeenCalledOnce(); - expect(release).toHaveBeenCalledOnce(); + expect(mocks.spawn).not.toHaveBeenCalled(); }); it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { @@ -829,105 +520,30 @@ describe('runUpdatePreflight', () => { // pipefail must come before the pipeline so a failed `curl` is not masked // by the trailing `bash` exiting 0 (see "surfaces a failed curl" below). expect(script).toContain('set -o pipefail'); - expect(script).toContain('curl -fsSL https://code.pythinker.com/pythinker-code/install.sh'); - // Pin the decided version, the same guarantee PYTHINKER_VERSION gives on - // Windows. Unpinned, the script installs whatever the CDN calls latest, - // which can differ from the version the rollout chose. - expect(script).toContain('| bash -s -- --version 0.5.0'); + expect(script).toContain('curl -fsSL https://code.kimi.com/pythinker-code/install.sh'); + expect(script).toContain('| bash'); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } }); - it('native on win32: starts a background powershell install, no manual prompt', async () => { + it('native on win32: prints manual powershell command, does not spawn', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); - mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'win32' }); try { const { stdout, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - expect(stdout.join('')).toBe(''); + expect(stdout.join('')).toContain('irm https://code.kimi.com/pythinker-code/install.ps1 | iex'); expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).toHaveBeenCalledWith( - 'powershell.exe', - [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex', - ], - { - detached: true, - windowsHide: true, - stdio: ['ignore', 'ignore', 'pipe'], - env: expect.objectContaining({ PYTHINKER_VERSION: '0.5.0' }), - }, - ); + expect(mocks.spawn).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } }); - it('native: offers and installs nothing when the manifest omits the running platform', async () => { - const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(omitted); - mocks.refreshUpdateCache.mockResolvedValue(omitted); - mocks.detectInstallSource.mockResolvedValue('native'); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(stdout.join('')).toBe(''); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(detectInstallSource).toHaveBeenCalledTimes(1); - }); - - it('native: prompts and installs when the manifest advertises the running platform', async () => { - disableAutoInstall(); - const advertised = cacheWithManifest(manifestForRunningTarget('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(advertised); - mocks.refreshUpdateCache.mockResolvedValue(advertised); - mocks.detectInstallSource.mockResolvedValue('native'); - mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( - expect.objectContaining({ installSource: 'native' }), - ); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('npm-global: still prompts and installs when the manifest omits the running platform', async () => { - disableAutoInstall(); - const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(omitted); - mocks.refreshUpdateCache.mockResolvedValue(omitted); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( - expect.objectContaining({ installSource: 'npm-global' }), - ); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { stdio: 'inherit' }, - ); - }); - it('unsupported: prints fallback npm command', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -949,116 +565,80 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('does not prompt for a foreground install while a fresh active install is running', async () => { + it('warns and continues when spawn exits non-zero, without claiming success', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - }, - })); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mocks.promptForInstallChoice.mockResolvedValue('install'); - const { options } = captureOutput(); - + mockSpawnExit(1); + const { stdout, stderr, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('warning: failed to install'); + // A failed install must never print the "Updated …" success line. + expect(stdout.join('')).not.toContain('Updated @pymodel/pythinker-code'); }); - it('acquires the install lock only after the prompt resolves and releases it afterwards', async () => { + it('spawns the resolved absolute path instead of the bare command name', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mocks.resolveCommandPath.mockReturnValue('/usr/local/bin/npm'); mockSpawnExit(0); - const release = vi.fn().mockResolvedValue(undefined); - mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, - }); - let resolvePrompt: ((value: 'install') => void) | undefined; - const prompt = new Promise<'install'>((resolve) => { resolvePrompt = resolve; }); - mocks.promptForInstallChoice.mockReturnValue(prompt); - const { stdout, options } = captureOutput(); + const { options } = captureOutput(); - const running = runUpdatePreflight('0.4.0', options); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); - // Let the flow actually reach the prompt first. Asserting straight after the - // call was vacuous: nothing had run past the first await, so "no lock yet" - // held wherever the acquisition sat, and the ordering claim in the test name - // went unchecked. - await vi.waitFor(() => { - expect(mocks.promptForInstallChoice).toHaveBeenCalled(); - }); - expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); - resolvePrompt?.('install'); - await expect(running).resolves.toBe('exit'); - expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); - expect(release).toHaveBeenCalledOnce(); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: null, - lastFailure: null, - lastSuccess: { - version: '0.5.0', - installedAt: expect.any(String), - notifiedAt: null, - }, - })); - expect(stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('npm'); + expect(mocks.spawn).toHaveBeenCalledWith( + '/usr/local/bin/npm', + ['install', '-g', '@pymodel/pythinker-code@0.5.0'], + { stdio: 'inherit' }, + ); }); - it('releases the lock and records the failure when the foreground install fails', async () => { + it('warns and continues without spawning when the package manager cannot be resolved', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(1); - const release = vi.fn().mockResolvedValue(undefined); - mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, - }); - const { stderr, options } = captureOutput(); + // Only resolvable inside the cwd (or missing entirely): refuse to run it. + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stdout, stderr, options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).not.toHaveBeenCalled(); expect(stderr.join('')).toContain('warning: failed to install'); - expect(release).toHaveBeenCalledOnce(); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + expect(stdout.join('')).not.toContain('Updated @pymodel/pythinker-code'); + }); + + it('records a background install failure without spawning when the package manager cannot be resolved', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ active: null, lastFailure: expect.objectContaining({ version: '0.5.0', attempts: 1, - operation: 'install', - failedAt: expect.any(String), }), lastSuccess: null, })); }); - it('warns and continues when spawn exits non-zero, without claiming success', async () => { - disableAutoInstall(); - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(1); - const { stdout, stderr, options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(stderr.join('')).toContain('warning: failed to install'); - // A failed install must never print the "Updated …" success line. - expect(stdout.join('')).not.toContain('Updated @pymodel/pythinker-code'); - }); - it('starts an automatic update in the background by default', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); @@ -1072,7 +652,7 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).toHaveBeenCalledWith( expect.stringMatching(/^npm(\.cmd)?$/), ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + { detached: true, stdio: 'ignore' }, ); expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ active: expect.objectContaining({ @@ -1096,55 +676,65 @@ describe('runUpdatePreflight', () => { })); }); - it('treats a fresh legacy active record as a conservative lease for the same target', async () => { + it('win32 background auto-update hides the console window', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - }, - })); + mocks.readUpdateInstallState.mockResolvedValue(installState()); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(promptForInstallChoice).not.toHaveBeenCalled(); + mockSpawnExit(0); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const { options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledWith( + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"npm.cmd"', + ['install', '-g', '@pymodel/pythinker-code@0.5.0'], + { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, + ); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } }); - it('blocks a changed target with a fresh PID-less lease without showing a success notice', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + it('tracks and logs successful background update installs', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { stdout, options } = captureOutput(); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); - await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); - expect(stdout).toEqual([]); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); - expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + source: 'npm-global', + })); + expect(track).toHaveBeenCalledWith('update_background_install_succeeded', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install started', expect.objectContaining({ + currentVersion: '0.4.0', + targetVersion: '0.5.0', + source: 'npm-global', + })); + expect(logger.info).toHaveBeenCalledWith('background update install succeeded', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + })); }); - it('retries a stale legacy active record that has no installer pid', async () => { + it('defaults to automatic background updates when client preferences cannot be loaded', async () => { + mocks.loadTuiConfig.mockRejectedValue(new Error('broken tui.toml')); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() - 7 * 60 * 60 * 1_000).toISOString(), - }, - })); + mocks.readUpdateInstallState.mockResolvedValue(installState()); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mockSpawnExit(0); @@ -1152,908 +742,358 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledOnce(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@pymodel/pythinker-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); }); - it('does not retry the same target while the recorded installer pid is still alive', async () => { + it('starts only one background update when two sessions preflight concurrently', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - pid: process.pid, - }, - })); + mocks.readUpdateInstallState.mockResolvedValue(installState()); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { options } = captureOutput(); + let acquired = false; + mocks.tryAcquireUpdateInstallLock.mockImplementation(async () => { + if (acquired) return null; + acquired = true; + return { + filePath: '/tmp/pythinker-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }; + }); + mockSpawnExit(0); + const first = captureOutput(); + const second = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await expect(Promise.all([ + runUpdatePreflight('0.4.0', first.options), + runUpdatePreflight('0.4.0', second.options), + ])).resolves.toEqual(['continue', 'continue']); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); }); - it('blocks a changed target while the previous target installer pid remains alive within the TTL', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 - 60_000)).toISOString(), - pid: process.pid, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + it('records the first background failure silently so the next launch can retry', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - }); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); - it('recovers a changed target after the previous installer pid outlives the TTL ceiling', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 + 60_000)).toISOString(), - pid: process.pid, - }, + attempts: 1, + failedAt: expect.any(String), + }), + lastSuccess: null, })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + }); + + it('tracks and logs background update install failures without writing stderr', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); + mockSpawnExit(1); + const { stderr, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); - await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); + await flushBackgroundInstall(); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/u), - ['install', '-g', '@pymodel/pythinker-code@0.6.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); + expect(stderr.join('')).toBe(''); + expect(track).toHaveBeenCalledWith('update_background_install_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + attempts: 1, + })); + expect(logger.warn).toHaveBeenCalledWith('background update install failed', expect.objectContaining({ + targetVersion: '0.5.0', + source: 'npm-global', + attempts: 1, + })); }); - it('tolerates a small clock rollback while the recorded installer pid is alive', async () => { + it('retries automatic update once after the first background failure', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { + lastFailure: { version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() + 60_000).toISOString(), - pid: process.pid, + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 1, }, })); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(1); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); - expect(mocks.spawn).not.toHaveBeenCalled(); expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 2, + }), + })); }); - it('retries an active record whose timestamp is far in the future', async () => { + it('prompts for manual foreground install after two background failures', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { + lastFailure: { version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() + 10 * 60_000).toISOString(), - pid: process.pid, + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 2, }, })); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledOnce(); + expect(promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ + target: { version: '0.5.0' }, + installSource: 'npm-global', + })); + expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('recovers a changed target after a stale PID-less lease expires', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + it('shows a one-shot notice after a background update succeeds and the new version starts', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { + lastSuccess: { version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() - 7 * 60 * 60 * 1_000).toISOString(), + installedAt: '2026-04-23T08:00:00.000Z', + notifiedAt: null, }, })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); + const track = vi.fn(); + const logger = captureLogger(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await expect(runUpdatePreflight('0.5.0', { ...options, track, logger })).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pymodel/pythinker-code@0.6.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - }); + const rendered = stdout.join(''); + expect(rendered).toContain('Pythinker Code updated to v0.5.0'); + expect(rendered).toContain( + 'https://code.pythinker.com/pythinker-code/en/release-notes/changelog.html', + ); + expect(track).toHaveBeenCalledWith('update_success_notice_shown', expect.objectContaining({ + version: '0.5.0', + inferred_from_active: false, + })); + expect(logger.info).toHaveBeenCalledWith('background update success notice shown', expect.objectContaining({ + version: '0.5.0', + inferredFromActive: false, + })); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); + expect(detectInstallSource).not.toHaveBeenCalled(); + }); - it('recovers a changed target when the previous installer pid is dead', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + it('infers a background update success notice when the active install version is now running', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); mocks.readUpdateInstallState.mockResolvedValue(installState({ active: { version: '0.5.0', source: 'npm-global', - startedAt: new Date().toISOString(), - pid: 999_999_999, + startedAt: '2026-04-23T08:00:00.000Z', }, })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); + mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); + const { stdout, options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pymodel/pythinker-code@0.6.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); + expect(stdout.join('')).toContain('Pythinker Code updated to v0.5.0'); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: expect.objectContaining({ + version: '0.5.0', + notifiedAt: expect.any(String), + }), + })); }); - it('parks a doomed version after two abandoned installs are reconciled', async () => { + it('tracks update_prompted telemetry', async () => { + disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mocks.promptForInstallChoice.mockResolvedValue('skip'); - let persisted: UpdateInstallState = installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - pid: 999_999_999, - }, - }); - // The reconciliation writes through install-state's own writer, which the - // module mock cannot intercept — capture that path via the persistence - // mock so both write routes feed the same simulated state file. - mocks.writeUpdateInstallState.mockImplementation( - async (state: UpdateInstallState) => { persisted = state; }, - ); - mocks.writeJsonFile.mockImplementation( - async (_filePath: string, _schema: unknown, value: UpdateInstallState) => { persisted = value; }, - ); - mocks.readUpdateInstallState.mockImplementation(async () => persisted); - // Each launch's installer dies without recording an outcome, so the next - // launch finds only an abandoned active record. - mocks.spawn.mockImplementation( - () => Object.assign(new EventEmitter(), { pid: 999_999_999, unref: vi.fn() }), - ); const { options } = captureOutput(); + const track = vi.fn(); + await runUpdatePreflight('0.4.0', { ...options, track }); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + current_version: '0.4.0', + target_version: '0.5.0', + decision: 'prompt-install', + source: 'npm-global', + })); + }); - // First launch: the abandoned record is reconciled to one attempt and the - // version is still attempted in the background. - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(persisted.lastFailure?.attempts).toBe(1); - expect(mocks.spawn).toHaveBeenCalledTimes(1); + describe('rollout gating', () => { + it('hides a cached update whose batch is not yet eligible', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { stdout, options } = captureOutput(); - // Second launch: the counter reaches the parking threshold and the - // automatic path refuses to start the installer again — assert the - // refusal, not just the counter. - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(persisted.lastFailure?.attempts).toBe(2); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledTimes(1); - }); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); - it('recovers a changed target from a far-future active timestamp', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: new Date(Date.now() + 10 * 60_000).toISOString(), - pid: process.pid, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(detectInstallSource).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + // The launch still refreshes the cache in the background so the device + // flips to eligible purely by time passing. + expect(refreshUpdateCache).toHaveBeenCalledTimes(1); + // Both checks of this launch are recorded in the rollout log. + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'held', + current: '0.4.0', + latest: '0.5.0', + bucket: expect.any(Number), + delaySeconds: 86_400, + eligibleAt: expect.any(String), + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'background-refresh', + reason: 'held', + })); + }); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + it('starts the background install once the device batch is eligible', async () => { + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + const track = vi.fn(); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pymodel/pythinker-code@0.6.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - }); + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); + await flushBackgroundInstall(); - it('finalizes and releases after persisting the child pid fails', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const release = vi.fn().mockResolvedValue(undefined); - mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@pymodel/pythinker-code@0.5.0'], + { detached: true, stdio: 'ignore' }, + ); + expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); + expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ + phase: 'startup-cache', + reason: 'eligible', + target: '0.5.0', + })); }); - mocks.writeUpdateInstallState - .mockResolvedValueOnce(undefined) - .mockRejectedValueOnce(new Error('cannot persist child pid')) - .mockResolvedValueOnce(undefined); - mockSpawnExit(0); - const { options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); + it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => { + disableAutoInstall(); + const released = cacheWithManifest(releasedForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(released); + mocks.refreshUpdateCache.mockResolvedValue(released); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); - expect(writeUpdateInstallState).toHaveBeenCalledTimes(3); - expect(writeUpdateInstallState).toHaveBeenNthCalledWith(2, expect.objectContaining({ - active: expect.objectContaining({ pid: 42_424 }), - })); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - lastSuccess: expect.objectContaining({ version: '0.5.0' }), - })); - expect(release).toHaveBeenCalledOnce(); - expect(mocks.writeUpdateInstallState.mock.invocationCallOrder[2]) - .toBeLessThan(release.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY); - }); + await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); - it('records a failure when the installer exits 0 without installing the target', async () => { - // The Windows report this exists for: install.ps1 exited 0 repeatedly - // while the executable on disk stayed on the old version, so the footer - // advertised "restart to apply" for a version that never ran. - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('native'); - mocks.verifyInstalledVersion.mockResolvedValue({ - ok: false, - reason: 'the installer reported success but /bin/pythinker still reports 0.4.0 (expected 0.5.0)', + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.5.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 0, + rollout_from_manifest: true, + })); }); - mockSpawnExit(0); - const { options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); + it('uses the refreshed manifest for rollout telemetry when the prompt target changes', async () => { + disableAutoInstall(); + const cached = cacheWithManifest(manifestFor('0.6.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 0 }], + })); + const refreshed = cacheWithManifest(manifestFor('0.7.0', { + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: 43_200 }], + })); + mocks.readUpdateCache.mockResolvedValue(cached); + mocks.refreshUpdateCache.mockResolvedValue(refreshed); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + const { options } = captureOutput(); + const track = vi.fn(); - expect(mocks.verifyInstalledVersion).toHaveBeenCalledWith('native', '0.5.0'); - expect(successOutcomeStates()).toHaveLength(0); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 1, - message: expect.stringContaining('still reports 0.4.0'), - }), - })); - }); + await expect(runUpdatePreflight('0.5.0', { ...options, track })).resolves.toBe('continue'); - it('records why a success could not be verified', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('native'); - mocks.verifyInstalledVersion.mockResolvedValue({ - ok: true, - unverified: '/usr/local/bin/pythinker could not be run: ETIMEDOUT', + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.7.0' } }), + ); + expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ + target_version: '0.7.0', + rollout_bucket: expect.any(Number), + rollout_delay_seconds: 43_200, + rollout_from_manifest: true, + })); }); - mockSpawnExit(0); - const { options } = captureOutput(); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); + it('suppresses the manual-command notice while a homebrew device batch is held', async () => { + const held = cacheWithManifest(heldForEveryone('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(held); + mocks.refreshUpdateCache.mockResolvedValue(held); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + const { stdout, options } = captureOutput(); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - lastFailure: null, - lastSuccess: expect.objectContaining({ - version: '0.5.0', - unverified: expect.stringContaining('ETIMEDOUT'), - }), - })); - }); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); - it('does not verify an install the installer already reported as failed', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('native'); - mockSpawnExit(1); - const { options } = captureOutput(); + expect(stdout.join('')).toBe(''); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); + it('does not start a fresh-check background install while the refreshed manifest is held', async () => { + mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); - expect(mocks.verifyInstalledVersion).not.toHaveBeenCalled(); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - lastFailure: expect.objectContaining({ version: '0.5.0' }), - })); - }); - - it('keeps the install lock until a delayed terminal state write completes', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - let held = false; - const release = vi.fn(async () => { held = false; }); - mocks.tryAcquireUpdateInstallLock.mockImplementation(async () => { - if (held) return null; - held = true; - return { - filePath: '/tmp/pythinker-update-install.lock', - release, - }; - }); - const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); - mocks.spawn.mockReturnValue(child); - let resolveTerminalWrite: (() => void) | undefined; - let terminalWriteStarted = false; - const terminalWrite = new Promise<void>((resolve) => { - resolveTerminalWrite = resolve; - }); - mocks.writeUpdateInstallState.mockImplementation((state: UpdateInstallState) => { - if (state.active !== null) return Promise.resolve(); - terminalWriteStarted = true; - return terminalWrite; - }); - const first = captureOutput(); - const second = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', first.options)).resolves.toBe('continue'); - child.emit('exit', 0, null); - // The finalizer verifies the installed version before it writes the - // outcome, so the terminal write is more than one microtask away. - await flushBackgroundInstall(); - - expect(terminalWriteStarted).toBe(true); - expect(held).toBe(true); - expect(release).not.toHaveBeenCalled(); - - await expect(runUpdatePreflight('0.4.0', second.options)).resolves.toBe('continue'); - - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledTimes(2); - expect(release).not.toHaveBeenCalled(); - - resolveTerminalWrite?.(); - await flushBackgroundInstall(); - - expect(release).toHaveBeenCalledOnce(); - expect(held).toBe(false); - }); - - it('falls back to the foreground prompt when background startup fails', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.writeUpdateInstallState.mockRejectedValue(new Error('updates directory is read-only')); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ - target: { version: '0.5.0' }, - })); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('tracks and logs successful background update installs', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - const track = vi.fn(); - const logger = captureLogger(); - - await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ - current_version: '0.4.0', - target_version: '0.5.0', - source: 'npm-global', - })); - expect(track).toHaveBeenCalledWith('update_background_install_succeeded', expect.objectContaining({ - target_version: '0.5.0', - source: 'npm-global', - })); - expect(logger.info).toHaveBeenCalledWith('background update install started', expect.objectContaining({ - currentVersion: '0.4.0', - targetVersion: '0.5.0', - source: 'npm-global', - })); - expect(logger.info).toHaveBeenCalledWith('background update install succeeded', expect.objectContaining({ - targetVersion: '0.5.0', - source: 'npm-global', - })); - }); - - it('defaults to automatic background updates when client preferences cannot be loaded', async () => { - mocks.loadTuiConfig.mockRejectedValue(new Error('broken tui.toml')); - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - }); - - it('starts only one background update when two sessions preflight concurrently', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - let acquired = false; - mocks.tryAcquireUpdateInstallLock.mockImplementation(async () => { - if (acquired) return null; - acquired = true; - return { - filePath: '/tmp/pythinker-update-install.lock', - release: vi.fn().mockResolvedValue(undefined), - }; - }); - mockSpawnExit(0); - const first = captureOutput(); - const second = captureOutput(); - - await expect(Promise.all([ - runUpdatePreflight('0.4.0', first.options), - runUpdatePreflight('0.4.0', second.options), - ])).resolves.toEqual(['continue', 'continue']); - - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('records the first background failure silently so the next launch can retry', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(1); - const { stderr, options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(stderr.join('')).toBe(''); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 1, - failedAt: expect.any(String), - }), - lastSuccess: null, - })); - }); - - it('records the installer stderr in the failure message', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr(1, 'bash: line 900: BASH_SOURCE[0]: unbound variable\n'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - lastFailure: expect.objectContaining({ - version: '0.5.0', - // Without the installer's own text a failure is undiagnosable: the - // exit code alone never said which line of install.sh blew up. - message: expect.stringContaining('BASH_SOURCE[0]: unbound variable'), - }), - })); - }); - - it('shows why the previous automatic install failed when prompting', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - disableAutoInstall(); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-08-05T02:25:45.813Z', - attempts: 2, - operation: 'install', - message: 'bash exited with code 1: BASH_SOURCE[0]: unbound variable', - }, - })); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ - previousFailure: expect.stringContaining('BASH_SOURCE[0]: unbound variable'), - })); - }); - - it('does not surface a failure recorded against a different version', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - disableAutoInstall(); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.4.9', - failedAt: '2026-08-05T02:25:45.813Z', - attempts: 1, - operation: 'install', - message: 'stale failure from an older target', - }, - })); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ - previousFailure: undefined, - })); - }); - - it('tracks and logs background update install failures without writing stderr', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(1); - const { stderr, options } = captureOutput(); - const track = vi.fn(); - const logger = captureLogger(); - - await expect(runUpdatePreflight('0.4.0', { ...options, track, logger })).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(stderr.join('')).toBe(''); - expect(track).toHaveBeenCalledWith('update_background_install_failed', expect.objectContaining({ - target_version: '0.5.0', - source: 'npm-global', - attempts: 1, - })); - expect(logger.warn).toHaveBeenCalledWith('background update install failed', expect.objectContaining({ - targetVersion: '0.5.0', - source: 'npm-global', - attempts: 1, - })); - }); - - it('retries automatic update once after the first background failure', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-04-23T08:00:00.000Z', - attempts: 1, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(1); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 2, - }), - })); - }); - - it('prompts for manual foreground install after two background failures', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-04-23T08:00:00.000Z', - attempts: 2, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(promptForInstallChoice).toHaveBeenCalledWith(expect.objectContaining({ - target: { version: '0.5.0' }, - installSource: 'npm-global', - })); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('shows a one-shot notice after a background update succeeds and the new version starts', async () => { - mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastSuccess: { - version: '0.5.0', - installedAt: '2026-04-23T08:00:00.000Z', - notifiedAt: null, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); - const { stdout, options } = captureOutput(); - const track = vi.fn(); - const logger = captureLogger(); - - await expect(runUpdatePreflight('0.5.0', { ...options, track, logger })).resolves.toBe('continue'); - - const rendered = stdout.join(''); - expect(rendered).toContain('Pythinker Code updated to v0.5.0'); - expect(rendered).toContain( - 'https://pymodel.github.io/pythinker-code/release-notes/changelog.html', - ); - expect(track).toHaveBeenCalledWith('update_success_notice_shown', expect.objectContaining({ - version: '0.5.0', - inferred_from_active: false, - })); - expect(logger.info).toHaveBeenCalledWith('background update success notice shown', expect.objectContaining({ - version: '0.5.0', - inferredFromActive: false, - })); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - lastSuccess: expect.objectContaining({ - version: '0.5.0', - notifiedAt: expect.any(String), - }), - })); - expect(detectInstallSource).not.toHaveBeenCalled(); - }); - - it('shows an explicit success notice without clearing a newer active lease', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.7.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.6.0', - source: 'npm-global', - startedAt: new Date().toISOString(), - }, - lastSuccess: { - version: '0.5.0', - installedAt: '2026-04-23T08:00:00.000Z', - notifiedAt: null, - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.7.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - - expect(stdout.join('')).toContain('Pythinker Code updated to v0.5.0'); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ version: '0.6.0' }), - lastSuccess: expect.objectContaining({ - version: '0.5.0', - notifiedAt: expect.any(String), - }), - })); - expect(mocks.spawn).not.toHaveBeenCalled(); - expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); - }); - - it('records an abandoned install as a failure instead of inferring a success notice', async () => { - mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { - version: '0.5.0', - source: 'npm-global', - startedAt: '2026-04-23T08:00:00.000Z', - }, - })); - mocks.refreshUpdateCache.mockResolvedValue(emptyUpdateCache()); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - - // A stale active record is an abandoned install, not an inferred success: - // it is reconciled into a recorded failure and never shows the notice. - expect(stdout.join('')).toBe(''); - expect(mocks.writeJsonFile).toHaveBeenCalledWith( - getUpdateInstallStateFile(), - expect.anything(), - expect.objectContaining({ - active: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 1, - message: expect.stringContaining('abandoned'), - }), - }), - expect.objectContaining({ durable: true }), - ); - }); - - it('tracks update_prompted telemetry', async () => { - disableAutoInstall(); - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - const track = vi.fn(); - await runUpdatePreflight('0.4.0', { ...options, track }); - expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - current: '0.4.0', - latest: '0.5.0', - decision: 'prompt-install', - source: 'npm-global', - })); - }); - - describe('rollout gating', () => { - it('hides a cached update whose batch is not yet eligible', async () => { - const held = cacheWithManifest(heldForEveryone('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(held); - mocks.refreshUpdateCache.mockResolvedValue(held); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(stdout.join('')).toBe(''); - expect(promptForInstallChoice).not.toHaveBeenCalled(); - expect(detectInstallSource).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); - // The launch still refreshes the cache in the background so the device - // flips to eligible purely by time passing. - expect(refreshUpdateCache).toHaveBeenCalledTimes(1); - // Both checks of this launch are recorded in the rollout log. - expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ - phase: 'startup-cache', - reason: 'held', - current: '0.4.0', - latest: '0.5.0', - bucket: expect.any(Number), - delaySeconds: 86_400, - eligibleAt: expect.any(String), - })); - expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ - phase: 'background-refresh', - reason: 'held', - })); - }); - - it('starts the background install once the device batch is eligible', async () => { - const released = cacheWithManifest(releasedForEveryone('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(released); - mocks.refreshUpdateCache.mockResolvedValue(released); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - const { options } = captureOutput(); - const track = vi.fn(); - - await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); - expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ - target_version: '0.5.0', - rollout_bucket: expect.any(Number), - rollout_delay_seconds: 0, - rollout_from_manifest: true, - })); - expect(mocks.appendRolloutDecisionLog).toHaveBeenCalledWith(expect.objectContaining({ - phase: 'startup-cache', - reason: 'eligible', - target: '0.5.0', - })); - }); - - it('prompts with rollout telemetry when eligible and auto-install is disabled', async () => { - disableAutoInstall(); - const released = cacheWithManifest(releasedForEveryone('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(released); - mocks.refreshUpdateCache.mockResolvedValue(released); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - const track = vi.fn(); - - await expect(runUpdatePreflight('0.4.0', { ...options, track })).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( - expect.objectContaining({ target: { version: '0.5.0' } }), - ); - expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.5.0', - rollout_bucket: expect.any(Number), - rollout_delay_seconds: 0, - rollout_from_manifest: true, - })); - }); - - it('uses the refreshed manifest for rollout telemetry when the prompt target changes', async () => { - disableAutoInstall(); - const cached = cacheWithManifest(manifestFor('0.6.0', { - publishedAt: '2020-01-01T00:00:00.000Z', - rollout: [{ percent: 100, delaySeconds: 0 }], - })); - const refreshed = cacheWithManifest(manifestFor('0.7.0', { - publishedAt: '2020-01-01T00:00:00.000Z', - rollout: [{ percent: 100, delaySeconds: 43_200 }], - })); - mocks.readUpdateCache.mockResolvedValue(cached); - mocks.refreshUpdateCache.mockResolvedValue(refreshed); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - const track = vi.fn(); - - await expect(runUpdatePreflight('0.5.0', { ...options, track })).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( - expect.objectContaining({ target: { version: '0.7.0' } }), - ); - expect(track).toHaveBeenCalledWith('update_prompted', expect.objectContaining({ - latest: '0.7.0', - rollout_bucket: expect.any(Number), - rollout_delay_seconds: 43_200, - rollout_from_manifest: true, - })); - }); - - it('suppresses the manual-command notice while a homebrew device batch is held', async () => { - const held = cacheWithManifest(heldForEveryone('0.5.0')); - mocks.readUpdateCache.mockResolvedValue(held); - mocks.refreshUpdateCache.mockResolvedValue(held); - mocks.detectInstallSource.mockResolvedValue('homebrew'); - const { stdout, options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(stdout.join('')).toBe(''); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('does not start a fresh-check background install while the refreshed manifest is held', async () => { - mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); expect(refreshUpdateCache).toHaveBeenCalledTimes(1); expect(detectInstallSource).not.toHaveBeenCalled(); @@ -2090,7 +1130,7 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).toHaveBeenCalledWith( expect.stringMatching(/^npm(\.cmd)?$/), ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + { detached: true, stdio: 'ignore' }, ); expect(track).toHaveBeenCalledWith('update_background_install_started', expect.objectContaining({ target_version: '0.5.0', @@ -2111,303 +1151,31 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(readUpdateCache).not.toHaveBeenCalled(); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('treats any plan older than 24h as fully rolled out', async () => { - disableAutoInstall(); - const staleRollout = manifestFor('0.5.0', { - publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(), - rollout: [ - { percent: 30, delaySeconds: 0 }, - { percent: 30, delaySeconds: 43_200 }, - { percent: 40, delaySeconds: 86_400 }, - ], - }); - mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.promptForInstallChoice.mockResolvedValue('skip'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - - expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( - expect.objectContaining({ target: { version: '0.5.0' } }), - ); - }); - }); - - describe('background installer progress lines', () => { - it('reassembles a progress line split across data events into one update', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithChunkedStderr(0, [ - 'progress: state=downloading percent=4', - '2 transferred=5320', - '000 total=12600000\n', - ]); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ - state: 'downloading', - percent: 42, - transferred: 5_320_000, - total: 12_600_000, - }), - }), - })); - }); - - /** - * The exact bytes a real `install.sh` run emitted while downloading the - * 0.9.2 release, captured from its stderr. Pinning them here means the - * emitter and this parser cannot drift apart silently. - */ - it('parses the bytes a real installer run actually emitted', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr( - 0, - 'progress: state=downloading percent=0 transferred=0 total=55795679\n' - + 'progress: state=downloading percent=49 transferred=27103232 total=55795679\n' - + 'progress: state=done transferred=55795679\n', - ); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - // All three lines arrive in one chunk, so the 2-second write throttle - // keeps the first downloading update and drops the second; the terminal - // state always bypasses the throttle. - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ - state: 'downloading', - percent: 0, - transferred: 0, - total: 55_795_679, - }), - }), - })); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ state: 'done', transferred: 55_795_679 }), - }), - })); - }); - - /** - * The state file is written as a temp file plus rename, so the last rename - * wins. The installer's terminal `state=done` line writes just as the child - * exits, so an unawaited progress write can rename over the outcome — - * restoring `active` and dropping `lastSuccess`. The next launch reads that - * as an abandoned install and records a failure for a version that - * installed cleanly, which at two attempts parks it for good. - */ - it('never lets a slow progress write rename over the install outcome', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - // Hold the progress write open; every other write settles at once. - let releaseProgressWrite: (() => void) | undefined; - mocks.writeUpdateInstallState.mockImplementation( - (state: { active?: { progress?: unknown } | null }) => ( - state.active?.progress === undefined - ? Promise.resolve() - : new Promise<void>((resolve) => { releaseProgressWrite = resolve; }) - ), - ); - mockSpawnExitWithStderr(0, 'progress: state=done transferred=55795679\n'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - // The progress write is still in flight, so the outcome must not be out yet. - expect(progressActiveStates()).toHaveLength(1); - expect(successOutcomeStates()).toEqual([]); - - releaseProgressWrite?.(); - await flushBackgroundInstall(); - await flushBackgroundInstall(); - - expect(successOutcomeStates()).toHaveLength(1); - // The outcome is the last thing written, so it survives on disk. - expect(mocks.writeUpdateInstallState.mock.calls.at(-1)?.[0]).toMatchObject({ - active: null, - lastSuccess: expect.objectContaining({ version: '0.5.0' }), - }); - }); - - it('keeps progress lines out of the failure tail and ordinary stderr lines in it', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr( - 1, - 'progress: state=downloading percent=42 transferred=5320000 total=12600000\n' - + 'bash: line 900: BASH_SOURCE[0]: unbound variable\n', - ); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - const messages = progressFailureMessages(); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain('BASH_SOURCE[0]: unbound variable'); - expect(messages[0]).not.toContain('progress: state=downloading'); - expect(messages[0]).not.toContain('percent=42'); - }); - - it('leaves the real error in the tail after a hundred progress lines', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - const progressLines = Array.from({ length: 100 }, (_, i) => ( - `progress: state=downloading percent=${i} transferred=${(i + 1) * 1000} total=12600000\n` - )).join(''); - mockSpawnExitWithStderr(1, `${progressLines}npm ERR! real failure\n`); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - const messages = progressFailureMessages(); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain('npm ERR! real failure'); - expect(messages[0]).not.toContain('progress:'); - expect(messages[0]).not.toContain('percent='); - }); - - it('parses the exact lines install.ps1 and install.sh emit', async () => { - // Captured from a real run of the installer's progress helpers. Windows - // shipped without these lines, which is why an update in flight looked - // identical to a wedged one there. Drift on either side fails here. - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr( - 0, - 'progress: state=waiting retry_in=4 elapsed=8\n' - + 'progress: state=downloading transferred=5242880\n' - + 'progress: state=downloading percent=25 transferred=5242880 total=20971520\n' - + 'progress: state=done transferred=20971520\n', - ); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - const states = progressActiveStates() as Array<{ - active: { progress: { state: string; percent?: number; transferred?: number } }; - }>; - expect(states[0]?.active.progress).toMatchObject({ state: 'waiting' }); - expect(states.at(-1)?.active.progress).toMatchObject({ - state: 'done', - transferred: 20_971_520, - }); - // The downloading lines in between are dropped by the 2s write throttle, - // not by the parser — a line it could not read would throw instead. - expect(states).toHaveLength(2); - }); - - it('ignores unknown keys and non-numeric percent values without throwing', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr( - 0, - 'progress: state=downloading percent=not-a-number transferred=5320000 total=12600000 mystery=1\n', - ); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ - state: 'downloading', - transferred: 5_320_000, - total: 12_600_000, - percent: undefined, - }), - }), - })); - }); - - it('accepts a downloading update without a total and without a percent', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithStderr(0, 'progress: state=downloading transferred=5320000\n'); - const { options } = captureOutput(); - - await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await flushBackgroundInstall(); - - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ - state: 'downloading', - transferred: 5_320_000, - percent: undefined, - total: undefined, - }), - }), - })); + expect(readUpdateCache).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('throttles progress writes to one per two seconds but never drops the terminal update', async () => { - mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.readUpdateInstallState.mockResolvedValue(installState()); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExitWithTimedStderr( - 0, - [ - { atMs: 0, text: 'progress: state=downloading percent=10 transferred=1000 total=10000\n' }, - { atMs: 100, text: 'progress: state=downloading percent=20 transferred=2000 total=10000\n' }, - { atMs: 250, text: 'progress: state=done transferred=10000\n' }, + it('treats any plan older than 24h as fully rolled out', async () => { + disableAutoInstall(); + const staleRollout = manifestFor('0.5.0', { + publishedAt: new Date(Date.now() - 25 * 3_600 * 1_000).toISOString(), + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, ], - 320, - ); + }); + mocks.readUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(staleRollout)); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - await new Promise((resolve) => setTimeout(resolve, 500)); - const progressStates = progressActiveStates(); - expect(progressStates).toHaveLength(2); - expect(progressStates[0]).toEqual(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ state: 'downloading', percent: 10 }), - }), - })); - expect(progressStates[1]).toEqual(expect.objectContaining({ - active: expect.objectContaining({ - progress: expect.objectContaining({ state: 'done', transferred: 10_000 }), - }), - })); + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ target: { version: '0.5.0' } }), + ); }); }); }); @@ -2418,8 +1186,8 @@ describe('spawnForSource native', () => { // so a curl that never connects (exit 7, empty stdin → bash exits 0) is // masked and the update is wrongly reported as successful. `set -o pipefail` // makes the pipeline surface curl's failure. Shadowing `curl` with a shell - // function keeps this offline and deterministic; skipped on Windows (no bash - // to run this script with). + // function keeps this offline and deterministic; skipped on Windows (no bash, + // and native auto-install is unsupported there anyway). it.skipIf(process.platform === 'win32')( 'surfaces a failed curl download as a non-zero exit', () => { @@ -2430,441 +1198,4 @@ describe('spawnForSource native', () => { expect(result.status).toBeGreaterThan(0); }, ); - - it('darwin/linux: unchanged bash -c pipeline', () => { - const { cmd, args } = spawnForSource('native', '0.5.0', 'darwin'); - expect(cmd).toBe('bash'); - expect(args[0]).toBe('-c'); - expect(args[1]).toContain('curl -fsSL https://code.pythinker.com/pythinker-code/install.sh'); - }); - - it('win32: powershell.exe with -ExecutionPolicy Bypass and the irm|iex install command', () => { - const { cmd, args, env } = spawnForSource('native', '0.5.0', 'win32'); - expect(cmd).toBe('powershell.exe'); - expect(args).toEqual([ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'irm https://code.pythinker.com/pythinker-code/install.ps1 | iex', - ]); - // install.ps1 reads $env:PYTHINKER_VERSION instead of fetching the CDN's - // current latest, so the selected update version is the one installed. - expect(env).toEqual({ PYTHINKER_VERSION: '0.5.0' }); - }); - - it('darwin/linux: no version env override (install.sh has no such hook)', () => { - const { env } = spawnForSource('native', '0.5.0', 'darwin'); - expect(env).toBeUndefined(); - }); -}); - -describe('windows package-manager shims', () => { - // Node >=18.20/20.12 refuses to spawn a .cmd directly (CVE-2024-27980), so - // every npm-family update on Windows failed with EINVAL. The command - // interpreter runs them, and the exact argv is asserted here because it - // cannot be exercised from a non-Windows test run. - it('runs npm.cmd through the command interpreter', () => { - const { cmd, args } = spawnForSource('npm-global', '0.5.0', 'win32'); - expect(cmd.toLowerCase()).toContain('cmd.exe'); - expect(args).toEqual([ - '/d', - '/s', - '/c', - 'npm.cmd', - 'install', - '-g', - '@pymodel/pythinker-code@0.5.0', - ]); - }); - - it('runs pnpm.cmd and yarn.cmd the same way', () => { - expect(spawnForSource('pnpm-global', '0.5.0', 'win32').args).toEqual([ - '/d', '/s', '/c', 'pnpm.cmd', 'add', '-g', '@pymodel/pythinker-code@0.5.0', - ]); - expect(spawnForSource('yarn-global', '0.5.0', 'win32').args).toEqual([ - '/d', '/s', '/c', 'yarn.cmd', 'global', 'add', '@pymodel/pythinker-code@0.5.0', - ]); - }); - - it('leaves real executables alone', () => { - expect(spawnForSource('bun-global', '0.5.0', 'win32')).toEqual({ - cmd: 'bun.exe', - args: ['add', '-g', '@pymodel/pythinker-code@0.5.0'], - }); - expect(spawnForSource('native', '0.5.0', 'win32').cmd).toBe('powershell.exe'); - }); - - it('never wraps anything off Windows', () => { - expect(spawnForSource('npm-global', '0.5.0', 'darwin')).toEqual({ - cmd: 'npm', - args: ['install', '-g', '@pymodel/pythinker-code@0.5.0'], - }); - expect(isWindowsShim('npm.cmd', 'darwin')).toBe(false); - expect(isWindowsShim('npm.cmd', 'win32')).toBe(true); - expect(isWindowsShim('powershell.exe', 'win32')).toBe(false); - }); -}); - -describe('canAutoInstall native', () => { - it('is true on win32 (rename-aside replace no longer needs the platform gate)', () => { - expect(canAutoInstall('native', 'win32')).toBe(true); - }); - - it('is true on darwin/linux', () => { - expect(canAutoInstall('native', 'darwin')).toBe(true); - expect(canAutoInstall('native', 'linux')).toBe(true); - }); -}); - -describe('startManualUpdate', () => { - beforeEach(() => { - mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); - mocks.writeUpdateInstallState.mockResolvedValue(undefined); - mocks.readJsonFile.mockResolvedValue(null); - mocks.writeJsonFile.mockResolvedValue(undefined); - mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); - mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); - mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); - mocks.verifyInstalledVersion.mockResolvedValue({ ok: true }); - mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release: vi.fn().mockResolvedValue(undefined), - }); - }); - - afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); - - it('reports up-to-date when the registry has nothing newer', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.4.0')); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'up-to-date' }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('native: reports up-to-date when the manifest omits the running platform', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); - mocks.detectInstallSource.mockResolvedValue('native'); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'up-to-date' }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('native: starts a background install when the manifest advertises the running platform', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestForRunningTarget('0.5.0'))); - mocks.detectInstallSource.mockResolvedValue('native'); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - await flushBackgroundInstall(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('npm-global: still starts the update when the manifest omits the running platform', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - }); - - it('starts a background install for an auto-installable source', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - await flushBackgroundInstall(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('ignores the rollout hold — an explicit request installs immediately', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(heldForEveryone('0.5.0'))); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - }); - - it('ignores auto_install=false — the user explicitly asked to update', async () => { - disableAutoInstall(); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - }); - - it('prepares a Homebrew update for installation on the next launch', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('homebrew'); - const child = Object.assign(new EventEmitter(), { pid: 42_424, unref: vi.fn() }); - mocks.spawn.mockImplementation(() => { - queueMicrotask(() => { child.emit('spawn'); }); - return child; - }); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: true, - }); - expect(mocks.spawn).toHaveBeenCalledWith( - process.execPath, - [ - process.argv[1], - '__update_helper', - 'prepare-homebrew', - expect.any(String), - '0.5.0', - 'manual', - ], - expect.objectContaining({ detached: true, stdio: 'ignore' }), - ); - expect(mocks.spawn).toHaveBeenCalledOnce(); - }); - - it('clears the preparation lease when the detached helper cannot start', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('homebrew'); - mocks.spawn.mockImplementation(() => { throw new Error('spawn failed'); }); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'check-failed', - message: 'spawn failed', - }); - expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ - active: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - operation: 'prepare', - message: 'spawn failed', - }), - })); - }); - - it('promotes a prepared automatic update when the user explicitly requests it', async () => { - const pending = preparedHomebrewUpdate(); - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('homebrew'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ pending })); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.5.0', - installOnRestart: true, - readyToInstall: true, - }); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - pending: { ...pending, requestedBy: 'manual' }, - })); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('reports an install already in progress instead of double-starting', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { version: '0.5.0', source: 'npm-global', startedAt: new Date().toISOString() }, - })); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.5.0', - installOnRestart: false, - readyToInstall: false, - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('reports both the running older install and the newer target it will follow', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { version: '0.10.0', source: 'npm-global', startedAt: new Date().toISOString() }, - })); - - await expect(startManualUpdate('0.9.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.10.0', - targetVersion: '0.11.0', - installOnRestart: false, - readyToInstall: false, - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('does not claim the target supersedes an active install of a newer version', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.10.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { version: '0.11.0', source: 'npm-global', startedAt: new Date().toISOString() }, - })); - - await expect(startManualUpdate('0.9.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.11.0', - installOnRestart: false, - readyToInstall: false, - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('keeps installOnRestart for a fresh homebrew active install', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('homebrew'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { version: '0.5.0', source: 'homebrew', startedAt: new Date().toISOString() }, - })); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.5.0', - installOnRestart: true, - readyToInstall: false, - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('reports a parked version as failed with the recorded attempts and reason', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-08-05T08:00:00.000Z', - attempts: 2, - operation: 'install', - message: 'npm exited with code 1', - }, - })); - - const result = await startManualUpdate('0.4.0'); - expect(result).toEqual({ - status: 'failed', - version: '0.5.0', - attempts: 2, - failedAt: '2026-08-05T08:00:00.000Z', - message: 'npm exited with code 1', - command: 'npm install -g @pymodel/pythinker-code@0.5.0', - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('still attempts the install one failure below the parked threshold', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-08-05T08:00:00.000Z', - attempts: 1, - message: 'npm exited with code 1', - }, - })); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - await flushBackgroundInstall(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('still attempts the install when the parked failures belong to another version', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.4.1', - failedAt: '2026-08-05T08:00:00.000Z', - attempts: 2, - message: 'npm exited with code 1', - }, - })); - mockSpawnExit(0); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'started', - version: '0.5.0', - installOnRestart: false, - }); - await flushBackgroundInstall(); - expect(mocks.spawn).toHaveBeenCalledTimes(1); - }); - - it('reports in-progress when a fresh install runs despite a parked failure', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - active: { version: '0.5.0', source: 'npm-global', startedAt: new Date().toISOString() }, - lastFailure: { - version: '0.5.0', - failedAt: '2026-08-05T08:00:00.000Z', - attempts: 2, - message: 'npm exited with code 1', - }, - })); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'in-progress', - installingVersion: '0.5.0', - installOnRestart: false, - readyToInstall: false, - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('omits the reason when the recorded failure carries none', async () => { - mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); - mocks.detectInstallSource.mockResolvedValue('npm-global'); - mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { - version: '0.5.0', - failedAt: '2026-08-05T08:00:00.000Z', - attempts: 2, - }, - })); - - const result = await startManualUpdate('0.4.0'); - expect(result).toEqual({ - status: 'failed', - version: '0.5.0', - attempts: 2, - failedAt: '2026-08-05T08:00:00.000Z', - command: 'npm install -g @pymodel/pythinker-code@0.5.0', - }); - expect(mocks.spawn).not.toHaveBeenCalled(); - }); - - it('reports check-failed when the registry refresh fails', async () => { - mocks.refreshUpdateCache.mockRejectedValue(new Error('offline')); - - await expect(startManualUpdate('0.4.0')).resolves.toEqual({ - status: 'check-failed', - message: 'offline', - }); - }); }); diff --git a/apps/pythinker-code/test/cli/update/prompt.test.ts b/apps/pythinker-code/test/cli/update/prompt.test.ts index 37f5eb37..bf890286 100644 --- a/apps/pythinker-code/test/cli/update/prompt.test.ts +++ b/apps/pythinker-code/test/cli/update/prompt.test.ts @@ -34,7 +34,7 @@ describe('install prompt helpers', () => { describe('promptForInstallChoice', () => { it('renders changelog hyperlink in the prompt output', async () => { - const CHANGELOG_URL = 'https://pymodel.github.io/pythinker-code/release-notes/changelog.html'; + const CHANGELOG_URL = 'https://code.pythinker.com/pythinker-code/en/release-notes/changelog.html'; const input = Object.assign(new EventEmitter(), { isRaw: false, diff --git a/apps/pythinker-code/test/cli/update/refresh.test.ts b/apps/pythinker-code/test/cli/update/refresh.test.ts index f0061006..ceb1306f 100644 --- a/apps/pythinker-code/test/cli/update/refresh.test.ts +++ b/apps/pythinker-code/test/cli/update/refresh.test.ts @@ -17,7 +17,7 @@ describe('refreshUpdateCache', () => { it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchManifest: async () => MANIFEST, + fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -31,23 +31,28 @@ describe('refreshUpdateCache', () => { expect(writeCache).toHaveBeenCalledWith(result); }); - it('takes `latest` from the manifest rather than a separate field', async () => { + it('writes a null manifest when the fetch fell back to plain text', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchManifest: async () => ({ ...MANIFEST, version: '0.6.0' }), + fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); - expect(result.latest).toBe('0.6.0'); - expect(result.manifest?.version).toBe('0.6.0'); + expect(result).toEqual({ + source: 'cdn', + checkedAt: '2026-05-20T12:34:56.000Z', + latest: '0.5.0', + manifest: null, + }); + expect(writeCache).toHaveBeenCalledWith(result); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { const writeCache = vi.fn(async () => {}); await expect( refreshUpdateCache({ - fetchManifest: async () => { + fetchLatest: async () => { throw new Error('network down'); }, writeCache, diff --git a/apps/pythinker-code/test/cli/update/rollout.test.ts b/apps/pythinker-code/test/cli/update/rollout.test.ts index c88f9e78..0664fad1 100644 --- a/apps/pythinker-code/test/cli/update/rollout.test.ts +++ b/apps/pythinker-code/test/cli/update/rollout.test.ts @@ -246,89 +246,6 @@ describe('decidePassiveUpdateTarget', () => { delaySeconds: 43_200, }); }); - - it('returns the target with reason required while the batch is still held', () => { - const manifest = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 86_400 }], - minRequiredVersion: '1.9.0', - }); - const decision = decidePassiveUpdateTarget( - '1.0.0', - '2.0.0', - manifest, - 'device-a', - secondsAfterPublish(60), - ); - expect(decision).toMatchObject({ - target: { version: '2.0.0' }, - reason: 'required', - bucket: rolloutBucket('device-a', '2.0.0'), - delaySeconds: 86_400, - eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), - }); - }); - - it('returns the target with reason required even when already eligible', () => { - const manifest = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 0 }], - minRequiredVersion: '1.9.0', - }); - const decision = decidePassiveUpdateTarget( - '1.0.0', - '2.0.0', - manifest, - 'device-a', - secondsAfterPublish(60), - ); - expect(decision).toMatchObject({ - target: { version: '2.0.0' }, - reason: 'required', - bucket: rolloutBucket('device-a', '2.0.0'), - delaySeconds: 0, - }); - }); - - it('applies ordinary eligibility rules when running at minRequiredVersion', () => { - const held = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 86_400 }], - minRequiredVersion: '1.0.0', - }); - expect( - decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), - ).toMatchObject({ target: null, reason: 'held' }); - const immediate = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 0 }], - minRequiredVersion: '1.0.0', - }); - expect( - decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), - ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); - }); - - it('applies ordinary eligibility rules when running above minRequiredVersion', () => { - const manifest = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 86_400 }], - minRequiredVersion: '0.9.0', - }); - expect( - decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', secondsAfterPublish(60)), - ).toMatchObject({ target: null, reason: 'held' }); - }); - - it('behaves exactly as before when the manifest declares no minRequiredVersion', () => { - const held = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); - expect( - decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), - ).toMatchObject({ - target: null, - reason: 'held', - bucket: rolloutBucket('device-a', '2.0.0'), - }); - const immediate = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); - expect( - decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), - ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); - }); }); describe('appendRolloutDecisionLog', () => { @@ -414,21 +331,6 @@ describe('experimental flag bypass', () => { }); }); - it('still reports experimental under bypass when below minRequiredVersion', () => { - const manifest = makeManifest({ - rollout: [{ percent: 100, delaySeconds: 86_400 }], - minRequiredVersion: '1.9.0', - }); - const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now, true); - expect(decision).toMatchObject({ - target: { version: '2.0.0' }, - reason: 'experimental', - bucket: null, - delaySeconds: null, - eligibleAt: null, - }); - }); - it('still reports not-newer / no-latest under bypass', () => { expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ target: null, diff --git a/apps/pythinker-code/test/cli/update/select.test.ts b/apps/pythinker-code/test/cli/update/select.test.ts index 6eb72a71..a616a486 100644 --- a/apps/pythinker-code/test/cli/update/select.test.ts +++ b/apps/pythinker-code/test/cli/update/select.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; -import type { UpdateManifest } from '#/cli/update/types'; +import { selectUpdateTarget } from '#/cli/update/select'; describe('selectUpdateTarget', () => { it('returns the latest version when it is newer than current', () => { @@ -33,51 +32,3 @@ describe('selectUpdateTarget', () => { expect(selectUpdateTarget('0.5.0', '0.5.0-rc.1')).toBeNull(); }); }); - -describe('isTargetInstallable', () => { - const artifact = { - url: 'https://code.pythinker.com/pythinker-code-0.5.0.zip', - sha256: 'a'.repeat(64), - }; - - function manifestWithPlatforms(platforms: Record<string, { url: string; sha256: string }>): UpdateManifest { - return { - version: '0.5.0', - publishedAt: '2020-01-01T00:00:00.000Z', - rollout: [], - platforms, - }; - } - - function manifestOmittingRunningTarget(): UpdateManifest { - const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; - return manifestWithPlatforms({ [`${process.platform}-${otherArch}`]: artifact }); - } - - it('native: returns false when the manifest omits the running target', () => { - expect(isTargetInstallable('native', manifestOmittingRunningTarget())).toBe(false); - }); - - it('native: returns true when the manifest has an entry for the running target', () => { - expect( - isTargetInstallable('native', manifestWithPlatforms({ [`${process.platform}-${process.arch}`]: artifact })), - ).toBe(true); - }); - - it('native: returns true for a null manifest', () => { - expect(isTargetInstallable('native', null)).toBe(true); - }); - - it('native: returns true for a manifest with no platforms key', () => { - const manifest: UpdateManifest = { - version: '0.5.0', - publishedAt: '2020-01-01T00:00:00.000Z', - rollout: [], - }; - expect(isTargetInstallable('native', manifest)).toBe(true); - }); - - it('npm-global: returns true even when the manifest omits the running target', () => { - expect(isTargetInstallable('npm-global', manifestOmittingRunningTarget())).toBe(true); - }); -}); diff --git a/apps/pythinker-code/test/cli/update/source.test.ts b/apps/pythinker-code/test/cli/update/source.test.ts index 01498082..881a65f4 100644 --- a/apps/pythinker-code/test/cli/update/source.test.ts +++ b/apps/pythinker-code/test/cli/update/source.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { classifyByPathHeuristic, classifyInstallSource, detectInstallSource, } from '#/cli/update/source'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: vi.fn(), +})); describe('classifyByPathHeuristic', () => { it('returns null for an npm-style global path (handled by classifyInstallSource)', () => { @@ -85,22 +90,6 @@ describe('classifyInstallSource (npm prefix matching)', () => { }); describe('detectInstallSource', () => { - // Every launch calls this. A layout with no reachable package.json used to - // throw out of the preflight instead of classifying as unsupported. - it('returns unsupported when the package root cannot be resolved', async () => { - await expect( - detectInstallSource({ - getPackageRoot: () => { - throw new Error('Could not locate package.json near /opt/pythinker'); - }, - getGlobalPrefix: async () => '/usr/local', - detectNative: () => false, - platform: 'linux', - }), - ).resolves.toBe('unsupported'); - }); - - it('returns pnpm-global when packageRoot matches pnpm heuristic', async () => { await expect( detectInstallSource({ @@ -192,4 +181,19 @@ describe('detectInstallSource', () => { }), ).resolves.toBe('unsupported'); }); + + it('returns unsupported when npm cannot be resolved outside the cwd', async () => { + // The default prefix lookup spawns npm; when it can only be found inside + // the current directory (or not at all), detection must degrade to + // 'unsupported' rather than run a planted binary. + vi.mocked(resolveCommandPath).mockReturnValue(undefined); + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/dev/@pymodel/pythinker-code', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('unsupported'); + expect(resolveCommandPath).toHaveBeenCalledWith('npm'); + }); }); diff --git a/apps/pythinker-code/test/cli/update/update-helper.test.ts b/apps/pythinker-code/test/cli/update/update-helper.test.ts deleted file mode 100644 index d53df6dc..00000000 --- a/apps/pythinker-code/test/cli/update/update-helper.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { spawn } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { activatePendingUpdate } from '#/cli/update/activation'; -import { readUpdateInstallState, writeUpdateInstallState } from '#/cli/update/install-state'; -import { runUpdateHelper } from '#/cli/update/update-helper'; -import type { UpdatePreparedHomebrew } from '#/cli/update/types'; - -const mocks = vi.hoisted(() => ({ - prepareHomebrewUpdate: vi.fn(), -})); - -vi.mock('#/cli/update/homebrew', async () => { - const actual = await vi.importActual<typeof import('#/cli/update/homebrew')>( - '#/cli/update/homebrew', - ); - return { - ...actual, - prepareHomebrewUpdate: mocks.prepareHomebrewUpdate, - }; -}); - -const JOB_ID = '7e717f78-70c6-4f7c-9745-ceb45822d24b'; -let dir: string; - -function preparedUpdate(): UpdatePreparedHomebrew { - return { - jobId: JOB_ID, - source: 'homebrew', - version: '0.5.0', - preparedAt: '2026-08-04T08:00:00.000Z', - requestedBy: 'automatic', - formulaUrl: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - artifactKind: 'source', - artifactSha256: 'a'.repeat(64), - formulaFileSha256: 'b'.repeat(64), - artifactPath: '/tmp/cache/pythinker-code-0.5.0.tgz', - }; -} - -beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'pythinker-update-helper-')); - process.env['PYTHINKER_CODE_HOME'] = dir; - await writeUpdateInstallState({ - active: { - version: '0.5.0', - source: 'homebrew', - operation: 'prepare', - jobId: JOB_ID, - startedAt: '2026-08-04T07:59:00.000Z', - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }); -}); - -afterEach(async () => { - delete process.env['PYTHINKER_CODE_HOME']; - await rm(dir, { recursive: true, force: true }); - vi.clearAllMocks(); -}); - -describe('update helper', () => { - it('owns preparation completion after the launching process hands off', async () => { - mocks.prepareHomebrewUpdate.mockImplementation(async () => { - const running = await readUpdateInstallState(); - expect(running.active).toEqual(expect.objectContaining({ - jobId: JOB_ID, - operation: 'prepare', - pid: process.pid, - })); - return preparedUpdate(); - }); - - await expect( - runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), - ).resolves.toBe(0); - - expect(mocks.prepareHomebrewUpdate).toHaveBeenCalledWith( - { jobId: JOB_ID, requestedVersion: '0.5.0', requestedBy: 'automatic' }, - expect.anything(), - ); - await expect(readUpdateInstallState()).resolves.toEqual({ - active: null, - pending: preparedUpdate(), - lastFailure: null, - lastSuccess: null, - }); - }); - - it('persists a preparation failure with a retry count and diagnostic message', async () => { - mocks.prepareHomebrewUpdate.mockRejectedValue(new Error('formula checksum mismatch')); - - await expect( - runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), - ).resolves.toBe(1); - - await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ - active: null, - pending: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 1, - operation: 'prepare', - message: 'formula checksum mismatch', - }), - })); - }); - - it('rejects malformed helper arguments without changing install state', async () => { - const before = await readUpdateInstallState(); - - await expect(runUpdateHelper(['prepare-homebrew', 'bad-id', 'nope'])).resolves.toBe(2); - - await expect(readUpdateInstallState()).resolves.toEqual(before); - expect(mocks.prepareHomebrewUpdate).not.toHaveBeenCalled(); - }); - - it('requires the active source and version to match the helper request', async () => { - const mismatched = await readUpdateInstallState(); - await writeUpdateInstallState({ - ...mismatched, - active: { - version: '0.6.0', - source: 'npm-global', - operation: 'prepare', - jobId: JOB_ID, - startedAt: '2026-08-04T07:59:00.000Z', - }, - }); - - await expect( - runUpdateHelper(['prepare-homebrew', JOB_ID, '0.5.0', 'automatic']), - ).resolves.toBe(0); - - expect(mocks.prepareHomebrewUpdate).not.toHaveBeenCalled(); - await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ - active: expect.objectContaining({ source: 'npm-global', version: '0.6.0' }), - pending: null, - })); - }); - - // The fake `brew` uses a POSIX shebang, chmod, and `:` PATH separators. - it.skipIf(process.platform === 'win32')( - 'finishes preparation in a detached process after its parent exits', - async () => { - const fakeBin = join(dir, 'bin'); - const fixtureDir = join(dir, 'fixtures'); - const formulaPath = join(fixtureDir, 'pythinker-code.rb'); - const artifactPath = join(fixtureDir, 'pythinker-code-0.5.0.tgz'); - const artifact = Buffer.from('verified package archive'); - const artifactSha256 = createHash('sha256').update(artifact).digest('hex'); - await Promise.all([mkdir(fakeBin), mkdir(fixtureDir)]); - await writeFile(formulaPath, 'class PythinkerCode < Formula\nend\n'); - - const fakeBrewPath = join(fakeBin, 'brew'); - await writeFile(fakeBrewPath, `#!/usr/bin/env node -import { writeFile } from 'node:fs/promises'; -const args = process.argv.slice(2).join(' '); -if (args === 'update') process.exit(0); -if (args === 'info --json=v2 pythinker-code') { - process.stdout.write(${JSON.stringify(homebrewInfoFixture(artifactSha256))}); - process.exit(0); -} -if (args === 'formula pythinker-code') { - process.stdout.write(${JSON.stringify(`${formulaPath}\n`)}); - process.exit(0); -} -if (args === '--cache --build-from-source --formula pythinker-code') { - process.stdout.write(${JSON.stringify(`${artifactPath}\n`)}); - process.exit(0); -} -if (args === '--prefix pythinker-code') { - process.stdout.write('/opt/homebrew/opt/pythinker-code\\n'); - process.exit(0); -} -if (args === 'fetch --build-from-source --retry --formula pythinker-code') { - await new Promise((resolve) => setTimeout(resolve, 250)); - await writeFile(${JSON.stringify(artifactPath)}, Buffer.from('verified package archive')); - process.exit(0); -} -process.stderr.write('unexpected fake brew command: ' + args + '\\n'); -process.exit(1); -`); - await chmod(fakeBrewPath, 0o755); - - const repoRoot = resolve(import.meta.dirname, '../../../../..'); - const appRoot = join(repoRoot, 'apps', 'pythinker-code'); - const rawTextLoader = join(repoRoot, 'build', 'register-raw-text-loader.mjs'); - const mainPath = join(appRoot, 'src', 'main.ts'); - const tsconfigPath = join(dir, 'tsx-tsconfig.json'); - await writeFile(tsconfigPath, JSON.stringify({ - extends: join(appRoot, 'tsconfig.json'), - include: [join(appRoot, 'src/**/*.ts'), join(repoRoot, 'packages/**/*.ts')], - })); - const helperOutputPath = join(dir, 'helper-output.log'); - const parentPath = join(dir, 'detached-parent.mjs'); - await writeFile(parentPath, ` -import { spawn } from 'node:child_process'; -import { closeSync, openSync } from 'node:fs'; -const output = openSync(${JSON.stringify(helperOutputPath)}, 'a'); -const child = spawn(process.execPath, [ - '--import', ${JSON.stringify(rawTextLoader)}, - '--import', 'tsx', - ${JSON.stringify(mainPath)}, - '__update_helper', - 'prepare-homebrew', - ${JSON.stringify(JOB_ID)}, - '0.5.0', - 'automatic', -], { detached: true, env: process.env, stdio: ['ignore', output, output] }); -child.once('error', () => { process.exitCode = 1; }); -child.once('spawn', () => { child.unref(); closeSync(output); }); -`); - - await runProcess(process.execPath, [parentPath], { - ...process.env, - PATH: `${fakeBin}:${process.env['PATH'] ?? ''}`, - PYTHINKER_CODE_HOME: dir, - PYTHINKER_CODE_UPDATE_HELPER: '1', - TSX_TSCONFIG_PATH: tsconfigPath, - }); - - try { - await vi.waitFor(async () => { - const state = await readUpdateInstallState(); - expect(state.pending).toEqual(expect.objectContaining({ - jobId: JOB_ID, - version: '0.5.0', - requestedBy: 'automatic', - artifactSha256, - })); - expect(state.active).toBeNull(); - }, { timeout: 8_000, interval: 50 }); - } catch (error) { - const helperOutput = await readFile(helperOutputPath, 'utf-8').catch(() => '<missing>'); - throw new Error(`detached helper did not finish: ${helperOutput}`, { cause: error }); - } - - await expect(activatePendingUpdate('0.4.0', { - enabled: true, - automaticEnabled: true, - deps: { - detectSource: async () => 'homebrew', - activateHomebrew: async (prepared) => ({ - version: prepared.version, - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }), - }, - })).resolves.toEqual({ - status: 'activated', - version: '0.5.0', - executable: '/opt/homebrew/opt/pythinker-code/bin/pythinker', - }); - - await expect(activatePendingUpdate('0.5.0', { - enabled: true, - automaticEnabled: true, - deps: { detectSource: async () => 'homebrew' }, - })).resolves.toEqual({ status: 'finalized', version: '0.5.0' }); - await expect(readUpdateInstallState()).resolves.toEqual(expect.objectContaining({ - active: null, - pending: null, - lastSuccess: expect.objectContaining({ version: '0.5.0' }), - })); - }, 12_000); -}); - -function homebrewInfoFixture(artifactSha256: string): string { - return JSON.stringify({ - formulae: [{ - name: 'pythinker-code', - versions: { stable: '0.5.0' }, - urls: { - stable: { - url: 'https://registry.example.com/pythinker-code-0.5.0.tgz', - checksum: artifactSha256, - }, - }, - linked_keg: '0.4.0', - pinned: false, - }], - }); -} - -async function runProcess( - command: string, - args: readonly string[], - env: NodeJS.ProcessEnv, -): Promise<void> { - await new Promise<void>((resolveProcess, reject) => { - const child = spawn(command, [...args], { env, stdio: 'ignore' }); - child.once('error', reject); - child.once('exit', (code) => { - if (code === 0) { - resolveProcess(); - return; - } - reject(new Error(`${command} exited with code ${String(code)}`)); - }); - }); -} diff --git a/apps/pythinker-code/test/cli/update/verify-install.test.ts b/apps/pythinker-code/test/cli/update/verify-install.test.ts deleted file mode 100644 index 7095176c..00000000 --- a/apps/pythinker-code/test/cli/update/verify-install.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { parseVersionOutput, verifyInstalledVersion } from '#/cli/update/verify-install'; - -const NEVER_PROBED = { - probeExecutableVersion: vi.fn(async () => { - throw new Error('the binary must not be probed for this source'); - }), -}; - -describe('parseVersionOutput', () => { - it('reads the bare version Commander prints', () => { - expect(parseVersionOutput('0.13.1\n')).toBe('0.13.1'); - }); - - it('finds the version inside surrounding text', () => { - expect(parseVersionOutput('Pythinker Code v1.2.3 (build 9)')).toBe('1.2.3'); - }); - - it('keeps a prerelease suffix', () => { - expect(parseVersionOutput('2.0.0-rc.1')).toBe('2.0.0-rc.1'); - }); - - it('returns null when there is no version to read', () => { - expect(parseVersionOutput('command not found')).toBeNull(); - }); -}); - -describe('verifyInstalledVersion native', () => { - it('reports the mismatch when the binary still runs the old version', async () => { - const result = await verifyInstalledVersion('native', '0.13.1', { - execPath: 'C:\\Programs\\Pythinker\\pythinker.exe', - probeExecutableVersion: async () => '0.12.0\n', - }); - - expect(result).toEqual({ - ok: false, - reason: expect.stringContaining('still reports 0.12.0 (expected 0.13.1)'), - }); - expect(result).toEqual({ ok: false, reason: expect.stringContaining('pythinker.exe') }); - }); - - it('accepts the install when the binary reports the target version', async () => { - await expect( - verifyInstalledVersion('native', '0.13.1', { - probeExecutableVersion: async () => 'v0.13.1', - }), - ).resolves.toEqual({ ok: true }); - }); - - it('probes the executable that was replaced', async () => { - const probe = vi.fn(async () => '0.13.1'); - await verifyInstalledVersion('native', '0.13.1', { - execPath: '/usr/local/bin/pythinker', - probeExecutableVersion: probe, - }); - - expect(probe).toHaveBeenCalledWith('/usr/local/bin/pythinker'); - }); - - // Fail open, but say so: an antivirus scan or a slow first start must never - // turn a good install into a recorded failure that parks the version after - // two attempts — and the note is what makes the next report diagnosable. - it('accepts the install unverified when the probe cannot run', async () => { - await expect( - verifyInstalledVersion('native', '0.13.1', { - execPath: '/usr/local/bin/pythinker', - probeExecutableVersion: async () => { - throw new Error('ETIMEDOUT'); - }, - }), - ).resolves.toEqual({ - ok: true, - unverified: expect.stringContaining('/usr/local/bin/pythinker could not be run'), - }); - }); - - it('accepts the install unverified when the output carries no version', async () => { - await expect( - verifyInstalledVersion('native', '0.13.1', { - probeExecutableVersion: async () => '', - }), - ).resolves.toEqual({ ok: true, unverified: expect.stringContaining('printed no version') }); - }); - - it('checks nothing when the target is not a version', async () => { - await expect( - verifyInstalledVersion('native', 'latest', NEVER_PROBED), - ).resolves.toEqual({ ok: true }); - }); -}); - -describe('verifyInstalledVersion other sources', () => { - // A global reinstall rewrites the directory this process was loaded from, - // so nothing readable here proves what the next launch will run. - it('checks nothing for a source it cannot prove, without probing', async () => { - for (const source of [ - 'npm-global', - 'pnpm-global', - 'yarn-global', - 'bun-global', - 'homebrew', - 'unsupported', - ] as const) { - await expect( - verifyInstalledVersion(source, '0.13.1', NEVER_PROBED), - ).resolves.toEqual({ ok: true }); - } - expect(NEVER_PROBED.probeExecutableVersion).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/pythinker-code/test/cli/upgrade.test.ts b/apps/pythinker-code/test/cli/upgrade.test.ts index 9642af21..3bc44f64 100644 --- a/apps/pythinker-code/test/cli/upgrade.test.ts +++ b/apps/pythinker-code/test/cli/upgrade.test.ts @@ -1,11 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { handleUpgrade } from '#/cli/sub/upgrade'; -import { emptyUpdateInstallState } from '#/cli/update/install-state'; -import type { UpdateInstallLockHandle } from '#/cli/update/install-lock'; import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; -import type { InstallSource, UpdateCache, UpdateInstallState } from '#/cli/update/types'; -import type { InstallOutcome } from '#/cli/update/verify-install'; +import type { InstallSource, UpdateCache } from '#/cli/update/types'; function cacheWith( version: string | null, @@ -19,31 +16,6 @@ function cacheWith( }; } -function platformManifest(version: string, platform: string): UpdateCache['manifest'] { - return { - version, - publishedAt: '2020-01-01T00:00:00.000Z', - rollout: [], - platforms: { - [platform]: { - url: `https://code.pythinker.com/pythinker-code-${version}.zip`, - sha256: 'a'.repeat(64), - }, - }, - }; -} - -/** A manifest advertising an artifact for a platform other than the running one. */ -function manifestOmittingRunningTarget(version: string): UpdateCache['manifest'] { - const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; - return platformManifest(version, `${process.platform}-${otherArch}`); -} - -/** A manifest advertising an artifact for the running platform. */ -function manifestForRunningTarget(version: string): UpdateCache['manifest'] { - return platformManifest(version, `${process.platform}-${process.arch}`); -} - function captureOutput(): { stdout: string[]; stderr: string[]; @@ -70,14 +42,7 @@ function createDeps(overrides: { readonly source?: InstallSource; readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise<InstallPromptChoiceValue>; - readonly installUpdate?: ( - source: InstallSource, - version: string, - platform: NodeJS.Platform, - ) => Promise<InstallOutcome>; - readonly readUpdateInstallState?: () => Promise<UpdateInstallState>; - readonly writeUpdateInstallState?: (state: UpdateInstallState) => Promise<void>; - readonly tryAcquireUpdateInstallLock?: () => Promise<UpdateInstallLockHandle | null>; + readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise<void>; } = {}) { const installUpdate = overrides.installUpdate ?? @@ -85,7 +50,7 @@ function createDeps(overrides: { source: InstallSource, version: string, platform: NodeJS.Platform, - ) => Promise<InstallOutcome>>().mockResolvedValue({}); + ) => Promise<void>>().mockResolvedValue(undefined); return { refreshUpdateCache: vi @@ -95,16 +60,6 @@ function createDeps(overrides: { promptForInstallChoice: overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), installUpdate, - readUpdateInstallState: - overrides.readUpdateInstallState ?? vi.fn().mockResolvedValue(emptyUpdateInstallState()), - writeUpdateInstallState: - overrides.writeUpdateInstallState ?? vi.fn().mockResolvedValue(undefined), - tryAcquireUpdateInstallLock: - overrides.tryAcquireUpdateInstallLock ?? - vi.fn().mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release: vi.fn().mockResolvedValue(undefined), - }), track: vi.fn(), logger: { info: vi.fn(), @@ -217,28 +172,6 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @pymodel/pythinker-code@0.5.0'); }); - it('records why a manual install could not be verified', async () => { - const { writable } = captureOutput(); - const unverified = '/usr/local/bin/pythinker could not be run: ETIMEDOUT'; - const writeUpdateInstallState = vi.fn().mockResolvedValue(undefined); - // A native target without an artifact for this platform is refused before - // the install runs, so the manifest has to advertise the running one. - const deps = createDeps({ - latest: '0.5.0', - source: 'native', - manifest: manifestForRunningTarget('0.5.0'), - installUpdate: vi.fn().mockResolvedValue({ unverified }), - writeUpdateInstallState, - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin'); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - lastSuccess: expect.objectContaining({ version: '0.5.0', unverified }), - })); - }); - it('returns a failing exit code when the foreground install fails', async () => { const { stderr, writable } = captureOutput(); const deps = createDeps({ @@ -299,187 +232,4 @@ describe('handleUpgrade', () => { expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); expect(stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); }); - - it('native: refuses the update when the manifest omits the running platform', async () => { - const { stdout, writable } = captureOutput(); - const deps = createDeps({ - latest: '0.5.0', - source: 'native', - manifest: manifestOmittingRunningTarget('0.5.0'), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(deps.detectInstallSource).toHaveBeenCalledTimes(1); - expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); - expect(deps.installUpdate).not.toHaveBeenCalled(); - expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ - current_version: '0.4.0', - })); - expect(stdout.join('')).toContain('v0.5.0 is published but has no build for this platform yet.'); - }); - - it('native: installs when the manifest advertises the running platform', async () => { - const { stdout, writable } = captureOutput(); - const deps = createDeps({ - latest: '0.5.0', - source: 'native', - manifest: manifestForRunningTarget('0.5.0'), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin'); - expect(stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); - }); - - it('npm-global: still installs when the manifest omits the running platform', async () => { - const { stdout, writable } = captureOutput(); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - manifest: manifestOmittingRunningTarget('0.5.0'), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); - expect(deps.track).toHaveBeenCalledWith('upgrade_command_prompted', expect.objectContaining({ - target_version: '0.5.0', - source: 'npm-global', - })); - expect(stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); - }); - - it('refuses the install while a fresh active install for another version is running', async () => { - const { stderr, writable } = captureOutput(); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - readUpdateInstallState: vi.fn().mockResolvedValue({ - ...emptyUpdateInstallState(), - active: { - version: '0.5.1', - source: 'npm-global', - startedAt: new Date().toISOString(), - }, - }), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); - - expect(deps.installUpdate).not.toHaveBeenCalled(); - expect(deps.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); - expect(stderr.join('')).toContain('0.5.1'); - expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ - target_version: '0.5.0', - source: 'npm-global', - stage: 'install', - })); - }); - - it('refuses the install when another process holds the install lock', async () => { - const { stderr, writable } = captureOutput(); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue(null), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); - - expect(deps.installUpdate).not.toHaveBeenCalled(); - expect(stderr.join('')).not.toBe(''); - expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ - target_version: '0.5.0', - source: 'npm-global', - stage: 'install', - })); - }); - - it('takes the install lock, installs, and records the success', async () => { - const { stdout, writable } = captureOutput(); - const release = vi.fn().mockResolvedValue(undefined); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, - }), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(deps.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); - expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); - expect(release).toHaveBeenCalledOnce(); - expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: null, - lastFailure: null, - lastSuccess: { - version: '0.5.0', - installedAt: expect.any(String), - notifiedAt: null, - }, - })); - expect(stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); - }); - - it('releases the lock and records the failure when the foreground install fails', async () => { - const { stderr, writable } = captureOutput(); - const release = vi.fn().mockResolvedValue(undefined); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), - tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ - filePath: '/tmp/pythinker-update-install.lock', - release, - }), - readUpdateInstallState: vi.fn().mockResolvedValue({ - ...emptyUpdateInstallState(), - lastFailure: { - version: '0.5.0', - failedAt: '2026-04-23T08:00:00.000Z', - attempts: 1, - operation: 'install', - }, - }), - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); - - expect(release).toHaveBeenCalledOnce(); - expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: null, - lastFailure: expect.objectContaining({ - version: '0.5.0', - attempts: 2, - operation: 'install', - failedAt: expect.any(String), - message: 'npm exited with code 1', - }), - })); - expect(stderr.join('')).toContain( - 'warning: failed to install @pymodel/pythinker-code@0.5.0: npm exited with code 1', - ); - }); - - it('never writes an active record for the foreground install', async () => { - const { writable } = captureOutput(); - const writeUpdateInstallState = vi.fn().mockResolvedValue(undefined); - const deps = createDeps({ - latest: '0.5.0', - source: 'npm-global', - writeUpdateInstallState, - }); - - await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); - - expect(writeUpdateInstallState).toHaveBeenCalledTimes(1); - expect(writeUpdateInstallState.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ - active: null, - })); - }); }); diff --git a/apps/pythinker-code/test/cli/v2-run-print.test.ts b/apps/pythinker-code/test/cli/v2-run-print.test.ts new file mode 100644 index 00000000..b5075d39 --- /dev/null +++ b/apps/pythinker-code/test/cli/v2-run-print.test.ts @@ -0,0 +1,496 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + IAgentGoalService, + IAgentLifecycleService, + IAgentPermissionModeService, + IAgentProfileService, + IAgentPromptService, + IAgentTaskService, + IAuthSummaryService, + IBootstrapService, + IConfigService, + IEventBus, + IFileSystemStorageService, + IOAuthToolkit, + ISessionCronService, + ISessionIndex, + ISessionManager, + ITelemetryService, + type BootstrapInput, + type Event2, +} from '@pymodel/agent-core-v2'; + +import { runV2Print } from '../../src/cli/v2/run-v2-print'; + +const mocks = vi.hoisted(() => ({ + bootstrap: vi.fn(), + ensureMainAgent: vi.fn(), + createPythinkerDefaultHeaders: vi.fn(() => ({})), + resolvePythinkerHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/pythinker-code-test-home'), + createPythinkerDeviceId: vi.fn(() => 'device-1'), +})); + +vi.mock('@pymodel/agent-core-v2', async (importOriginal) => { + const actual = await importOriginal<typeof import('@pymodel/agent-core-v2')>(); + return { + ...actual, + bootstrap: mocks.bootstrap, + ensureMainAgent: mocks.ensureMainAgent, + }; +}); + +vi.mock('@pymodel/pythinker-code-oauth', async () => { + const actual = await vi.importActual<typeof import('@pymodel/pythinker-code-oauth')>( + '@pymodel/pythinker-code-oauth', + ); + return { + ...actual, + createPythinkerDefaultHeaders: mocks.createPythinkerDefaultHeaders, + createPythinkerDeviceId: mocks.createPythinkerDeviceId, + }; +}); + +vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { + const actual = await importOriginal<typeof import('@pymodel/pythinker-code-sdk')>(); + return { + ...actual, + resolvePythinkerHome: mocks.resolvePythinkerHome, + }; +}); + +vi.mock('@pymodel/pythinker-telemetry', () => ({ + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + shutdownTelemetry: vi.fn(), + track: vi.fn(), + setTelemetryContext: vi.fn(), + withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), +})); + +interface FakeScope { + readonly id: string; + readonly accessor: { readonly get: (token: unknown) => unknown }; + readonly dispose: ReturnType<typeof vi.fn>; +} + +function fakeScope(id: string, services: Map<unknown, unknown>): FakeScope { + return { + id, + accessor: { + get: (token: unknown) => { + if (!services.has(token)) throw new Error(`unexpected service request: ${String(token)}`); + return services.get(token); + }, + }, + dispose: vi.fn(), + }; +} + +function writer() { + let text = ''; + return { + write: vi.fn((chunk: string) => { + text += chunk; + return true; + }), + text: () => text, + }; +} + +function opts(overrides: Record<string, unknown> = {}) { + return { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: 'say hello', + skillsDirs: [], + agent: undefined, + agentFiles: [], + addDirs: [], + ...overrides, + } as const; +} + +function makeFakeHarness() { + // Native event listeners registered on the main agent's IEventBus; the turn + // emits a streaming assistant delta before completing. + const eventListeners = new Set<(event: Event2<any>) => void>(); + const profileState: { profileName: string | undefined } = { profileName: undefined }; + + const agentServices = new Map<unknown, unknown>([ + [ + IAgentProfileService, + { + bind: vi.fn(async () => {}), + setModel: vi.fn(async () => ({ model: 'k2' })), + getModel: () => 'k2', + data: () => ({ profileName: profileState.profileName }), + }, + ], + [IAgentPermissionModeService, { mode: 'auto', setMode: vi.fn() }], + [IAuthSummaryService, { ensureReady: vi.fn(async () => {}) }], + [ + IEventBus, + { + subscribe: vi.fn((handler: (event: Event2<any>) => void) => { + eventListeners.add(handler); + return { dispose: () => eventListeners.delete(handler) }; + }), + }, + ], + [ + IAgentPromptService, + { + enqueue: vi.fn(async () => { + // Emit a native assistant delta on the main agent bus, then complete. + for (const listener of [...eventListeners]) { + listener({ type: 'assistant.delta', turnId: 1, delta: 'hello world' } as unknown as Event2<any>); + } + return { + launched: Promise.resolve({ + id: 1, + result: Promise.resolve({ type: 'completed' }), + }), + }; + }), + }, + ], + [IAgentTaskService, { list: vi.fn(() => []) }], + [IAgentGoalService, { createGoal: vi.fn(), getGoal: vi.fn() }], + ]); + const agent = fakeScope('main', agentServices); + + const sessionServices = new Map<unknown, unknown>([ + // drain enumerates agents; empty → no background work to wait on. + [IAgentLifecycleService, { list: vi.fn(() => []) }], + // No scheduled cron tasks → no future fire time to wait on. + [ISessionCronService, { getNextFireTime: vi.fn(() => null) }], + ]); + const session = fakeScope('ses_v2', sessionServices); + + const appServices = new Map<unknown, unknown>([ + [ + IConfigService, + { + ready: Promise.resolve(), + get: vi.fn((section: string) => (section === 'defaultModel' ? 'k2' : undefined)), + // `applyPrintModeConfigDefaults` inspects each section and fills unset + // keys via the memory layer; an empty section means everything is unset. + inspect: vi.fn(() => ({ value: {} })), + set: vi.fn(async () => {}), + diagnostics: vi.fn(() => []), + }, + ], + [ + ISessionManager, + { + create: vi.fn(async () => session), + resume: vi.fn(async () => session), + get: vi.fn(() => session), + list: vi.fn(() => [session]), + } as unknown as ISessionManager, + ], + [ + ISessionIndex, + { + list: vi.fn(async () => ({ items: [] })), + get: vi.fn(async (id: string) => ({ + id, + workspaceId: 'wd_v2', + cwd: process.cwd(), + createdAt: 1, + updatedAt: 1, + archived: false, + })), + }, + ], + [ISessionIndex, { get: vi.fn(async () => undefined), listRecent: vi.fn(async () => ({ items: [] })) }], + [ + IBootstrapService, + { + platform: 'linux', + arch: 'x64', + clientIdentity: { + productName: 'test-product', + version: '1.2.3-test', + platform: 'test_platform', + }, + osHomeDir: '/home/test', + getEnv: () => undefined, + }, + ], + [IOAuthToolkit, { getCachedAccessToken: vi.fn(async () => undefined) }], + [IFileSystemStorageService, {}], + [ + ITelemetryService, + (() => { + const svc = { + setAppender: vi.fn(), + setContext: vi.fn(), + track: vi.fn(), + track2: vi.fn(), + shutdown: vi.fn(async () => {}), + withContext: vi.fn(() => svc), + }; + return svc; + })(), + ], + ]); + const app = fakeScope('app', appServices); + return { app, agent, session, agentServices, appServices, profileState }; +} + +describe('runV2Print', () => { + beforeEach(() => { + vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '1'); + vi.stubEnv('PYTHINKER_MODEL_OUTPUT_FORMAT', ''); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('submits a prompt, renders native events, awaits completion, and drains', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const promptService = agentServices.get(IAgentPromptService) as { enqueue: ReturnType<typeof vi.fn> }; + expect(promptService.enqueue).toHaveBeenCalledWith({ + message: { + role: 'user', + content: [{ type: 'text', text: 'say hello' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }); + // Version banner is first, then the rendered assistant output. + expect(stderr.write).toHaveBeenNthCalledWith(1, 'pythinker version 1.2.3-test\n'); + expect(stdout.text()).toContain('hello world'); + expect(app.dispose).toHaveBeenCalled(); + }); + + it('passes explicit skill dirs from --skillsDir into bootstrap args', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ skillsDirs: ['/skills'] }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs).toEqual(['/skills']); + }); + + it('leaves the skill dirs arg unset when --skillsDir is empty', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.skillDirs ?? []).toEqual([]); + }); + + it('seeds explicit agent files from --agentFile and binds the --agent profile', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, appServices, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print( + opts({ agent: 'reviewer', agentFiles: ['/agents/reviewer.md'] }) as never, + '1.2.3-test', + { stdout, stderr }, + ); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['/agents/reviewer.md']); + + const sessions = appServices.get(ISessionManager) as { create: ReturnType<typeof vi.fn> }; + expect(sessions.create).toHaveBeenCalledWith({ + workDir: process.cwd(), + additionalDirs: undefined, + mainAgentBinding: { profile: 'reviewer', model: 'k2' }, + }); + const profile = agentServices.get(IAgentProfileService) as { bind: ReturnType<typeof vi.fn> }; + expect(profile.bind).not.toHaveBeenCalled(); + }); + + it('binds the profile named by --agent-file when --agent is absent', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-agent-file-')); + const agentFile = join(dir, 'reviewer.md'); + await writeFile( + agentFile, + '---\nname: file-reviewer\ndescription: Reviews code.\n---\n\nYou review code.\n', + ); + const stdout = writer(); + const stderr = writer(); + const { app, agent, appServices, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ agentFiles: [agentFile] }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual([agentFile]); + + const sessions = appServices.get(ISessionManager) as { create: ReturnType<typeof vi.fn> }; + expect(sessions.create).toHaveBeenCalledWith({ + workDir: process.cwd(), + additionalDirs: undefined, + mainAgentBinding: { profile: 'file-reviewer', model: 'k2' }, + }); + const profile = agentServices.get(IAgentProfileService) as { bind: ReturnType<typeof vi.fn> }; + expect(profile.bind).not.toHaveBeenCalled(); + }); + + it('does not materialize a main agent after fresh profile binding fails', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, appServices } = makeFakeHarness(); + const sessions = appServices.get(ISessionManager) as { create: ReturnType<typeof vi.fn> }; + sessions.create.mockRejectedValueOnce(new Error('Unknown agent profile')); + mocks.bootstrap.mockReturnValue({ app }); + + await expect( + runV2Print(opts({ agent: 'missing' }) as never, '1.2.3-test', { stdout, stderr }), + ).rejects.toThrow('Unknown agent profile'); + + expect(mocks.ensureMainAgent).not.toHaveBeenCalled(); + }); + + it('fails before any turn when --agent-file is invalid', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-agent-file-')); + const agentFile = join(dir, 'broken.md'); + await writeFile(agentFile, '---\nname: broken\n---\n\nbody\n'); + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await expect( + runV2Print(opts({ agentFiles: [agentFile] }) as never, '1.2.3-test', { stdout, stderr }), + ).rejects.toThrow(/Invalid agent file/); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType<typeof vi.fn>; + }; + expect(profile.bind).not.toHaveBeenCalled(); + }); + + it('leaves the agent files arg unset when --agentFile is empty', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles ?? []).toEqual([]); + }); + + it('passes --agent-file paths through unresolved so the engine can expand ~', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print( + opts({ agent: 'reviewer', agentFiles: ['~/agents/reviewer.md'] }) as never, + '1.2.3-test', + { stdout, stderr }, + ); + + const input = mocks.bootstrap.mock.calls[0]?.[0] as BootstrapInput; + expect(input.args?.agentFiles).toEqual(['~/agents/reviewer.md']); + }); + + it('treats re-selecting the already-bound profile on resume as a no-op', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); + profileState.profileName = 'reviewer'; + + const index = appServices.get(ISessionIndex) as { get: ReturnType<typeof vi.fn> }; + index.get.mockResolvedValue({ id: 'ses_1', cwd: process.cwd() }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print(opts({ session: 'ses_1', agent: 'reviewer' }) as never, '1.2.3-test', { + stdout, + stderr, + }); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType<typeof vi.fn>; + setModel: ReturnType<typeof vi.fn>; + }; + expect(profile.bind).not.toHaveBeenCalled(); + expect(profile.setModel).not.toHaveBeenCalled(); + }); + + it('switches the model when resuming with the already-bound profile and an explicit model', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, agent, agentServices, appServices, profileState } = makeFakeHarness(); + profileState.profileName = 'reviewer'; + + const index = appServices.get(ISessionIndex) as { get: ReturnType<typeof vi.fn> }; + index.get.mockResolvedValue({ id: 'ses_1', cwd: process.cwd() }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue(agent); + + await runV2Print( + opts({ session: 'ses_1', agent: 'reviewer', model: 'new-model' }) as never, + '1.2.3-test', + { stdout, stderr }, + ); + + const profile = agentServices.get(IAgentProfileService) as { + bind: ReturnType<typeof vi.fn>; + setModel: ReturnType<typeof vi.fn>; + }; + expect(profile.bind).not.toHaveBeenCalled(); + expect(profile.setModel).toHaveBeenCalledWith('new-model'); + }); +}); diff --git a/apps/pythinker-code/test/cli/version.test.ts b/apps/pythinker-code/test/cli/version.test.ts index 084099f1..c4967319 100644 --- a/apps/pythinker-code/test/cli/version.test.ts +++ b/apps/pythinker-code/test/cli/version.test.ts @@ -1,10 +1,10 @@ import { readFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { dirname, join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { - buildPythinkerDefaultHeaders, + createPythinkerCodeUserAgent, getHostPackageJsonPath, getHostPackageRoot, getVersion, @@ -15,14 +15,12 @@ describe('cli version helpers', () => { const pkgPath = getHostPackageJsonPath(); const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version: string }; - expect(pkgPath.endsWith('/apps/pythinker-code/package.json')).toBe(true); + expect(pkgPath.endsWith(join('apps', 'pythinker-code', 'package.json'))).toBe(true); expect(getHostPackageRoot()).toBe(dirname(pkgPath)); expect(getVersion()).toBe(pkg.version); }); - it('builds default headers with the pythinker-code-cli user-agent', () => { - const headers = buildPythinkerDefaultHeaders('1.2.3'); - - expect(headers['User-Agent']).toBe('pythinker-code-cli/1.2.3'); + it('builds the product user-agent for ad-hoc fetches', () => { + expect(createPythinkerCodeUserAgent('1.2.3')).toBe('pythinker-code-cli/1.2.3'); }); }); diff --git a/apps/pythinker-code/test/cli/vis.test.ts b/apps/pythinker-code/test/cli/vis.test.ts new file mode 100644 index 00000000..f9408f72 --- /dev/null +++ b/apps/pythinker-code/test/cli/vis.test.ts @@ -0,0 +1,114 @@ +/** + * `pythinker vis` + * + * Verifies the CLI layer for the session visualizer: home + auto-port + * resolution, browser open vs `--no-open`, and the session deep-link path. + * Uses injected deps so no real port is bound and the real vis server is + * never started. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { handleVis, type VisDeps } from '#/cli/sub/vis'; + +function makeDeps(over: Partial<VisDeps> = {}): { + deps: VisDeps; + opened: string[]; + out: string[]; +} { + const opened: string[] = []; + const out: string[] = []; + const deps: VisDeps = { + getHomeDir: () => '/home/k', + startVisServer: vi.fn(async (o) => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close: async () => {}, + _opts: o, + })) as unknown as VisDeps['startVisServer'], + openUrl: async (u: string) => { + opened.push(u); + }, + waitForShutdown: async () => {}, + stdout: { + write: (s: string) => { + out.push(s); + return true; + }, + }, + stderr: { write: () => true }, + exit: vi.fn() as unknown as VisDeps['exit'], + ...over, + }; + return { deps, opened, out }; +} + +describe('handleVis', () => { + it('starts the server with the home dir + auto port and opens the browser', async () => { + const { deps, opened, out } = makeDeps(); + await handleVis(deps, { open: true }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 0 }), + ); + expect(opened).toEqual(['http://127.0.0.1:41234/']); + expect(out.join('')).toContain('http://127.0.0.1:41234/'); + }); + + it('does not open the browser when open is false', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: false }); + expect(opened).toEqual([]); + }); + + it('deep-links to a session when sessionId is given', async () => { + const { deps, opened } = makeDeps(); + await handleVis(deps, { open: true, sessionId: 'sess_abc' }); + expect(opened[0]).toBe('http://127.0.0.1:41234/sessions/sess_abc'); + }); + + it('uses the explicit port when provided', async () => { + const { deps } = makeDeps(); + await handleVis(deps, { open: false, port: 4321 }); + expect(deps.startVisServer).toHaveBeenCalledWith( + expect.objectContaining({ homeDir: '/home/k', port: 4321 }), + ); + }); + + it('closes the server after shutdown', async () => { + const close = vi.fn(async () => {}); + const { deps } = makeDeps({ + startVisServer: vi.fn(async () => ({ + port: 41234, + host: '127.0.0.1', + url: 'http://127.0.0.1:41234/', + close, + })) as unknown as VisDeps['startVisServer'], + }); + await handleVis(deps, { open: false }); + expect(close).toHaveBeenCalledOnce(); + }); + + it('reports a clean error and exits when the server fails to start', async () => { + const errored: string[] = []; + const { deps, opened } = makeDeps({ + startVisServer: vi.fn(async () => { + throw new Error('listen EADDRINUSE: address already in use 127.0.0.1:4321'); + }) as unknown as VisDeps['startVisServer'], + stderr: { + write: (s: string) => { + errored.push(s); + return true; + }, + }, + waitForShutdown: vi.fn(async () => {}), + }); + await handleVis(deps, { open: true, port: 4321 }); + expect(errored.join('')).toContain('Failed to start pythinker vis'); + expect(errored.join('')).toContain('EADDRINUSE'); + expect(deps.exit).toHaveBeenCalledWith(1); + // Nothing past the failed start should run. + expect(opened).toEqual([]); + expect(deps.waitForShutdown).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/cli/web/web.test.ts b/apps/pythinker-code/test/cli/web/web.test.ts new file mode 100644 index 00000000..2206bd05 --- /dev/null +++ b/apps/pythinker-code/test/cli/web/web.test.ts @@ -0,0 +1,1031 @@ +/** + * Tests for the `pythinker web` Commander wiring and its subcommands. + * + * These tests don't actually start the server — the foreground runner is + * injected, so they verify option parsing, the ready banner / one-line ready + * output, browser opening, and the rotate-token / deprecated `pythinker server kill` + * subcommands against fake deps. + */ + +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import chalk, { Chalk } from 'chalk'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerWebCommand } from '#/cli/sub/web'; +import type { LegacyKillDeps } from '#/cli/sub/web/legacy-kill'; +import type { WebCommandDeps } from '#/cli/sub/web/run'; +import type { ParsedServerOptions } from '#/cli/sub/web/shared'; +import { darkColors } from '#/tui/theme/colors'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:child_process')>(); + return { ...actual, spawn: vi.fn() }; +}); + +function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +function makeProgram(): Command { + // `commander` exitOverride avoids killing the test runner when --help/error fires. + const program = new Command('pythinker').exitOverride(); + registerWebCommand(program); + return program; +} + +type ForegroundRunner = NonNullable<WebCommandDeps['startServerForeground']>; + +/** + * Fake foreground runner: records the parsed options and fires `onReady` with + * a fixed origin, then returns (the real runner blocks until SIGINT/SIGTERM). + */ +function makeRunner(origin = 'http://127.0.0.1:58627'): { + runner: ForegroundRunner; + calls: { options: ParsedServerOptions | undefined }; +} { + const calls: { options: ParsedServerOptions | undefined } = { options: undefined }; + const runner: ForegroundRunner = async (options, hooks) => { + calls.options = options; + hooks?.onReady?.(origin); + return undefined as never; + }; + return { runner, calls }; +} + +/** Capturing stdout/stderr pair for `WebCommandDeps`. */ +function makeIo(): { + stdout: Pick<NodeJS.WriteStream, 'write'>; + stderr: Pick<NodeJS.WriteStream, 'write'>; + readStdout(): string; +} { + let out = ''; + return { + stdout: { + write(chunk: string | Uint8Array) { + out += String(chunk); + return true; + }, + }, + stderr: { + write() { + return true; + }, + }, + readStdout: () => out, + }; +} + +describe('pythinker web', () => { + it('registers the `web` command with only the rotate-token subcommand', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const subs = web?.commands.map((c) => c.name()).toSorted(); + // Foreground servers stop with Ctrl+C, so there is no kill/ps. + expect(subs).toEqual(['rotate-token']); + }); + + it('exposes the foreground server options on `web` itself', () => { + const program = makeProgram(); + const web = program.commands.find((c) => c.name() === 'web'); + expect(web).toBeDefined(); + const longs = web!.options.map((o) => o.long).filter(Boolean); + expect(longs).toContain('--port'); + expect(longs).toContain('--host'); + expect(longs).toContain('--allowed-host'); + expect(longs).toContain('--insecure-no-tls'); + expect(longs).toContain('--allow-remote-shutdown'); + expect(longs).toContain('--allow-remote-terminals'); + expect(longs).toContain('--dangerous-bypass-auth'); + expect(longs).toContain('--log-level'); + expect(longs).toContain('--debug-endpoints'); + expect(longs).toContain('--web-title'); + // web opens the browser by default → the option is the negative --no-open. + expect(longs).toContain('--no-open'); + // The background/daemon era flags are gone: the server always runs in the + // foreground. + expect(longs).not.toContain('--foreground'); + expect(longs).not.toContain('--keep-alive'); + expect(longs).not.toContain('--daemon'); + expect(longs).not.toContain('--idle-grace-ms'); + }); + + it('routes `pythinker server` and any legacy subcommand to a deprecation notice', async () => { + for (const argv of [ + ['node', 'pythinker', 'server'], + ['node', 'pythinker', 'server', 'run', '--port', '1'], + ['node', 'pythinker', 'server', 'status'], + ['node', 'pythinker', 'server', 'ps', '--json'], + ]) { + const program = makeProgram(); + let stderr = ''; + const exitCalls: number[] = []; + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr += String(chunk); + return true; + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCalls.push(code ?? 0); + return undefined; + }) as never); + + await program.parseAsync(argv); + errSpy.mockRestore(); + exitSpy.mockRestore(); + + expect(exitCalls).toEqual([1]); + expect(stderr).toContain('`pythinker server` has been deprecated and no longer works.'); + expect(stderr).toContain('pythinker web'); + expect(stderr).toContain('pythinker server kill'); + expect(stderr).toContain('0.28.0'); + expect(stderr).toContain('next major version'); + } + }); +}); + +describe('`pythinker web` ready banner', () => { + it('prints the TUI-style ready panel once listening', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + // The runner reports the actual bound origin — the banner must take the + // port from it, not from the requested --port. + const { runner } = makeRunner('http://127.0.0.1:58628'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { port: '58627', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + expect(plain).toContain('Pythinker server ready'); + expect(plain).toContain('Local:'); + expect(plain).toContain('http://127.0.0.1:58628/#token=tok'); + expect(plain).toContain('Token:'); + // Loopback bind shows a Network hint for enabling network access. + expect(plain).toContain('Network:'); + expect(plain).toContain('use --host to enable'); + expect(plain).toContain('Logs:'); + expect(plain).toContain('off'); + expect(plain).toContain('Stop:'); + expect(plain).toContain('Ctrl+C'); + // No bordered panel (the token URL must print in full for copying), but + // the Pythinker sprite stays next to the title. + expect(plain).not.toContain('╭'); + expect(plain).not.toContain('╰'); + expect(plain).toContain('▐█▛█▛█▌'); + expect(plain).toContain('▐█████▌'); + expect(plain).not.toContain('Pythinker server:'); + + // Title is above the URLs; Logs/Stop are at the bottom. + expect(plain.indexOf('Pythinker server ready')).toBeLessThan(plain.indexOf('Local:')); + expect(plain.indexOf('Logs:')).toBeLessThan(plain.indexOf('Stop:')); + }); + + it('uses the TUI dark palette for the ready banner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleWebCommand( + { port: '58627', host: '127.0.0.1', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const out = readStdout(); + const color = new Chalk({ level: 3 }); + expect(out).toContain(color.hex(darkColors.primary)('▐█▛█▛█▌')); + expect(out).toContain(color.bold.hex(darkColors.primary)('Pythinker server ready')); + expect(out).toContain(color.hex(darkColors.accent)('http://127.0.0.1:58627/')); + expect(out).toContain(color.bold.hex(darkColors.textDim)('Local: ')); + expect(out).toContain(color.hex(darkColors.textMuted)('off')); + }); + + it('renders the bypass danger notice in the error color', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const previousChalkLevel = chalk.level; + chalk.level = 3; + + try { + await handleWebCommand( + { port: '58627', dangerousBypassAuth: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + } finally { + chalk.level = previousChalkLevel; + } + + const color = new Chalk({ level: 3 }); + expect(readStdout()).toContain( + color.bold.hex(darkColors.error)( + '⚠ DANGER: authentication is DISABLED (--dangerous-bypass-auth).', + ), + ); + }); + + it('prints the danger notice and suppresses the token when auth is bypassed', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', host: '127.0.0.1', dangerousBypassAuth: true, open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl, + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + // Red, impossible-to-miss danger notice. + expect(plain).toContain('DANGER: authentication is DISABLED'); + expect(plain).toContain('--dangerous-bypass-auth'); + expect(plain).toContain('Ctrl+C'); + // The token is irrelevant when bypassed — neither printed nor carried in + // any URL (so it cannot leak via copy/paste of the banner). + expect(plain).not.toContain('tok'); + expect(plain).not.toContain('#token='); + // The opened browser URL carries no token fragment either. + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); +}); + +describe('ready banner reflects the bind class', () => { + it('lists Local + Network addresses for a 0.0.0.0 bind (Vite-style)', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://0.0.0.0:58627'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { host: '0.0.0.0', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + networkAddresses: [ + { address: '192.168.98.66', family: 'IPv4' }, + { address: '10.8.12.216', family: 'IPv4' }, + ], + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const raw = stripAnsi(readStdout()); + expect(raw).toContain('Pythinker server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('Network:'); + // Full token-bearing URLs are printed plainly (no box, no truncation) so + // they are easy to copy. + expect(raw).toContain('http://localhost:58627/#token=tok-xyz'); + expect(raw).toContain('http://192.168.98.66:58627/#token=tok-xyz'); + expect(raw).toContain('http://10.8.12.216:58627/#token=tok-xyz'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-xyz'); + expect(raw).not.toContain('╭'); + }); + + it('lists only the Local URL for a 127.0.0.1 bind', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://127.0.0.1:58627'); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { host: '127.0.0.1', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok-loop', + // Injected interface addresses must NOT leak into a loopback banner. + networkAddresses: [{ address: '192.168.98.66', family: 'IPv4' }], + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const raw = stripAnsi(readStdout()); + expect(raw).toContain('Pythinker server ready'); + expect(raw).toContain('Local:'); + expect(raw).toContain('http://127.0.0.1:58627/#token=tok-loop'); + expect(raw).toContain('Token:'); + expect(raw).toContain('tok-loop'); + // No network URLs on a loopback bind — just the "off" hint. + expect(raw).toContain('use --host to enable'); + expect(raw).not.toContain('Network: http'); + expect(raw).not.toContain('192.168.98.66'); + expect(raw).not.toContain('╭'); + }); +}); + +describe('`pythinker web` opens the browser', () => { + it('opens the Web UI URL with the #token= fragment by default', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: true }, + { + startServerForeground: runner, + resolveToken: () => 'tok-xyz', + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627/#token=tok-xyz'); + }); + + it('opens the plain origin when no token is resolvable', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: true }, + { + startServerForeground: runner, + resolveToken: () => undefined, + openUrl, + stdout, + stderr, + }, + ); + + expect(openUrl).toHaveBeenCalledWith('http://127.0.0.1:58627'); + }); + + it('does not open the browser when open is false', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner('http://127.0.0.1:9000'); + const { stdout, stderr } = makeIo(); + const openUrl = vi.fn(); + + await handleWebCommand( + { port: '58627', open: false }, + { startServerForeground: runner, openUrl, stdout, stderr }, + ); + + expect(openUrl).not.toHaveBeenCalled(); + }); +}); + +describe('`pythinker web` option threading', () => { + it('threads the CLI flags into the foreground runner options', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { + port: '59000', + host: '0.0.0.0', + insecureNoTls: true, + allowedHost: ['.example.com'], + dangerousBypassAuth: true, + debugEndpoints: true, + allowRemoteShutdown: true, + allowRemoteTerminals: true, + open: false, + }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toEqual({ + host: '0.0.0.0', + port: 59000, + logLevel: 'silent', + debugEndpoints: true, + insecureNoTls: true, + allowRemoteShutdown: true, + allowRemoteTerminals: true, + dangerousBypassAuth: true, + allowedHosts: ['.example.com'], + }); + }); + + it('defaults the host to 127.0.0.1 and insecureNoTls to true', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ + host: '127.0.0.1', + insecureNoTls: true, + logLevel: 'silent', + }); + }); + + it('maps a bare --host to the default LAN host', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', host: true, open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ host: '0.0.0.0', insecureNoTls: true }); + }); + + it('passes --log-level through to the runner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', logLevel: 'debug', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ logLevel: 'debug' }); + }); + + it('passes --web-title through to the runner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', webTitle: 'My Dev Box', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options).toMatchObject({ webTitle: 'My Dev Box' }); + }); + + it('leaves webTitle undefined when --web-title is not passed', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner, calls } = makeRunner(); + const { stdout, stderr } = makeIo(); + + await handleWebCommand( + { port: '58627', open: false }, + { startServerForeground: runner, openUrl: vi.fn(), stdout, stderr }, + ); + + expect(calls.options?.webTitle).toBeUndefined(); + }); + + it('rejects an invalid --log-level before calling the runner', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const startServerForeground = vi.fn(async () => undefined as never); + const { stdout, stderr } = makeIo(); + + await expect( + handleWebCommand( + { logLevel: 'shout', open: false }, + { startServerForeground, openUrl: vi.fn(), stdout, stderr }, + ), + ).rejects.toThrow(/invalid --log-level/); + expect(startServerForeground).not.toHaveBeenCalled(); + }); + + it('prints the one-line ready line instead of the full banner with a non-default --log-level', async () => { + const { handleWebCommand } = await import('#/cli/sub/web/run'); + const { runner } = makeRunner(); + const { stdout, stderr, readStdout } = makeIo(); + + await handleWebCommand( + { port: '58627', logLevel: 'info', open: false }, + { + startServerForeground: runner, + resolveToken: () => 'tok', + openUrl: vi.fn(), + stdout, + stderr, + }, + ); + + const plain = stripAnsi(readStdout()); + expect(plain).toContain('Pythinker server: http://127.0.0.1:58627/#token=tok'); + expect(plain).not.toContain('Pythinker server ready'); + expect(plain).not.toContain('Local:'); + }); + + it('parses comma-separated --allowed-host values', async () => { + const { parseAllowedHostArgs } = await import('#/cli/sub/web/shared'); + expect(parseAllowedHostArgs(['.example.com, app.example.com'])).toEqual([ + '.example.com', + 'app.example.com', + ]); + }); +}); + +describe('shared parsers stay strict', () => { + it('rejects out-of-range --port', async () => { + const { parsePort } = await import('#/cli/sub/web/shared'); + expect(() => parsePort('99999', '--port', 58627)).toThrow(/invalid --port/); + expect(() => parsePort('-1', '--port', 58627)).toThrow(/invalid --port/); + expect(parsePort(undefined, '--port', 58627)).toBe(58627); + expect(parsePort('8080', '--port', 58627)).toBe(8080); + }); + + it('rejects unknown --log-level values', async () => { + const { parseLogLevel } = await import('#/cli/sub/web/shared'); + expect(() => parseLogLevel('shout')).toThrow(/invalid --log-level/); + expect(parseLogLevel(undefined)).toBe('info'); + expect(parseLogLevel('debug')).toBe('debug'); + }); +}); + +describe('server web asset directory resolution', () => { + it('uses extracted SEA web assets when available', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/web/run'); + expect(resolveServerWebAssetsDir('/cache/pythinker/dist-web')).toBe('/cache/pythinker/dist-web'); + }); + + it('falls back to package dist-web outside SEA mode', async () => { + const { resolveServerWebAssetsDir } = await import('#/cli/sub/web/run'); + expect(resolveServerWebAssetsDir(null)).toMatch(/[/\\]dist-web$/); + }); + + it('returns the assets dir when it is built, dev mode or not', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'pythinker-web-assets-')); + try { + writeFileSync(join(dir, 'index.html'), '<html></html>'); + expect(serverWebAssetsDir({}, dir)).toBe(dir); + expect(serverWebAssetsDir({ PYTHINKER_CODE_DEV_SERVER: '1' }, dir)).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('requires built assets outside dev mode', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'pythinker-web-assets-')); + try { + expect(serverWebAssetsDir({}, dir)).toBe(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('tolerates missing assets in dev mode (API-only server)', async () => { + const { serverWebAssetsDir } = await import('#/cli/sub/web/run'); + const dir = mkdtempSync(join(tmpdir(), 'pythinker-web-assets-')); + try { + expect(serverWebAssetsDir({ PYTHINKER_CODE_DEV_SERVER: '1' }, dir)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +function makeLegacyKillDeps(overrides: Partial<LegacyKillDeps> = {}): { + deps: LegacyKillDeps; + writes: string[]; + errors: string[]; + signals: Array<{ pid: number; signal: NodeJS.Signals }>; + state: { shutdownCalls: number; removeCalls: number }; + clock: { t: number }; +} { + const writes: string[] = []; + const errors: string[] = []; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + const state = { shutdownCalls: 0, removeCalls: 0 }; + const clock = { t: 0 }; + const deps: LegacyKillDeps = { + readLock: async () => undefined, + removeLock: async () => { + state.removeCalls += 1; + }, + requestShutdown: async () => { + state.shutdownCalls += 1; + }, + resolveToken: () => undefined, + signalPid: (pid, signal) => { + signals.push({ pid, signal }); + return true; + }, + pidAlive: () => false, + sleep: async (ms) => { + clock.t += ms; + }, + stdout: { + write(chunk: string | Uint8Array) { + writes.push(String(chunk)); + return true; + }, + }, + stderr: { + write(chunk: string | Uint8Array) { + errors.push(String(chunk)); + return true; + }, + }, + now: () => clock.t, + ...overrides, + }; + return { deps, writes, errors, signals, state, clock }; +} + +describe('`pythinker server kill` (deprecated, legacy servers only)', () => { + const legacyLock = { pid: 1234, host: '127.0.0.1', port: 58627 }; + + it('is registered as the only working subcommand of the deprecated `server` command', () => { + const program = makeProgram(); + const server = program.commands.find((c) => c.name() === 'server'); + expect(server).toBeDefined(); + expect(server?.commands.map((c) => c.name())).toEqual(['kill']); + }); + + it('prints a deprecation notice naming the 0.28.0 cutoff on every run', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, errors } = makeLegacyKillDeps(); + + await handleLegacyKillCommand(deps); + + const notice = errors.join(''); + expect(notice).toContain('deprecated'); + expect(notice).toContain('0.28.0'); + expect(notice).toContain('Ctrl+C'); + }); + + it('prints "No running legacy Pythinker server." and sends no signal when no lock exists', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals } = makeLegacyKillDeps({ readLock: async () => undefined }); + + await handleLegacyKillCommand(deps); + + expect(writes.join('')).toContain('No running legacy Pythinker server.'); + expect(signals).toEqual([]); + }); + + it('sweeps a stale lock whose pid is already dead', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, state } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + pidAlive: () => false, + }); + + await handleLegacyKillCommand(deps); + + expect(writes.join('')).toContain('No running legacy Pythinker server.'); + expect(signals).toEqual([]); + expect(state.shutdownCalls).toBe(0); + expect(state.removeCalls).toBe(1); + }); + + it('attempts the API shutdown, then stops after SIGTERM when the pid exits promptly', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, state, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + pidAlive: () => clock.t < 50, + }); + + await handleLegacyKillCommand(deps); + + expect(state.shutdownCalls).toBe(1); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + expect(writes.join('')).toContain('pid 1234'); + expect(writes.join('')).toContain('stopped.'); + // The lock is removed once the pid is confirmed dead. + expect(state.removeCalls).toBe(1); + }); + + it('escalates to SIGKILL when the pid survives SIGTERM', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, writes, signals, clock } = makeLegacyKillDeps({ + readLock: async () => ({ ...legacyLock, pid: 5678 }), + // Survives the 3s SIGTERM grace, dies during the 2s SIGKILL grace. + pidAlive: () => clock.t < 3100, + }); + + await handleLegacyKillCommand(deps); + + expect(signals.map((s) => s.signal)).toEqual(['SIGTERM', 'SIGKILL']); + expect(writes.join('')).toContain('pid 5678'); + expect(writes.join('')).toContain('killed.'); + }); + + it('throws a permissions error when the pid survives SIGKILL', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps } = makeLegacyKillDeps({ + readLock: async () => ({ ...legacyLock, pid: 9999 }), + pidAlive: () => true, + }); + + await expect(handleLegacyKillCommand(deps)).rejects.toThrow(/insufficient permissions/); + }); + + it('skips the API path when the lock records no port', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + const { deps, signals, state, clock } = makeLegacyKillDeps({ + readLock: async () => ({ pid: 1234 }), + // Alive at the initial check, dead when the SIGTERM grace polls. + pidAlive: () => clock.t < 50, + }); + + await handleLegacyKillCommand(deps); + + expect(state.shutdownCalls).toBe(0); + expect(signals).toEqual([{ pid: 1234, signal: 'SIGTERM' }]); + }); + + it('passes the resolved token to requestShutdown', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + let seenToken: string | undefined = 'unset'; + const { deps, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + resolveToken: () => 'tok-123', + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => clock.t < 50, + }); + + await handleLegacyKillCommand(deps); + + expect(seenToken).toBe('tok-123'); + }); + + it('passes undefined when the token cannot be read (best-effort)', async () => { + const { handleLegacyKillCommand } = await import('#/cli/sub/web/legacy-kill'); + let seenToken: string | undefined = 'unset'; + const { deps, clock } = makeLegacyKillDeps({ + readLock: async () => legacyLock, + resolveToken: () => undefined, + requestShutdown: async (_origin, token) => { + seenToken = token; + }, + pidAlive: () => clock.t < 50, + }); + + await handleLegacyKillCommand(deps); + + expect(seenToken).toBeUndefined(); + }); +}); + +describe('readLegacyLock', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pythinker-legacy-lock-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('parses a lock written by an old build', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + const lockPath = join(dir, 'lock'); + writeFileSync( + lockPath, + JSON.stringify({ pid: 1234, started_at: '2026-01-01T00:00:00.000Z', port: 58627 }), + ); + + await expect(readLegacyLock(lockPath)).resolves.toEqual({ + pid: 1234, + host: undefined, + port: 58627, + }); + }); + + it('rejects a corrupt lock whose pid is not a positive integer', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + const lockPath = join(dir, 'lock'); + // pid 0 / negative pids have process-group semantics on POSIX — the lock + // must be treated as unusable rather than signaled. + for (const pid of [0, -1, 1.5, '1234']) { + writeFileSync(lockPath, JSON.stringify({ pid, port: 58627 })); + await expect(readLegacyLock(lockPath)).resolves.toBeUndefined(); + } + }); + + it('returns undefined when the lock file is missing or unparseable', async () => { + const { readLegacyLock } = await import('#/cli/sub/web/legacy-kill'); + await expect(readLegacyLock(join(dir, 'missing'))).resolves.toBeUndefined(); + const lockPath = join(dir, 'lock'); + writeFileSync(lockPath, 'not json'); + await expect(readLegacyLock(lockPath)).resolves.toBeUndefined(); + }); +}); + +describe('resolveServerToken', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pythinker-server-token-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('reads the token from <homeDir>/server.token', async () => { + const { resolveServerToken } = await import('#/cli/sub/web/shared'); + writeFileSync(join(dir, 'server.token'), 'secret-token\n'); + expect(resolveServerToken(dir)).toBe('secret-token'); + }); + + it('trims surrounding whitespace', async () => { + const { resolveServerToken } = await import('#/cli/sub/web/shared'); + writeFileSync(join(dir, 'server.token'), ' tok \n'); + expect(resolveServerToken(dir)).toBe('tok'); + }); + + it('throws a clear error when the token file is missing', async () => { + const { resolveServerToken } = await import('#/cli/sub/web/shared'); + expect(() => resolveServerToken(dir)).toThrow(/unable to read server token/); + }); +}); + +describe('authHeaders', () => { + it('builds a Bearer Authorization header', async () => { + const { authHeaders } = await import('#/cli/sub/web/shared'); + expect(authHeaders('abc')).toEqual({ Authorization: 'Bearer abc' }); + }); +}); + +describe('buildWebUrl', () => { + it('carries the token in the URL fragment (not path or query)', async () => { + const { buildWebUrl } = await import('#/cli/sub/web/run'); + const url = buildWebUrl('http://127.0.0.1:58627', 'abc123'); + expect(url).toBe('http://127.0.0.1:58627/#token=abc123'); + const parsed = new URL(url); + expect(parsed.hash).toBe('#token=abc123'); + // The token is client-side only: it must NOT appear in the path or query + // (which WOULD be sent to the server and logged). + expect(parsed.pathname).not.toContain('abc123'); + expect(parsed.search).not.toContain('abc123'); + }); + + it('normalizes a trailing slash', async () => { + const { buildWebUrl } = await import('#/cli/sub/web/run'); + expect(buildWebUrl('http://127.0.0.1:58627/', 't')).toBe( + 'http://127.0.0.1:58627/#token=t', + ); + }); +}); + +describe('accessUrlLines', () => { + it('returns Local + Network lines for a wildcard bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/web/access-urls'); + const lines = accessUrlLines('0.0.0.0', 58627, 'tok', [ + { address: '192.168.1.5', family: 'IPv4' }, + ]); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://localhost:58627/#token=tok' }, + { label: 'Network: ', url: 'http://192.168.1.5:58627/#token=tok' }, + ]); + }); + + it('returns a single Local line for a loopback bind', async () => { + const { accessUrlLines } = await import('#/cli/sub/web/access-urls'); + const lines = accessUrlLines('127.0.0.1', 58627, 'tok'); + expect(lines).toEqual([ + { label: 'Local: ', url: 'http://127.0.0.1:58627/#token=tok' }, + ]); + }); + + it('returns a single URL line for a specific host (no token)', async () => { + const { accessUrlLines } = await import('#/cli/sub/web/access-urls'); + const lines = accessUrlLines('192.168.1.5', 58627, undefined); + expect(lines).toEqual([{ label: 'URL: ', url: 'http://192.168.1.5:58627/' }]); + }); + + it('splitTokenFragment splits off the #token= fragment', async () => { + const { splitTokenFragment } = await import('#/cli/sub/web/access-urls'); + expect(splitTokenFragment('http://h:1/#token=abc')).toEqual(['http://h:1/', '#token=abc']); + expect(splitTokenFragment('http://h:1/')).toEqual(['http://h:1/', '']); + }); +}); + +describe('`pythinker web rotate-token`', () => { + let dir: string; + let prevHome: string | undefined; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pythinker-rotate-')); + prevHome = process.env['PYTHINKER_CODE_HOME']; + process.env['PYTHINKER_CODE_HOME'] = dir; + vi.resetModules(); + }); + + afterEach(() => { + if (prevHome === undefined) { + delete process.env['PYTHINKER_CODE_HOME']; + } else { + process.env['PYTHINKER_CODE_HOME'] = prevHome; + } + rmSync(dir, { recursive: true, force: true }); + }); + + it('writes a new token to server.token and prints it', async () => { + const { registerWebCommand } = await import('#/cli/sub/web'); + const program = new Command('pythinker').exitOverride(); + registerWebCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'pythinker', 'web', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(token.length).toBeGreaterThan(20); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(token); + }); + + it('re-prints the access links with the new token when a server is running', async () => { + const { registerWebCommand } = await import('#/cli/sub/web'); + const { mkdirSync, writeFileSync: writeSync } = await import('node:fs'); + // Fake a live instance-registry entry pointing at this (alive) process so + // getLiveServerInstance() finds the running server and the command can + // re-print its links. + mkdirSync(join(dir, 'server', 'instances'), { recursive: true }); + writeSync( + join(dir, 'server', 'instances', '01JTEST0000000000000000000.json'), + JSON.stringify({ + server_id: '01JTEST0000000000000000000', + pid: process.pid, + host: '127.0.0.1', + port: 58627, + started_at: Date.now(), + heartbeat_at: Date.now(), + }), + ); + + const program = new Command('pythinker').exitOverride(); + registerWebCommand(program); + let stdout = ''; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => { + stdout += String(chunk); + return true; + }); + + await program.parseAsync(['node', 'pythinker', 'web', 'rotate-token']); + writeSpy.mockRestore(); + + const token = readFileSync(join(dir, 'server.token'), 'utf8').trim(); + expect(stdout).toContain('New server token'); + expect(stdout).toContain(`http://127.0.0.1:58627/#token=${token}`); + // Token line sits between the note and the links. + expect(stdout.indexOf('picks up the new token')).toBeLessThan( + stdout.indexOf('New server token'), + ); + expect(stdout.indexOf('New server token')).toBeLessThan( + stdout.indexOf(`http://127.0.0.1:58627/#token=${token}`), + ); + }); +}); + +describe('formatHostForUrl', () => { + it('bracket-wraps IPv6 and leaves IPv4 as-is', async () => { + const { formatHostForUrl } = await import('#/cli/sub/web/networks'); + expect(formatHostForUrl('192.168.1.5', 'IPv4')).toBe('192.168.1.5'); + expect(formatHostForUrl('fe80::1', 'IPv6')).toBe('[fe80::1]'); + }); +}); + +describe('filterDisplayAddresses', () => { + it('drops IPv6 link-local, de-duplicates, and orders IPv4 before IPv6', async () => { + const { filterDisplayAddresses } = await import('#/cli/sub/web/networks'); + const out = filterDisplayAddresses([ + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '192.168.1.5', family: 'IPv4' }, + { address: 'fe80::ecf3:c2ff:fe9c:11c3', family: 'IPv6' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: 'fe80::1', family: 'IPv6' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + expect(out).toEqual([ + { address: '192.168.1.5', family: 'IPv4' }, + { address: '10.0.0.1', family: 'IPv4' }, + { address: '2001:db8::1', family: 'IPv6' }, + ]); + }); +}); diff --git a/apps/pythinker-code/test/feedback/codebase-upload/codebase-upload.test.ts b/apps/pythinker-code/test/feedback/codebase-upload/codebase-upload.test.ts new file mode 100644 index 00000000..8903d162 --- /dev/null +++ b/apps/pythinker-code/test/feedback/codebase-upload/codebase-upload.test.ts @@ -0,0 +1,406 @@ +import { execFile } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdtemp, mkdir, rm, stat, utimes, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { removeStaleFeedbackUploads } from '../../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../../src/feedback/codebase'; +import { uploadArchive } from '../../../src/feedback/upload'; + +const execFileAsync = promisify(execFile); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('uploadArchive', () => { + it('requests upload parts, PUTs each part, and completes with etags', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-direct-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + expect(api.createUploadUrl).toHaveBeenCalledWith({ + feedbackId: 3, + filename: 'repo.zip', + size: 5, + sha256: 'hash', + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('https://example.test/part1'); + expect(init.method).toBe('PUT'); + expect(init.body).toBeInstanceOf(ReadableStream); + expect((init as { duplex?: string }).duplex).toBe('half'); + expect(new Headers(init.headers).get('content-length')).toBe('5'); + // Drain the stream so the underlying file handle is released. + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('uses the backend-provided part upload method', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-method-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn( + async () => new Response('', { status: 200, headers: { ETag: '"etag-1"' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'POST', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + try { + await uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip' }, + ); + + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.method).toBe('POST'); + expect(await new Response(init.body as ReadableStream).text()).toBe('hello'); + } finally { + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('aborts a stalled part PUT and does not mark upload complete', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-stalled-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + const fetchMock = vi.fn((_url: string, init?: RequestInit) => + new Promise<Response>((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + return; + } + signal?.addEventListener( + 'abort', + () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }, + { once: true }, + ); + }), + ); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 25, maxRetries: 0 }, + ); + const expectation = expect(upload).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(25); + await expectation; + expect(api.completeUpload).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); + + it('retries a failed part and completes once it succeeds', async () => { + const workRoot = await mkdtemp(join(tmpdir(), 'feedback-upload-retry-')); + const archivePath = join(workRoot, 'repo.zip'); + await writeFile(archivePath, 'hello'); + + let attempt = 0; + const fetchMock = vi.fn(async () => { + attempt += 1; + if (attempt === 1) return new Response('server error', { status: 500 }); + return new Response('', { status: 200, headers: { ETag: '"etag-1"' } }); + }); + vi.stubGlobal('fetch', fetchMock); + const api = { + createUploadUrl: vi.fn(async () => ({ + uploadId: 28, + parts: [{ partNumber: 1, url: 'https://example.test/part1', method: 'PUT', size: 5 }], + })), + completeUpload: vi.fn(async () => {}), + }; + + vi.useFakeTimers(); + try { + const upload = uploadArchive( + api, + { + path: archivePath, + size: 5, + sha256: 'hash', + fingerprint: 'fingerprint', + fileCount: 1, + }, + 3, + { filename: 'repo.zip', timeoutMs: 10_000 }, + ); + await vi.advanceTimersByTimeAsync(1_000); + await upload; + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(api.completeUpload).toHaveBeenCalledWith({ + uploadId: 28, + parts: [{ partNumber: 1, etag: '"etag-1"' }], + }); + } finally { + vi.useRealTimers(); + await rm(workRoot, { recursive: true, force: true }); + } + }); +}); + +describe('packageCodebase', () => { + it('rejects empty codebase archives instead of uploading an empty zip', async () => { + const archivePath = join(tmpdir(), 'feedback-empty-codebase.zip'); + try { + await expect( + packageCodebase( + { + root: tmpdir(), + files: [], + fingerprint: 'empty-codebase', + usedGitIgnore: false, + }, + archivePath, + ), + ).rejects.toThrow(/empty/i); + await expect(stat(archivePath)).rejects.toThrow(); + } finally { + await rm(archivePath, { force: true }); + } + }); +}); + + +describe('scanCodebase filtering', () => { + it('rejects when the scan signal is already aborted', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-aborted-')); + const controller = new AbortController(); + controller.abort(); + try { + await expect(scanCodebase(root, { signal: controller.signal })).rejects.toMatchObject({ + name: 'AbortError', + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips dependency and build directories outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-no-git-')); + try { + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(root, 'dist')); + await writeFile(join(root, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1;\n'); + await writeFile(join(root, 'dist', 'bundle.js'), 'built\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(false); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files even when tracked by git', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-git-')); + try { + await writeFile(join(root, '.env'), 'SECRET=1\n'); + await writeFile(join(root, '.envrc'), 'export AWS_SECRET_ACCESS_KEY=secret\n'); + await writeFile(join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=secret\n'); + await writeFile(join(root, '.yarnrc.yml'), 'npmAuthToken: secret\n'); + await writeFile(join(root, 'id_rsa'), 'private-key\n'); + await writeFile(join(root, 'app.ts'), 'export const app = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + const paths = scan.files.map((file) => file.path); + expect(paths).toContain('app.ts'); + expect(paths).not.toContain('.env'); + expect(paths).not.toContain('.envrc'); + expect(paths).not.toContain('.npmrc'); + expect(paths).not.toContain('.yarnrc.yml'); + expect(paths).not.toContain('id_rsa'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('filters sensitive files by glob outside a git work tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-sensitive-')); + try { + await mkdir(join(root, '.ssh')); + await writeFile(join(root, '.env.production'), 'SECRET=1\n'); + await writeFile(join(root, 'tls.pem'), 'cert\n'); + await writeFile(join(root, '.ssh', 'config'), 'Host *\n'); + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + + const scan = await scanCodebase(root); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips individual files larger than the per-file limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-large-file-')); + try { + await writeFile(join(root, 'big.bin'), randomBytes(256)); + await writeFile(join(root, 'small.txt'), 'hello\n'); + + const scan = await scanCodebase(root, { limits: { maxFileSize: 128 } }); + expect(scan.files.map((file) => file.path)).toEqual(['small.txt']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('skips tracked files that were deleted from the working tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-deleted-')); + try { + await writeFile(join(root, 'keep.ts'), 'export const keep = 1;\n'); + await writeFile(join(root, 'deleted.ts'), 'export const gone = 1;\n'); + await execFileAsync('git', ['init'], { cwd: root }); + await execFileAsync('git', ['add', '-A'], { cwd: root }); + // Remove only from the working tree; the index still lists it, so + // `git ls-files` reports a path that no longer exists on disk. + await rm(join(root, 'deleted.ts')); + + const scan = await scanCodebase(root); + expect(scan.usedGitIgnore).toBe(true); + expect(scan.files.map((file) => file.path)).toEqual(['keep.ts']); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when file count reaches the limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-limit-')); + try { + await writeFile(join(root, 'a.txt'), 'a\n'); + await writeFile(join(root, 'b.txt'), 'b\n'); + await writeFile(join(root, 'c.txt'), 'c\n'); + + const scan = await scanCodebase(root, { limits: { maxFiles: 2 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'file-count', limit: 2 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('marks exceedsLimit when cumulative file size reaches the archive limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-scan-total-size-')); + try { + await writeFile(join(root, 'a.txt'), 'a'.repeat(100)); + await writeFile(join(root, 'b.txt'), 'b'.repeat(100)); + await writeFile(join(root, 'c.txt'), 'c'.repeat(100)); + + // 250 bytes fits any two files (200) but not the third (300). + const scan = await scanCodebase(root, { limits: { maxArchiveSize: 250 } }); + expect(scan.files).toHaveLength(2); + expect(scan.exceedsLimit).toEqual({ reason: 'total-size', limit: 250 }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); + +describe('removeStaleFeedbackUploads', () => { + it('removes archive dirs older than the cutoff and keeps recent ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'feedback-uploads-gc-')); + try { + const staleDir = join(root, 'stale'); + const freshDir = join(root, 'fresh'); + await mkdir(staleDir); + await mkdir(freshDir); + await writeFile(join(staleDir, 'repo.zip'), 'old'); + await writeFile(join(freshDir, 'repo.zip'), 'new'); + + const now = Date.now(); + const twoDaysAgoSec = (now - 2 * 24 * 60 * 60 * 1000) / 1000; + await utimes(staleDir, twoDaysAgoSec, twoDaysAgoSec); + + await removeStaleFeedbackUploads({ now, dir: root }); + + await expect(stat(staleDir)).rejects.toThrow(); + await expect(stat(freshDir)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('is a no-op when the cache dir does not exist', async () => { + const missing = join(tmpdir(), 'feedback-uploads-gc-missing-' + String(Date.now())); + await expect(removeStaleFeedbackUploads({ dir: missing })).resolves.toBeUndefined(); + }); +}); diff --git a/apps/pythinker-code/test/helpers/process.ts b/apps/pythinker-code/test/helpers/process.ts index b9d23adc..1da718fa 100644 --- a/apps/pythinker-code/test/helpers/process.ts +++ b/apps/pythinker-code/test/helpers/process.ts @@ -6,7 +6,7 @@ export class ExitCalled extends Error { } } -export function mockProcessExit(): { mockRestore(): void } { +export function mockProcessExit() { return vi.spyOn(process, 'exit').mockImplementation(((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); }) as never); @@ -19,15 +19,8 @@ export function captureProcessWrite(stream: 'stdout' | 'stderr'): { } { const chunks: string[] = []; const target = process[stream]; - const spy = vi.spyOn(target, 'write').mockImplementation((( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void, - ) => { + const spy = vi.spyOn(target, 'write').mockImplementation(((chunk: string | Uint8Array) => { chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')); - const complete = - typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; - complete?.(); return true; }) as never); diff --git a/apps/pythinker-code/test/migration/badge.test.ts b/apps/pythinker-code/test/migration/badge.test.ts new file mode 100644 index 00000000..39c38ea4 --- /dev/null +++ b/apps/pythinker-code/test/migration/badge.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { formatSessionLabel } from '#/migration/badge'; + +describe('formatSessionLabel', () => { + it('prepends [imported] when metadata.imported_from_pythinker_cli === true', () => { + const label = formatSessionLabel({ + title: 'Refactor sessions list', + metadata: { imported_from_pythinker_cli: true }, + }); + expect(label).toBe('[imported] Refactor sessions list'); + }); + + it('does not prepend [imported] when metadata is missing', () => { + const label = formatSessionLabel({ title: 'Plain session' }); + expect(label).toBe('Plain session'); + }); + + it('does not prepend [imported] when metadata is empty', () => { + const label = formatSessionLabel({ title: 'Plain session', metadata: {} }); + expect(label).toBe('Plain session'); + }); + + it('only triggers on the literal boolean true (not truthy values)', () => { + const label = formatSessionLabel({ + title: 'truthy but not true', + metadata: { imported_from_pythinker_cli: 'yes' as unknown }, + }); + expect(label).toBe('truthy but not true'); + }); + + it('does not prepend [imported] when flag is false', () => { + const label = formatSessionLabel({ + title: 'native session', + metadata: { imported_from_pythinker_cli: false }, + }); + expect(label).toBe('native session'); + }); + + it('preserves the title even when it is empty', () => { + const label = formatSessionLabel({ + title: '', + metadata: { imported_from_pythinker_cli: true }, + }); + expect(label).toBe('[imported] '); + }); +}); diff --git a/apps/pythinker-code/test/migration/command.test.ts b/apps/pythinker-code/test/migration/command.test.ts new file mode 100644 index 00000000..eb726078 --- /dev/null +++ b/apps/pythinker-code/test/migration/command.test.ts @@ -0,0 +1,29 @@ +/** + * `pythinker migrate` — a bare, flagless subcommand that delegates to a host + * handler. The migration UI is the native pi-tui screen, covered separately + * by `migration-screen.test.ts`. + */ + +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { registerMigrateCommand } from '#/migration/command'; + +describe('registerMigrateCommand', () => { + it('adds a flagless migrate subcommand to the program', () => { + const program = new Command('pythinker'); + registerMigrateCommand(program, () => {}); + const sub = program.commands.find((c) => c.name() === 'migrate'); + expect(sub).toBeDefined(); + expect(sub!.description()).toContain('Migrate'); + expect(sub!.options).toHaveLength(0); + }); + + it('invokes the host handler when `migrate` runs', () => { + const program = new Command('pythinker'); + const onMigrate = vi.fn(); + registerMigrateCommand(program, onMigrate); + program.parse(['migrate'], { from: 'user' }); + expect(onMigrate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/pythinker-code/test/migration/detect-pending.test.ts b/apps/pythinker-code/test/migration/detect-pending.test.ts new file mode 100644 index 00000000..41473911 --- /dev/null +++ b/apps/pythinker-code/test/migration/detect-pending.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { detectPendingMigration } from '#/migration/detect-pending'; + +let src: string; +let tgt: string; +beforeEach(async () => { + src = await mkdtemp(join(tmpdir(), 'detect-pending-src-')); + tgt = await mkdtemp(join(tmpdir(), 'detect-pending-tgt-')); +}); +afterEach(async () => { + await rm(src, { recursive: true, force: true }); + await rm(tgt, { recursive: true, force: true }); +}); + +describe('detectPendingMigration', () => { + it('returns null when source dir does not exist', async () => { + const plan = await detectPendingMigration({ sourceHome: join(src, 'nope'), targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when the migrated marker exists', async () => { + await writeFile(join(src, '.migrated-to-pythinker-code'), '{}', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when the skip marker exists in target', async () => { + await writeFile(join(src, 'config.toml'), '', 'utf-8'); + await writeFile(join(tgt, '.skip-migration-from-pythinker-cli'), '', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when source has nothing worth migrating', async () => { + // empty source dir, no config/mcp/credentials/sessions + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns null when the only source data is OAuth credentials', async () => { + // OAuth credentials are deliberately never migrated. An install whose + // only legacy data is `credentials/*.json` therefore has nothing to + // offer the migration screen — pythinker-code's own /login flow handles + // re-auth on first use. + await mkdir(join(src, 'credentials'), { recursive: true }); + await writeFile( + join(src, 'credentials', 'pythinker-code.json'), + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + expires_at: 1, + scope: 's', + token_type: 'Bearer', + }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + + it('returns a MigrationPlan when source has migratable data', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.hasConfig).toBe(true); + }); + + it('returns a MigrationPlan when source has only user-history', async () => { + await mkdir(join(src, 'user-history'), { recursive: true }); + await writeFile(join(src, 'user-history', 'shell.txt'), 'ls\n', 'utf-8'); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); + expect(plan?.hasUserHistory).toBe(true); + }); + + it('does not suppress when the marker targeted a different home', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-pythinker-code'), + JSON.stringify({ version: 1, target_path: '/some/other/home' }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).not.toBeNull(); // this target was never migrated → still offer + }); + + it('suppresses when the marker targeted this home', async () => { + await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); + await writeFile( + join(src, '.migrated-to-pythinker-code'), + JSON.stringify({ version: 1, target_path: tgt }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); +}); diff --git a/apps/pythinker-code/test/migration/migration-screen.test.ts b/apps/pythinker-code/test/migration/migration-screen.test.ts new file mode 100644 index 00000000..2e5decf3 --- /dev/null +++ b/apps/pythinker-code/test/migration/migration-screen.test.ts @@ -0,0 +1,576 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + MigrationScreenComponent, + type MigrationScreenResult, +} from '#/migration/migration-screen'; +import { darkColors } from '#/tui/theme/colors'; +import type { + MigrationPlan, + MigrationReport, + RunMigrationInput, +} from '@pymodel/migration-legacy'; + +function makePlan(over: Partial<MigrationPlan> = {}): MigrationPlan { + return { + sourceHome: '/x/.pythinker', + hasConfig: true, + hasMcp: true, + hasUserHistory: true, + oauthCredentials: ['pythinker-code.json'], + workdirs: [], + detectedPlugins: [], + detectedMcpOauthServers: [], + totalSessions: 1365, + ...over, + }; +} + +function render(c: MigrationScreenComponent): string { + return c.render(80).join('\n'); +} + +describe('MigrationScreenComponent — ask phase', () => { + it('ask1 renders the intro block and three options', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('Migrate from pythinker-cli'); + expect(out).toContain('1365 sessions'); + expect(out).toContain('Migrate now'); + expect(out).toContain('Ask me later'); + expect(out).toContain('Never ask again'); + }); + + it('ask1 summary does not mention pythinker-cli login (oauth is not a migrated kind)', async () => { + // OAuth credentials are deliberately never migrated, so the pre-migration + // summary must not list "pythinker-cli login" alongside the real migratable + // data classes — that framing makes users believe their session will + // carry over, which it does not. + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + const out = render(c); + expect(out).not.toContain('pythinker-cli login'); + expect(out).not.toContain('/login'); + }); + + it('picking "Ask me later" at ask1 completes with decision=later', () => { + let result: { decision: string } | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: (r) => { + result = r; + }, + }); + c.handleInput('\u001B[B'); // Down -> "Ask me later" + c.handleInput('\r'); // Enter + expect(result?.decision).toBe('later'); + }); + + it('"Migrate now" -> "Config only" advances ask1 -> ask2 and resolves scope.sessions=false', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: "Migrate now" + c.handleInput('\r'); // ask2: "Config only" (first option) + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(false); + }); + + it('"Migrate now" -> "Config + sessions" begins migration immediately with sessions=true', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\u001B[B'); // ask2: down -> Also migrate sessions + c.handleInput('\r'); // ask2 select -> "Config + N sessions" begins migration immediately + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(true); + }); + + it('ask2 shows the detected session count alongside the "config only" option', () => { + const c = new MigrationScreenComponent({ + plan: makePlan({ totalSessions: 1365 }), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1: Migrate now -> ask2 + const out = render(c); + expect(out).toContain('Config only'); + // Concrete count so the user sees the cost of "+ sessions" up front. + expect(out).toContain('Config + 1365 sessions'); + expect(out).not.toContain('Most recent'); + expect(out).not.toContain('Migrate now'); + }); + + it('ask2 falls back to "Config + all sessions" when no sessions were detected', () => { + const c = new MigrationScreenComponent({ + plan: makePlan({ totalSessions: 0 }), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c.handleInput('\r'); // ask1 -> ask2 + const out = render(c); + expect(out).toContain('Config + all sessions'); + // "Config + 0 sessions" would read as an obvious dead-end. + expect(out).not.toContain('Config + 0 sessions'); + }); + + it('skipDecisionStep starts at the scope question with the now/later/never gate hidden', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + skipDecisionStep: true, + onComplete: () => {}, + }); + const out = render(c); + expect(out).toContain('Migrate chat sessions too?'); + expect(out).not.toContain('Migrate now'); + expect(out).not.toContain('Never ask again'); + }); + + it('skipDecisionStep -> "Config only" resolves scope without the decision step', async () => { + let captured: RunMigrationInput | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + skipDecisionStep: true, + runMigration: async (input) => { + captured = input; + return makeReport(); + }, + onComplete: () => {}, + }); + c.handleInput('\r'); // ask2: "Config only" (first option) — no ask1 gate + await new Promise((r) => setTimeout(r, 0)); + expect(captured?.scope.sessions).toBe(false); + }); +}); + +describe('MigrationScreenComponent — progress phase', () => { + it('renders a step checklist and the session counter when in progress', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + // expose progress rendering via the test hook (see Step 5.2) + c._testEnterProgress(); + c._testUpdateStep('config done'); + c._testUpdateSessionProgress(32, 50); + const out = c.render(80).join('\n'); + expect(out).toContain('Migrating from pythinker-cli'); + expect(out).toContain('32 / 50'); + expect(out).toContain('Config'); + }); + + it('animates the progress spinner while a migration step runs', async () => { + vi.useFakeTimers(); + try { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + skipDecisionStep: true, + // A migration that never settles keeps the screen in the progress + // phase so the spinner animation can be observed. + runMigration: () => new Promise<MigrationReport>(() => {}), + onComplete: () => {}, + }); + c.handleInput('\r'); // ask2: "Config only" -> migration begins + c._testUpdateSessionProgress(1, 3); // surface the spinner line + const before = c.render(80).join('\n'); + vi.advanceTimersByTime(400); // several spinner frames + const after = c.render(80).join('\n'); + // Before the fix nothing advanced the spinner — the frame, and the whole + // progress render, stayed frozen on the first braille glyph. + expect(after).not.toBe(before); + } finally { + vi.useRealTimers(); + } + }); + + it('tracks Config and MCP as independent steps', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testEnterProgress(); + c._testUpdateStep('config done'); // config finished; MCP has not started + const out = c.render(80).join('\n'); + // Four checklist rows (config, mcp, user-history, sessions). With only + // config done, exactly one shows ✓ and the other three show ◐ — MCP is + // its own step and stays pending. + expect((out.match(/✓/g) ?? []).length).toBe(1); + expect((out.match(/◐/g) ?? []).length).toBe(3); + }); +}); + +function makeReport( + over: Partial<MigrationReport['summary']['sessions']> = {}, + summaryOver: Partial<MigrationReport['summary']> = {}, + noticesOver: Partial<MigrationReport['notices']> = {}, +): MigrationReport { + return { + startedAt: 's', + completedAt: 'e', + migratorVersion: '0.1.1', + source: '/x/.pythinker', + target: '/y/.pythinker-code', + summary: { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, + userHistory: { copied: 12, skippedExisting: 0 }, + skills: { copied: 0, skippedExisting: 0 }, + sessions: { + scope: 'all', + bucketsScanned: 0, + bucketsSkippedNonlocalKaos: 0, + bucketsSkippedNoWorkdirFound: 0, + sessionsAttempted: 50, + sessionsMigrated: 50, + sessionsAlreadyMigrated: 0, + sessionsSkippedPlaceholder: 0, + sessionsSkippedEmpty: 0, + sessionsSkippedMalformed: 0, + sessionsFailed: [], + sessionsConflicts: [], + ...over, + }, + ...summaryOver, + }, + notices: { + mcpOauthServersRequiringReauth: [], + oauthLoginsRequiringRelogin: [], + detectedPlugins: ['p1', 'p2'], + configConflictNotice: null, + tuiConflictNotice: null, + ...noticesOver, + }, + }; +} + +describe('MigrationScreenComponent — result phase', () => { + it('renders the report summary including plugin notices', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult(makeReport()); + const out = c.render(80).join('\n'); + expect(out).toContain('Migration complete'); + expect(out).toContain('50 sessions migrated'); + expect(out).toContain('2 pythinker-cli plugins'); + }); + + it('renders migrated hooks in the ✓ line and dropped hooks as a warning', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 2, + droppedHooks: 1, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('· hooks'); // appears in the ✓ migrated-kinds line + expect(out).toContain('1 hooks dropped'); + }); + + it('Enter on the result screen completes with the prior decision', () => { + let result: MigrationScreenResult | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: (r) => { + result = r; + }, + }); + c._testShowResult(makeReport()); + c.handleInput('\r'); + expect(result?.decision).toBe('now'); + expect(result?.migrated).toBe(true); + }); + + it('omits a data class from the result when it was not migrated', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + // config skipped (e.g. a malformed legacy config.toml). + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: false, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + // REPL history (copied) is still shown... + expect(out).toContain('REPL history'); + // ...but config must not be claimed as migrated. + expect(out).not.toContain('config'); + }); + + it('surfaces conflict and failure warnings on the result screen', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + { sessionsFailed: [{ sourcePath: '/s', reason: 'bad' }] }, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: true, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + mcp: { mergedServers: ['m'], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: true }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('config.migrated-from-pythinker-cli.toml'); + expect(out).toContain('mcp.migrated-from-pythinker-cli.json'); + expect(out).toContain('1 sessions failed'); + }); + + it('lists sibling-file contents in the config-fallback warning so the user knows what to merge', () => { + // When the target's `config.toml` could not be parsed and migration writes + // to `config.migrated-from-pythinker-cli.toml` instead, the result screen must + // (a) name the sibling, (b) say what's in it so the user knows what to + // merge by hand, and (c) describe the trigger accurately (parse failure, + // not "unreadable"). Otherwise users have to crack the file open to find + // out — and they may not realize hooks landed in there at all. + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: [], + wroteSiblingDueToConflict: true, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { + providers: ['openai', 'managed:pythinker-code'], + models: ['gpt4'], + hooks: 3, + }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('config.migrated-from-pythinker-cli.toml'); + // Accurate trigger description (file parses, not "unreadable"). + expect(out).toContain('could not be parsed'); + // Enumeration of what's inside the sibling. + expect(out).toContain('2 providers'); + expect(out).toContain('1 model'); + expect(out).toContain('3 hooks'); + }); + + it('shows skipped empty sessions as a muted line, not a failure', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult(makeReport({ sessionsSkippedEmpty: 3 })); + const out = c.render(80).join('\n'); + expect(out).toContain('3 empty sessions skipped'); + // It is informational, not a failure. + expect(out).not.toContain('3 sessions failed'); + }); + + it('lists kept config settings on the result screen when pythinker-cli differed', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult( + makeReport( + {}, + { + config: { + migrated: true, + tuiExtracted: false, + droppedProviders: [], + droppedModels: [], + droppedKeys: [], + configConflicts: ['default_model', 'providers.pythinker'], + wroteSiblingDueToConflict: false, + wroteTuiSibling: false, + migratedHooks: 0, + droppedHooks: 0, + siblingContents: { providers: [], models: [], hooks: 0 }, + }, + }, + ), + ); + const out = c.render(80).join('\n'); + expect(out).toContain('2 config conflicts kept yours'); + expect(out).toContain('default_model · providers.pythinker'); + }); + + it('surfaces MCP servers that need re-authentication', () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + }); + c._testShowResult(makeReport({}, {}, { mcpOauthServersRequiringReauth: ['srv-a', 'srv-b'] })); + const out = c.render(80).join('\n'); + expect(out).toContain('2 MCP servers need re-authentication'); + }); +}); + +describe('MigrationScreenComponent — execution wiring', () => { + it('runs migration after the ask phase and lands on the result phase', async () => { + const fakeReport = makeReport(); + let onCompleteResult: MigrationScreenResult | undefined; + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: (r) => { + onCompleteResult = r; + }, + // injected runner for testability — no filesystem access + runMigration: async (_input) => fakeReport, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\r'); // ask2: Config only -> begins migration + // migration is async; wait a tick + await new Promise((res) => setTimeout(res, 0)); + expect(c.render(80).join('\n')).toContain('Migration complete'); + c.handleInput('\r'); // dismiss result + expect(onCompleteResult?.decision).toBe('now'); + expect(onCompleteResult?.migrated).toBe(true); + }); + + it('lands on the failure screen with the runner rejection reason', async () => { + const c = new MigrationScreenComponent({ + plan: makePlan(), + sourceHome: '/x/.pythinker', + targetHome: '/y/.pythinker-code', + onComplete: () => {}, + runMigration: async () => { + throw new Error('boom'); + }, + }); + c.handleInput('\r'); // ask1: Migrate now + c.handleInput('\r'); // ask2: Config only -> begins migration + await new Promise((res) => setTimeout(res, 0)); + const out = c.render(80).join('\n'); + expect(out).toContain('Migration failed'); + expect(out).toContain('Reason: boom'); + }); +}); diff --git a/apps/pythinker-code/test/native/native-assets.test.ts b/apps/pythinker-code/test/native/native-assets.test.ts index 080f052b..0d028745 100644 --- a/apps/pythinker-code/test/native/native-assets.test.ts +++ b/apps/pythinker-code/test/native/native-assets.test.ts @@ -3,24 +3,29 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + getTextBuildWorkerRuntimeState, + resetTextBuildWorkerRuntime, +} from '@pymodel/minidb/worker-runtime'; import { - cleanupStaleUpdateBackup, - getNativeAssetFilePath, + getEmbeddedNativeAssetManifest, + getMinidbTextBuildWorkerFile, getNativeCacheBase, getNativePackageRoot, NATIVE_ASSET_MANIFEST_VERSION, type NativeAssetManifest, type NativeAssetSource, } from '#/native/native-assets'; +import { installMinidbTextBuildWorker } from '#/native/minidb-worker'; import { loadNativePackage } from '#/native/native-require'; function sha256(bytes: Buffer | string): string { return createHash('sha256').update(bytes).digest('hex'); } -function fakeManifest(files: Record<string, string>): { +function fakeManifest(files: Record<string, string>, workerContent?: string): { manifest: NativeAssetManifest; source: NativeAssetSource; } { @@ -32,6 +37,8 @@ function fakeManifest(files: Record<string, string>): { sha256: sha256(content), }; }); + const manifestKey = 'native/test-target/manifest.json'; + const workerAssetKey = 'native/test-target/runtime/minidb-text-build-worker'; const manifest: NativeAssetManifest = { version: NATIVE_ASSET_MANIFEST_VERSION, target: 'test-target', @@ -42,14 +49,28 @@ function fakeManifest(files: Record<string, string>): { files: assetEntries, }, ], + runtimeFiles: + workerContent === undefined + ? [] + : [ + { + key: 'minidb-text-build-worker', + assetKey: workerAssetKey, + relativePath: 'runtime/minidb/text-build-worker.mjs', + sha256: sha256(workerContent), + mode: 0o644, + }, + ], }; - const manifestKey = 'native/test-target/manifest.json'; const assets = new Map<string, Buffer>([ [manifestKey, Buffer.from(JSON.stringify(manifest))], ...Object.entries(files).map(([relativePath, content]) => [ `native/test-target/${relativePath}`, Buffer.from(content), ] as const), + ...(workerContent === undefined + ? [] + : [[workerAssetKey, Buffer.from(workerContent)] as const]), ]); return { manifest, @@ -64,6 +85,21 @@ function fakeManifest(files: Record<string, string>): { }; } +function sourceForManifest(manifest: unknown): NativeAssetSource { + const key = 'native/test-target/manifest.json'; + return { + getAssetKeys: () => [key], + getRawAsset: (assetKey) => { + if (assetKey !== key) throw new Error(`missing test asset: ${assetKey}`); + return Buffer.from(JSON.stringify(manifest)); + }, + }; +} + +afterEach(() => { + resetTextBuildWorkerRuntime(); +}); + describe('native assets', () => { it('uses PYTHINKER_CODE_CACHE_DIR as the native cache base when present', () => { expect( @@ -128,87 +164,173 @@ describe('native assets', () => { } }); - it('returns an extracted explicit native library path', () => { - const dir = mkdtempSync(join(tmpdir(), 'pythinker-native-library-')); + it('extracts, reuses, and repairs the runtime worker in the unified cache tree', () => { + const dir = mkdtempSync(join(tmpdir(), 'pythinker-native-worker-')); try { - const { manifest, source } = fakeManifest({ - 'node_modules/fake-native/libopentui.so': 'native-library', - }); - - expect( - getNativeAssetFilePath('fake-native', 'libopentui.so', { - cacheBase: dir, - manifest, - source, - version: 'test', - }), - ).toBe( + const worker = 'export const worker = true;\n'; + const { manifest, source } = fakeManifest( + { 'node_modules/fake-native/package.json': '{"main":"index.js"}' }, + worker, + ); + const options = { cacheBase: dir, manifest, source, version: 'test' }; + const first = getMinidbTextBuildWorkerFile(options); + const packageRoot = getNativePackageRoot('fake-native', options); + expect(first).toBe( join( dir, 'native', 'test', 'test-target', sha256(JSON.stringify(manifest)), - 'node_modules', - 'fake-native', - 'libopentui.so', + 'runtime', + 'minidb', + 'text-build-worker.mjs', ), ); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); + expect(packageRoot?.startsWith(join(dir, 'native', 'test', 'test-target'))).toBe(true); + expect(getMinidbTextBuildWorkerFile(options)).toBe(first); -describe('cleanupStaleUpdateBackup', () => { - it('removes a leftover .old exe on win32 SEA installs', () => { - const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-')); - const execPath = join(dir, 'pythinker.exe'); - const stalePath = `${execPath}.old`; - writeFileSync(stalePath, 'stale'); - try { - cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: true }); - expect(existsSync(stalePath)).toBe(false); + writeFileSync(first!, 'corrupt'); + expect(getMinidbTextBuildWorkerFile(options)).toBe(first); + expect(readFileSync(first!, 'utf-8')).toBe(worker); + + const installed = installMinidbTextBuildWorker(options); + expect(installed).toMatchObject({ status: 'installed', assetSha256: sha256(worker) }); + expect(getTextBuildWorkerRuntimeState()).toMatchObject({ + configured: true, + entry: { kind: 'packaged', path: first }, + }); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it('is a no-op when there is nothing to clean up (missing file)', () => { - const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-')); - const execPath = join(dir, 'pythinker.exe'); + it('reports missing and corrupt runtime worker assets without configuring MiniDb', () => { + const dir = mkdtempSync(join(tmpdir(), 'pythinker-native-worker-fail-')); try { - expect(() => { - cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: true }); - }).not.toThrow(); + const missing = fakeManifest({}); + expect( + installMinidbTextBuildWorker({ + cacheBase: dir, + manifest: missing.manifest, + source: missing.source, + version: 'test', + }), + ).toEqual({ status: 'asset-missing' }); + + const corrupt = fakeManifest({}, 'worker'); + const corruptSource: NativeAssetSource = { + getAssetKeys: () => corrupt.source.getAssetKeys(), + getRawAsset: (key) => + key.endsWith('/runtime/minidb-text-build-worker') + ? Buffer.from('wrong') + : corrupt.source.getRawAsset(key), + }; + expect( + installMinidbTextBuildWorker({ + cacheBase: dir, + manifest: corrupt.manifest, + source: corruptSource, + version: 'test', + }), + ).toMatchObject({ status: 'failed', errorCode: 'Error' }); + expect(getTextBuildWorkerRuntimeState()).toEqual({ configured: false }); } finally { rmSync(dir, { recursive: true, force: true }); } }); - it('skips non-win32 platforms', () => { - const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-')); - const execPath = join(dir, 'pythinker'); - const stalePath = `${execPath}.old`; - writeFileSync(stalePath, 'stale'); - try { - cleanupStaleUpdateBackup({ execPath, platform: 'darwin', isSea: true }); - expect(existsSync(stalePath)).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); + it('rejects unsupported or structurally incomplete native manifest versions', () => { + const valid = fakeManifest({}, 'worker').manifest; + const cases: Array<{ manifest: unknown; error: RegExp }> = [ + { manifest: { ...valid, version: 1 }, error: /Unsupported native asset manifest version: 1/ }, + { + manifest: { version: NATIVE_ASSET_MANIFEST_VERSION, target: 'test-target', runtimeFiles: [] }, + error: /packages must be an array/, + }, + { + manifest: { version: NATIVE_ASSET_MANIFEST_VERSION, target: 'test-target', packages: [] }, + error: /runtimeFiles must be an array/, + }, + { manifest: { ...valid, packages: {} }, error: /packages must be an array/ }, + { manifest: { ...valid, runtimeFiles: {} }, error: /runtimeFiles must be an array/ }, + ]; + + for (const item of cases) { + expect(() => + getEmbeddedNativeAssetManifest(sourceForManifest(item.manifest), 'test-target'), + ).toThrow(item.error); } }); - it('skips non-SEA (npm/dev) processes', () => { - const dir = mkdtempSync(join(tmpdir(), 'pythinker-update-backup-')); - const execPath = join(dir, 'pythinker.exe'); - const stalePath = `${execPath}.old`; - writeFileSync(stalePath, 'stale'); - try { - cleanupStaleUpdateBackup({ execPath, platform: 'win32', isSea: false }); - expect(existsSync(stalePath)).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); + it('rejects unsafe paths, invalid file metadata, and duplicate manifest keys', () => { + const valid = fakeManifest({}, 'worker').manifest; + const worker = valid.runtimeFiles[0]!; + const invalidRuntimeFiles: Array<{ file: Record<string, unknown>; error: RegExp }> = [ + { file: { ...worker, relativePath: '/tmp/worker.mjs' }, error: /safe relative path/ }, + { file: { ...worker, relativePath: '../worker.mjs' }, error: /safe relative path/ }, + { file: { ...worker, relativePath: 'runtime\\..\\worker.mjs' }, error: /safe relative path/ }, + { file: { ...worker, sha256: 'not-a-sha' }, error: /64 lowercase hex/ }, + { file: { ...worker, mode: 0o1000 }, error: /mode must be an integer/ }, + { file: { ...worker, assetKey: 42 }, error: /assetKey must be a non-empty string/ }, + ]; + for (const item of invalidRuntimeFiles) { + expect(() => + getEmbeddedNativeAssetManifest( + sourceForManifest({ ...valid, runtimeFiles: [item.file] }), + 'test-target', + ), + ).toThrow(item.error); } + + const validPackage = valid.packages[0]!; + expect(() => + getEmbeddedNativeAssetManifest( + sourceForManifest({ + ...valid, + packages: [{ ...validPackage, root: '../node_modules/fake-native' }], + }), + 'test-target', + ), + ).toThrow(/safe relative path/); + expect(() => + getEmbeddedNativeAssetManifest( + sourceForManifest({ + ...valid, + packages: [{ ...validPackage, files: {} }], + }), + 'test-target', + ), + ).toThrow(/files must be an array/); + + expect(() => + getEmbeddedNativeAssetManifest( + sourceForManifest({ + ...valid, + runtimeFiles: [ + worker, + { ...worker, assetKey: 'native/test-target/runtime/other', relativePath: 'runtime/other.mjs' }, + ], + }), + 'test-target', + ), + ).toThrow(/duplicate runtime key/); + + expect(() => + getEmbeddedNativeAssetManifest( + sourceForManifest({ + ...valid, + runtimeFiles: [ + worker, + { + ...worker, + key: 'other', + relativePath: 'runtime/other.mjs', + }, + ], + }), + 'test-target', + ), + ).toThrow(/duplicate assetKey/); }); }); diff --git a/apps/pythinker-code/test/native/opentui-library.test.ts b/apps/pythinker-code/test/native/opentui-library.test.ts deleted file mode 100644 index 40ea18c6..00000000 --- a/apps/pythinker-code/test/native/opentui-library.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { OPENTUI_TARGETS, resolveOpenTuiTarget } from '../../scripts/native/opentui-target.mjs'; - -describe('OpenTUI native targets', () => { - it('covers exactly the six published targets', () => { - expect(Object.keys(OPENTUI_TARGETS).toSorted()).toEqual([ - 'darwin-arm64', - 'darwin-x64', - 'linux-arm64', - 'linux-x64', - 'win32-arm64', - 'win32-x64', - ]); - }); - - it.each([ - ['darwin-arm64', '@opentui/core-darwin-arm64', 'libopentui.dylib'], - ['darwin-x64', '@opentui/core-darwin-x64', 'libopentui.dylib'], - ['linux-arm64', '@opentui/core-linux-arm64', 'libopentui.so'], - ['linux-x64', '@opentui/core-linux-x64', 'libopentui.so'], - ['win32-arm64', '@opentui/core-win32-arm64', 'opentui.dll'], - ['win32-x64', '@opentui/core-win32-x64', 'opentui.dll'], - ])('maps %s to %s and %s', (target, packageName, libraryFile) => { - expect(resolveOpenTuiTarget(target)).toEqual({ packageName, libraryFile }); - }); - - it('rejects musl targets explicitly', () => { - expect(() => resolveOpenTuiTarget('linux-x64-musl')).toThrow(/musl.*unsupported/iu); - expect(() => resolveOpenTuiTarget('linux-arm64-musl')).toThrow(/musl.*unsupported/iu); - }); -}); diff --git a/apps/pythinker-code/test/scripts/build-plugin-marketplace-cdn.test.ts b/apps/pythinker-code/test/scripts/build-plugin-marketplace-cdn.test.ts new file mode 100644 index 00000000..2a833a92 --- /dev/null +++ b/apps/pythinker-code/test/scripts/build-plugin-marketplace-cdn.test.ts @@ -0,0 +1,55 @@ +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildPluginMarketplaceCdn } from '../../scripts/build-plugin-marketplace-cdn.mjs'; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe('buildPluginMarketplaceCdn', () => { + it('packages WebBridge without publishing other unlisted official directories', async () => { + const root = await mkdtemp(join(tmpdir(), 'pythinker-plugin-cdn-build-')); + tempRoots.push(root); + const pluginsRoot = join(root, 'plugins'); + const outDir = join(root, 'out'); + + await writePlugin(pluginsRoot, 'listed-plugin'); + await writePlugin(pluginsRoot, 'pythinker-webbridge'); + await writePlugin(pluginsRoot, 'not-listed'); + await writeFile( + join(pluginsRoot, 'marketplace.json'), + JSON.stringify({ + version: '1', + plugins: [{ id: 'listed-plugin', source: './official/listed-plugin' }], + }), + 'utf8', + ); + + await buildPluginMarketplaceCdn({ pluginsRoot, outDir }); + + await expect(access(join(outDir, 'official/listed-plugin.zip'))).resolves.toBeUndefined(); + await expect(access(join(outDir, 'official/pythinker-webbridge.zip'))).resolves.toBeUndefined(); + await expect(access(join(outDir, 'official/not-listed.zip'))).rejects.toThrow(); + const marketplace = JSON.parse(await readFile(join(outDir, 'marketplace.json'), 'utf8')); + expect(marketplace.plugins).toHaveLength(1); + expect(marketplace.plugins[0].id).toBe('listed-plugin'); + }); +}); + +async function writePlugin(pluginsRoot: string, id: string): Promise<void> { + const pluginDir = join(pluginsRoot, 'official', id); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + join(pluginDir, 'pythinker.plugin.json'), + JSON.stringify({ name: id, version: '1.0.0' }), + 'utf8', + ); +} diff --git a/apps/pythinker-code/test/scripts/dev-vite-runtime.test.ts b/apps/pythinker-code/test/scripts/dev-vite-runtime.test.ts deleted file mode 100644 index 62e01fbf..00000000 --- a/apps/pythinker-code/test/scripts/dev-vite-runtime.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { spawn } from 'node:child_process'; -import { once } from 'node:events'; -import { resolve } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -const appRoot = resolve(import.meta.dirname, '../..'); -const runtimeScript = resolve(appRoot, 'scripts/dev-vite-runtime.mjs'); - -const ffiEnabled = - process.execArgv.some((arg) => arg.includes('experimental-ffi')) || - (process.env['NODE_OPTIONS'] ?? '').includes('experimental-ffi'); - -describe.skipIf(!ffiEnabled)('dev Vite runtime', () => { - it('updates OpenTUI output through the shared Solid runtime', async () => { - const child = spawn( - process.execPath, - ['--experimental-ffi', runtimeScript], - { - cwd: appRoot, - env: { ...process.env, PYTHINKER_CODE_OPENTUI_SMOKE: '1' }, - stdio: 'pipe', - }, - ); - - let stdout = ''; - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - - const reactiveMarker = 'OpenTUI reactive smoke passed: BEFORE -> AFTER'; - try { - await expect.poll(() => stdout, { timeout: 5000 }).toContain(reactiveMarker); - } finally { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGTERM'); - await once(child, 'exit'); - } - } - - expect(stderr).not.toContain('React is not defined'); - expect(stderr).not.toContain('OpenTUI lifecycle probe failed'); - expect(stdout).toContain(reactiveMarker); - }, 15000); -}); diff --git a/apps/pythinker-code/test/scripts/native/native-deps.test.ts b/apps/pythinker-code/test/scripts/native/native-deps.test.ts index 04c47c51..45f8b945 100644 --- a/apps/pythinker-code/test/scripts/native/native-deps.test.ts +++ b/apps/pythinker-code/test/scripts/native/native-deps.test.ts @@ -41,9 +41,7 @@ describe('resolveTargetDeps', () => { const names = deps.map((d) => d.resolvedName); expect(names).toContain('@mariozechner/clipboard'); expect(names).toContain('@mariozechner/clipboard-darwin-arm64'); - expect(names).toContain('koffi'); - expect(names).toContain('@opentui/core'); - expect(names).toContain('@opentui/core-darwin-arm64'); + expect(names).toContain('@pymodel/pi-tui'); }); it('picks the right clipboard subpackage per target', () => { @@ -58,38 +56,27 @@ describe('resolveTargetDeps', () => { ).toContain('@mariozechner/clipboard-win32-arm64-msvc'); }); - it('encodes koffi native file path with target triplet', () => { - const linuxKoffi = resolveTargetDeps('linux-arm64').find((d) => d.resolvedName === 'koffi'); - expect(linuxKoffi?.nativeFileRelatives).toEqual(['build/koffi/linux_arm64/koffi.node']); - const macKoffi = resolveTargetDeps('darwin-x64').find((d) => d.resolvedName === 'koffi'); - expect(macKoffi?.nativeFileRelatives).toEqual(['build/koffi/darwin_x64/koffi.node']); - const winArmKoffi = resolveTargetDeps('win32-arm64').find((d) => d.resolvedName === 'koffi'); - expect(winArmKoffi?.nativeFileRelatives).toEqual(['build/koffi/win32_arm64/koffi.node']); - }); - - it('selects one OpenTUI platform library with an explicit file descriptor', () => { - const linux = resolveTargetDeps('linux-arm64').find((d) => d.id === 'opentui-platform'); - expect(linux?.resolvedName).toBe('@opentui/core-linux-arm64'); - expect(linux?.collect).toBe('explicit-files'); - expect(linux?.nativeFileRelatives).toEqual(['libopentui.so']); - - const windows = resolveTargetDeps('win32-x64').find((d) => d.id === 'opentui-platform'); - expect(windows?.resolvedName).toBe('@opentui/core-win32-x64'); - expect(windows?.nativeFileRelatives).toEqual(['opentui.dll']); - }); - - it('registers the OpenTUI tree-sitter assets for extraction', () => { - const core = resolveTargetDeps('darwin-arm64').find((d) => d.id === 'opentui-core-assets'); - expect(core?.resolvedName).toBe('@opentui/core'); - expect(core?.collect).toBe('explicit-files'); - expect(core?.nativeFileRelatives).toContain( - 'assets/javascript/tree-sitter-javascript.wasm', + it('encodes pi-tui native file path per target', () => { + const linuxPiTui = resolveTargetDeps('linux-arm64').find( + (d) => d.resolvedName === '@pymodel/pi-tui', ); - expect(core?.nativeFileRelatives).toContain('assets/javascript/highlights.scm'); + expect(linuxPiTui?.nativeFileRelatives).toEqual([]); + const macPiTui = resolveTargetDeps('darwin-x64').find( + (d) => d.resolvedName === '@pymodel/pi-tui', + ); + expect(macPiTui?.nativeFileRelatives).toEqual([ + 'native/darwin/prebuilds/darwin-x64/darwin-modifiers.node', + ]); + const winArmPiTui = resolveTargetDeps('win32-arm64').find( + (d) => d.resolvedName === '@pymodel/pi-tui', + ); + expect(winArmPiTui?.nativeFileRelatives).toEqual([ + 'native/win32/prebuilds/win32-arm64/win32-console-mode.node', + ]); }); it('throws on unsupported target', () => { - expect(() => resolveTargetDeps('linux-x64-musl')).toThrow(/unsupported/iu); + expect(() => resolveTargetDeps('linux-x64-musl')).toThrow(/unsupported/i); }); }); @@ -105,15 +92,9 @@ describe('nativeDeps registry shape', () => { expect(target?.parent).toBe('clipboard-host'); }); - it('has koffi (collect=js-and-native-file, parent=pi-tui)', () => { - const koffi = nativeDeps.find((d) => d.id === 'koffi'); - expect(koffi?.collect).toBe('js-and-native-file'); - expect(koffi?.parent).toBe('pi-tui'); - }); - - it('has an OpenTUI platform package nested under core', () => { - const target = nativeDeps.find((d) => d.id === 'opentui-platform'); - expect(target?.collect).toBe('explicit-files'); - expect(target?.parent).toBe('opentui-core-assets'); + it('has pi-tui (collect=native-file-only, no parent)', () => { + const piTui = nativeDeps.find((d) => d.id === 'pi-tui'); + expect(piTui?.collect).toBe('native-file-only'); + expect(piTui?.parent).toBe(null); }); }); diff --git a/apps/pythinker-code/test/scripts/native/paths.test.ts b/apps/pythinker-code/test/scripts/native/paths.test.ts index 51c6a6d5..ed035407 100644 --- a/apps/pythinker-code/test/scripts/native/paths.test.ts +++ b/apps/pythinker-code/test/scripts/native/paths.test.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { @@ -18,6 +20,10 @@ import { SEA_SENTINEL_FUSE, } from '../../../scripts/native/paths.mjs'; +// paths.mjs builds every path with node:path.resolve (backslashes on Windows). +// Build expectations the same way so they match on every platform. +const p = (...segments: string[]): string => resolve(appRoot, ...segments); + describe('targetTriple', () => { it('returns platform-arch when env unset', () => { expect(targetTriple({ platform: 'darwin', arch: 'arm64', env: {} })).toBe('darwin-arm64'); @@ -49,27 +55,27 @@ describe('executableName', () => { describe('path helpers', () => { it('returns absolute intermediates dir under app root', () => { - expect(nativeIntermediatesDir()).toBe(`${appRoot}/dist-native/intermediates`); + expect(nativeIntermediatesDir()).toBe(p('dist-native/intermediates')); }); it('returns absolute bin dir per target', () => { - expect(nativeBinDir('darwin-arm64')).toBe(`${appRoot}/dist-native/bin/darwin-arm64`); + expect(nativeBinDir('darwin-arm64')).toBe(p('dist-native/bin/darwin-arm64')); }); it('returns absolute bin path with executable name', () => { expect(nativeBinPath('darwin-arm64', 'darwin')).toBe( - `${appRoot}/dist-native/bin/darwin-arm64/pythinker`, + p('dist-native/bin/darwin-arm64/pythinker'), ); expect(nativeBinPath('win32-x64', 'win32')).toBe( - `${appRoot}/dist-native/bin/win32-x64/pythinker.exe`, + p('dist-native/bin/win32-x64/pythinker.exe'), ); }); it('returns intermediate artifact paths', () => { - expect(nativeJsBundlePath()).toBe(`${appRoot}/dist-native/intermediates/main.mjs`); - expect(nativeBlobPath()).toBe(`${appRoot}/dist-native/intermediates/pythinker.blob`); + expect(nativeJsBundlePath()).toBe(p('dist-native/intermediates/main.cjs')); + expect(nativeBlobPath()).toBe(p('dist-native/intermediates/pythinker.blob')); expect(nativeSeaConfigPath()).toBe( - `${appRoot}/dist-native/intermediates/sea-config.json`, + p('dist-native/intermediates/sea-config.json'), ); }); @@ -78,21 +84,21 @@ describe('path helpers', () => { }); it('returns native dist root', () => { - expect(nativeDistRoot()).toBe(`${appRoot}/dist-native`); + expect(nativeDistRoot()).toBe(p('dist-native')); }); it('returns manifest dir for target', () => { expect(nativeManifestDir('darwin-arm64')).toBe( - `${appRoot}/dist-native/intermediates/native-assets/darwin-arm64`, + p('dist-native/intermediates/native-assets/darwin-arm64'), ); }); it('returns artifacts dir', () => { - expect(nativeArtifactsDir()).toBe(`${appRoot}/dist-native/artifacts`); + expect(nativeArtifactsDir()).toBe(p('dist-native/artifacts')); }); it('returns smoke home', () => { - expect(nativeSmokeHome()).toBe(`${appRoot}/dist-native/smoke-home`); + expect(nativeSmokeHome()).toBe(p('dist-native/smoke-home')); }); it('has correct SEA sentinel fuse value', () => { diff --git a/apps/pythinker-code/test/scripts/native/sea-config.test.ts b/apps/pythinker-code/test/scripts/native/sea-config.test.ts deleted file mode 100644 index 96356b03..00000000 --- a/apps/pythinker-code/test/scripts/native/sea-config.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createSeaConfig } from '../../../scripts/native/02-sea-blob.mjs'; -import { nativeBlobPath, nativeJsBundlePath } from '../../../scripts/native/paths.mjs'; - -describe('native SEA config', () => { - it('uses an ESM main and enables Node FFI', () => { - expect(createSeaConfig({ 'native/test/asset': '/tmp/asset' })).toEqual({ - main: nativeJsBundlePath(), - mainFormat: 'module', - output: nativeBlobPath(), - assets: { 'native/test/asset': '/tmp/asset' }, - disableExperimentalSEAWarning: true, - useCodeCache: false, - useSnapshot: false, - execArgv: ['--experimental-ffi'], - execArgvExtension: 'env', - }); - expect(nativeJsBundlePath()).toMatch(/main\.mjs$/u); - }); -}); diff --git a/apps/pythinker-code/test/scripts/native/sign-args.test.ts b/apps/pythinker-code/test/scripts/native/sign-args.test.ts index c0a67bb7..12bebbc2 100644 --- a/apps/pythinker-code/test/scripts/native/sign-args.test.ts +++ b/apps/pythinker-code/test/scripts/native/sign-args.test.ts @@ -15,14 +15,14 @@ describe('buildCodesignArgs', () => { it('returns hardened-runtime args for Developer ID identity', () => { const args = buildCodesignArgs({ - identity: 'Developer ID Application: Pythoughts (ABCD1234)', + identity: 'Developer ID Application: PyModel (ABCD1234)', executable: '/path/pythinker', entitlementsPath: '/path/entitlements.plist', keychainPath: '/tmp/sign.keychain-db', }); expect(args).toEqual([ '--sign', - 'Developer ID Application: Pythoughts (ABCD1234)', + 'Developer ID Application: PyModel (ABCD1234)', '--options', 'runtime', '--entitlements', @@ -37,7 +37,7 @@ describe('buildCodesignArgs', () => { it('omits --keychain when keychainPath is null but uses Developer ID otherwise', () => { const args = buildCodesignArgs({ - identity: 'Developer ID Application: Pythoughts (ABCD1234)', + identity: 'Developer ID Application: PyModel (ABCD1234)', executable: '/path/pythinker', entitlementsPath: '/path/entitlements.plist', keychainPath: null, diff --git a/apps/pythinker-code/test/scripts/node-version-floor.test.ts b/apps/pythinker-code/test/scripts/node-version-floor.test.ts deleted file mode 100644 index cef8ee6f..00000000 --- a/apps/pythinker-code/test/scripts/node-version-floor.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { runInNewContext } from 'node:vm'; - -import { describe, expect, it } from 'vitest'; - -const repositoryRoot = resolve(import.meta.dirname, '../../../..'); - -function readRepositoryFile(path: string): string { - return readFileSync(resolve(repositoryRoot, path), 'utf8'); -} - -function readJson(path: string): { - engines?: { node?: string }; - devDependencies?: Record<string, string>; -} { - return JSON.parse(readRepositoryFile(path)); -} - -function extractTsdownTarget(path: string): string | undefined { - return readRepositoryFile(path).match(/\btarget:\s*['"]([^'"]+)['"]/)?.[1]; -} - -describe('Node.js version floor', () => { - it('keeps every runtime and build surface on Node.js 26.4', () => { - const rootPackage = readJson('package.json'); - const appPackage = readJson('apps/pythinker-code/package.json'); - - expect(readRepositoryFile('.nvmrc').trim()).toBe('26.4.0'); - expect(rootPackage.engines?.node).toBe('>=26.4.0'); - expect(appPackage.engines?.node).toBe('>=26.4.0'); - expect(appPackage.devDependencies?.['@types/node']).toBe('^26.1.2'); - expect(extractTsdownTarget('apps/pythinker-code/tsdown.config.ts')).toBe('node26'); - expect(extractTsdownTarget('apps/pythinker-code/tsdown.native.config.ts')).toBe( - 'node26', - ); - }); - - it('keeps the native build guard at Node.js 26.4.0', () => { - const source = readRepositoryFile('apps/pythinker-code/scripts/native/build.mjs'); - const minimumVersion = source.match( - /const MINIMUM_NODE_VERSION = \[(\d+),\s*(\d+),\s*(\d+)\];/, - ); - const guardStart = source.indexOf('const MINIMUM_NODE_VERSION'); - const guardEnd = source.indexOf('function ensureNodeVersion'); - const context: { - versions: string[]; - results?: boolean[]; - } = { - versions: [ - '25.99.99', - '26.3.99', - '26.4.0-rc.1', - 'invalid', - '26.4.0', - '26.4.0+build.1', - '26.4.1-rc.1', - '26.4.1', - '27.0.0-rc.1', - '27.0.0', - ], - }; - - expect(minimumVersion?.slice(1).map(Number)).toEqual([26, 4, 0]); - expect(guardStart).toBeGreaterThanOrEqual(0); - expect(guardEnd).toBeGreaterThan(guardStart); - expect(source).toContain( - 'isNodeVersionBelow(process.versions.node, MINIMUM_NODE_VERSION)', - ); - - runInNewContext( - `${source.slice(guardStart, guardEnd)} - results = versions.map((version) => - isNodeVersionBelow(version, MINIMUM_NODE_VERSION), - );`, - context, - ); - - expect(context.results).toEqual([ - true, - true, - true, - true, - false, - false, - false, - false, - false, - false, - ]); - }); -}); diff --git a/apps/pythinker-code/test/scripts/open-tui-build.test.ts b/apps/pythinker-code/test/scripts/open-tui-build.test.ts deleted file mode 100644 index 4343a4f6..00000000 --- a/apps/pythinker-code/test/scripts/open-tui-build.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; - -import { describe, expect, it } from 'vitest'; - -const appRoot = resolve(import.meta.dirname, '../..'); - -function readAppFile(path: string): string { - return readFileSync(resolve(appRoot, path), 'utf8'); -} - -describe('OpenTUI build wiring', () => { - it('preserves Solid JSX with the OpenTUI JSX runtime', () => { - const tsconfig = JSON.parse(readAppFile('tsconfig.json')) as { - compilerOptions?: { jsx?: string; jsxImportSource?: string }; - }; - - expect(tsconfig.compilerOptions?.jsx).toBe('preserve'); - expect(tsconfig.compilerOptions?.jsxImportSource).toBe('@opentui/solid'); - }); - - it('applies the Solid transform to npm, native, and Vitest builds', () => { - const npmConfig = readAppFile('tsdown.config.ts'); - const nativeConfig = readAppFile('tsdown.native.config.ts'); - const vitestConfig = readAppFile('vitest.config.ts'); - - expect(npmConfig).toContain("from 'unplugin-solid/rolldown'"); - expect(nativeConfig).toContain("from 'unplugin-solid/rolldown'"); - expect(vitestConfig).toContain("from 'unplugin-solid/vite'"); - for (const config of [npmConfig, nativeConfig, vitestConfig]) { - expect(config).toContain("moduleName: '@opentui/solid'"); - expect(config).toContain("generate: 'universal'"); - } - expect(npmConfig).toContain('rawTextPlugin()'); - expect(nativeConfig).toContain('rawTextPlugin()'); - }); - - it('emits launcher.mjs and main.mjs while externalizing native packages', () => { - const npmConfig = readAppFile('tsdown.config.ts'); - - expect(npmConfig).toContain("entry: ['./src/launcher.ts', './src/main.ts']"); - expect(npmConfig).toContain("entryFileNames: '[name].mjs'"); - expect(npmConfig).toContain('/^@opentui\\//u'); - expect(npmConfig).toContain("'node-pty'"); - }); - - it('routes npm and production development through launcher.mjs', () => { - const packageJson = JSON.parse(readAppFile('package.json')) as { - bin?: Record<string, string>; - scripts?: Record<string, string>; - }; - - expect(packageJson.bin?.['pythinker']).toBe('dist/launcher.mjs'); - expect(packageJson.scripts?.['dev:prod']).toBe('node dist/launcher.mjs'); - }); -}); diff --git a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts b/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts deleted file mode 100644 index 244bc6c6..00000000 --- a/apps/pythinker-code/test/scripts/release/cdn-consistency.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - classifyCdnVersion, - compareRelease, - pollCdnUntilCaughtUp, -} from '../../../../../scripts/release/cdn-consistency.mjs'; - -const URL = 'https://cdn.example/latest.json'; - -/** Response stub shaped like the subset of `fetch` the poll actually reads. */ -function manifest(version: unknown, ok = true, status = 200) { - return { - ok, - status, - text: async () => JSON.stringify({ version }), - }; -} - -/** - * Fake clock and sleep: `sleep` advances the clock instead of waiting, so a - * ten-minute budget resolves instantly and the test asserts real elapsed logic. - */ -function fakeClock() { - let current = 0; - return { - now: () => current, - sleep: async (ms: number) => { - current += ms; - }, - }; -} - -/** Serve one scripted outcome per attempt. */ -function scriptedFetch(steps: readonly (() => unknown)[]) { - let index = 0; - return async () => { - const step = steps[Math.min(index, steps.length - 1)]; - index += 1; - return step?.(); - }; -} - -describe('compareRelease', () => { - it('orders by major, then minor, then patch', () => { - const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; - expect(compareRelease(parse('1.0.0'), parse('0.9.9'))).toBeGreaterThan(0); - expect(compareRelease(parse('0.13.0'), parse('0.12.0'))).toBeGreaterThan(0); - expect(compareRelease(parse('0.12.1'), parse('0.12.2'))).toBeLessThan(0); - expect(compareRelease(parse('0.12.0'), parse('0.12.0'))).toBe(0); - }); - - it('separates identifiers that float arithmetic would round together', () => { - const parse = (value: string) => /^(\d+)\.(\d+)\.(\d+)$/u.exec(value) as RegExpExecArray; - // 9007199254740992 and 9007199254740993 are the same IEEE-754 double. - expect(compareRelease(parse('9007199254740993.0.0'), parse('9007199254740992.0.0'))).toBe(1); - expect(compareRelease(parse('0.9007199254740992.0'), parse('0.9007199254740993.0'))).toBe(-1); - }); -}); - -describe('classifyCdnVersion', () => { - it('reports an equal version as a match', () => { - expect(classifyCdnVersion('0.13.0', '0.13.0')).toBe('match'); - }); - - it('reports an older CDN version as behind', () => { - expect(classifyCdnVersion('0.12.0', '0.13.0')).toBe('behind'); - expect(classifyCdnVersion('0.13.0', '1.0.0')).toBe('behind'); - }); - - it('reports a newer CDN version as ahead', () => { - expect(classifyCdnVersion('0.14.0', '0.13.0')).toBe('ahead'); - }); - - it('rejects anything that is not a stable release version', () => { - expect(classifyCdnVersion('not-a-version', '0.13.0')).toBe('invalid'); - expect(classifyCdnVersion('0.13.0-beta.1', '0.13.0')).toBe('invalid'); - expect(classifyCdnVersion('', '0.13.0')).toBe('invalid'); - expect(classifyCdnVersion(undefined, '0.13.0')).toBe('invalid'); - }); -}); - -describe('pollCdnUntilCaughtUp', () => { - const base = { url: URL, npmLatest: '0.13.0', budgetMs: 600_000, intervalMs: 15_000 }; - - it('resolves on the first attempt when the CDN already matches', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.13.0')]), - }); - - expect(result).toMatchObject({ ok: true, reason: 'match', cdnVersion: '0.13.0', attempts: 1 }); - }); - - it('keeps polling while the CDN is behind and succeeds once it catches up', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([ - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.13.0'), - ]), - }); - - expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 3 }); - }); - - it('re-triggers on the configured cadence while the CDN is behind', async () => { - const { now, sleep } = fakeClock(); - let retriggerCalls = 0; - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([ - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.12.0'), - () => manifest('0.13.0'), - ]), - retrigger: async () => { - retriggerCalls += 1; - }, - retriggerEveryAttempts: 3, - }); - - expect(retriggerCalls).toBe(2); - expect(result).toMatchObject({ ok: true, attempts: 7, retriggers: 2 }); - }); - - it('does not re-trigger when the first attempt matches', async () => { - const { now, sleep } = fakeClock(); - let retriggerCalls = 0; - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.13.0')]), - retrigger: async () => { - retriggerCalls += 1; - }, - retriggerEveryAttempts: 1, - }); - - expect(retriggerCalls).toBe(0); - expect(result.retriggers).toBe(0); - }); - - it('does not re-trigger when the CDN is ahead', async () => { - const { now, sleep } = fakeClock(); - let retriggerCalls = 0; - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.14.0')]), - retrigger: async () => { - retriggerCalls += 1; - }, - retriggerEveryAttempts: 1, - }); - - expect(retriggerCalls).toBe(0); - expect(result).toMatchObject({ reason: 'ahead', retriggers: 0 }); - }); - - it('keeps polling when a re-trigger throws', async () => { - const { now, sleep } = fakeClock(); - let retriggerCalls = 0; - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.12.0'), () => manifest('0.13.0')]), - retrigger: async () => { - retriggerCalls += 1; - throw new Error('trigger failed'); - }, - retriggerEveryAttempts: 1, - }); - - expect(retriggerCalls).toBe(1); - expect(result).toMatchObject({ ok: true, attempts: 2, retriggers: 1 }); - }); - - it('re-triggers while the CDN is unreachable', async () => { - const { now, sleep } = fakeClock(); - let retriggerCalls = 0; - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([ - () => { - throw new Error('ECONNREFUSED'); - }, - () => manifest('0.13.0'), - ]), - retrigger: async () => { - retriggerCalls += 1; - }, - retriggerEveryAttempts: 1, - }); - - expect(retriggerCalls).toBe(1); - expect(result).toMatchObject({ ok: true, retriggers: 1 }); - }); - - it('reports re-trigger attempts when the budget expires', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - budgetMs: 45_000, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.12.0')]), - retrigger: async () => {}, - retriggerEveryAttempts: 1, - }); - - expect(result).toMatchObject({ reason: 'timeout', attempts: 3, retriggers: 2 }); - }); - - it('treats an unreachable CDN as lag rather than a failure', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([ - () => { - throw new Error('ECONNREFUSED'); - }, - () => manifest('0.13.0', false, 502), - () => ({ ok: true, status: 200, text: async () => 'not json' }), - () => manifest('0.13.0'), - ]), - }); - - expect(result).toMatchObject({ ok: true, reason: 'match', attempts: 4 }); - }); - - it('fails immediately when the CDN is ahead of npm', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.14.0')]), - }); - - expect(result).toMatchObject({ - ok: false, - reason: 'ahead', - cdnVersion: '0.14.0', - attempts: 1, - }); - }); - - it('gives up with the last observed version once the budget expires', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - budgetMs: 45_000, - now, - sleep, - fetchImpl: scriptedFetch([() => manifest('0.12.0')]), - }); - - expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: '0.12.0' }); - expect(result.attempts).toBe(3); - }); - - it('reports a null version when the CDN was never readable', async () => { - const { now, sleep } = fakeClock(); - const result = await pollCdnUntilCaughtUp({ - ...base, - budgetMs: 15_000, - now, - sleep, - fetchImpl: scriptedFetch([ - () => { - throw new Error('ENOTFOUND'); - }, - ]), - }); - - expect(result).toMatchObject({ ok: false, reason: 'timeout', cdnVersion: null }); - }); -}); diff --git a/apps/pythinker-code/test/tui/activity-pane.test.ts b/apps/pythinker-code/test/tui/activity-pane.test.ts index b9274c0a..8031f288 100644 --- a/apps/pythinker-code/test/tui/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/activity-pane.test.ts @@ -1,17 +1,8 @@ -import chalk from 'chalk'; -import type { Event } from '@pymodel/pythinker-code-sdk'; import { describe, expect, it, vi } from 'vitest'; -import { DynamicWorkflowMissionControlComponent } from '#/tui/components/messages/dynamic-workflow-mission-control'; -import { - BRAILLE_SPINNER_FRAMES, - BRAILLE_SPINNER_INTERVAL_MS, - formatThinkingSpinnerLabel, -} from '#/tui/constant/rendering'; +import { AgentDynamicWorkflowProgressComponent } from '#/tui/components/messages/agent-dynamic-workflow-progress'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; -import { currentTheme, darkColors } from '#/tui/theme'; interface ActivityDriver { state: TUIState; @@ -28,7 +19,6 @@ function makeStartupInput(): PythinkerTUIStartupInput { cliOptions: { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -36,15 +26,15 @@ function makeStartupInput(): PythinkerTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', - layout: 'inline', - copyFullResponse: false, + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', @@ -64,14 +54,10 @@ function makeDriverWithTerminalProgress(): { return { driver, state: driver.state, setProgress }; } -function startDynamicWorkflow( - driver: ActivityDriver, - state: TUIState, -): DynamicWorkflowMissionControlComponent { +function startDynamicWorkflowProgress(driver: ActivityDriver, state: TUIState): AgentDynamicWorkflowProgressComponent { const handler = driver.sessionEventHandler.subAgentEventHandler; - handler.handleDynamicWorkflowToolCallStarted('call_dynamic_workflow', { + handler.handleAgentDynamicWorkflowToolCallStarted('call_dynamic_workflow', { description: 'Review changed files', - items: ['Review changed files'], }); handler.handleLifecycleEvent({ type: 'subagent.spawned', @@ -87,38 +73,14 @@ function startDynamicWorkflow( subagentId: 'agent-1', } as Parameters<typeof handler.handleLifecycleEvent>[0]); - const missionControl = state.transcriptContainer.children.find( - (child): child is DynamicWorkflowMissionControlComponent => - child instanceof DynamicWorkflowMissionControlComponent, + const progress = state.transcriptContainer.children.find( + (child): child is AgentDynamicWorkflowProgressComponent => child instanceof AgentDynamicWorkflowProgressComponent, ); - if (missionControl === undefined) throw new Error('expected Dynamic Workflow mission control'); - return missionControl; + if (progress === undefined) throw new Error('expected AgentDynamicWorkflow progress'); + return progress; } describe('updateActivityPane terminal progress', () => { - it.each(['waiting', 'tool'] as const)('shows a labeled primary spinner while %s', (mode) => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - chalk.level = 3; - currentTheme.setPalette(darkColors); - try { - const { driver, state } = makeDriverWithTerminalProgress(); - state.livePane = { ...state.livePane, mode }; - - driver.updateActivityPane(); - - const spinner = state.activitySpinner?.instance; - if (spinner === undefined) throw new Error('expected activity spinner'); - expect(strip(spinner.renderInline())).toBe(`⠋ ${formatThinkingSpinnerLabel()}`); - expect(spinner.renderInline().startsWith(currentTheme.fg('primary', '⠋'))).toBe(true); - spinner.stop(); - } finally { - chalk.level = previousLevel; - vi.useRealTimers(); - } - }); - it('toggles terminal progress when the activity pane enters and leaves work mode', () => { vi.useFakeTimers(); try { @@ -204,184 +166,54 @@ describe('updateActivityPane terminal progress', () => { } }); - it('moves the one host loader into Dynamic Workflow without creating a component timer', () => { + it('moves the thinking indicator into the AgentDynamicWorkflow progress row while active', () => { vi.useFakeTimers(); try { const { driver, state, setProgress } = makeDriverWithTerminalProgress(); + const progress = startDynamicWorkflowProgress(driver, state); state.livePane = { ...state.livePane, mode: 'tool' }; + driver.updateActivityPane(); - const timersBeforeMissionControl = vi.getTimerCount(); - const missionControl = startDynamicWorkflow(driver, state); - expect(vi.getTimerCount()).toBe(timersBeforeMissionControl); expect(setProgress).toHaveBeenCalledTimes(1); expect(setProgress).toHaveBeenLastCalledWith(true); expect(state.activitySpinner).not.toBeNull(); expect(state.activityContainer.children).toHaveLength(0); - expect(vi.getTimerCount()).toBe(timersBeforeMissionControl); - const output = strip(missionControl.render(100).join('\n')); - expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/u); - expect(output).not.toContain(formatThinkingSpinnerLabel()); + expect(strip(progress.render(80).join('\n'))).toContain('⣷ Working...'); state.activitySpinner?.instance.stop(); - driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); + driver.sessionEventHandler.clearAgentDynamicWorkflowProgress(); } finally { vi.useRealTimers(); } }); - it('shimmers verb labels independently of the repeating spinner frame', () => { + it('keeps ended AgentDynamicWorkflow progress on a placeholder instead of the thinking indicator', () => { vi.useFakeTimers(); - const previousLevel = chalk.level; - chalk.level = 3; try { - vi.setSystemTime(0); const { driver, state } = makeDriverWithTerminalProgress(); - state.livePane = { ...state.livePane, mode: 'idle' }; - state.appState.streamingPhase = 'composing'; - driver.updateActivityPane(); - - const spinner = state.activitySpinner?.instance; - if (spinner === undefined) throw new Error('expected activity spinner'); - const before = spinner.renderInline(); - - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * BRAILLE_SPINNER_FRAMES.length); - const after = spinner.renderInline(); - - expect(strip(after)).toBe(strip(before)); - expect(after).not.toBe(before); - - spinner.stop(); - } finally { - chalk.level = previousLevel; - vi.useRealTimers(); - } - }); - - it('keeps terminal workflow output static while the host loader remains owned by the activity pane', () => { - vi.useFakeTimers(); - try { - const { driver, state } = makeDriverWithTerminalProgress(); - const missionControl = startDynamicWorkflow(driver, state); - state.livePane = { ...state.livePane, mode: 'tool' }; - driver.updateActivityPane(); - const hostTimerCount = vi.getTimerCount(); - driver.sessionEventHandler.subAgentEventHandler.handleDynamicWorkflowToolResult( + const progress = startDynamicWorkflowProgress(driver, state); + driver.sessionEventHandler.subAgentEventHandler.handleAgentDynamicWorkflowToolResult( 'call_dynamic_workflow', { tool_call_id: 'call_dynamic_workflow', - output: [ - '<dynamic_workflow_result>', - '<summary>completed: 1, failed: 0, aborted: 0</summary>', - '<subagent outcome="completed">Done</subagent>', - '</dynamic_workflow_result>', - ].join('\n'), + output: 'Done', is_error: false, }, false, ); + state.livePane = { ...state.livePane, mode: 'tool' }; driver.updateActivityPane(); expect(state.activitySpinner).not.toBeNull(); expect(state.activityContainer.children).toHaveLength(1); - expect(vi.getTimerCount()).toBe(hostTimerCount); - const output = strip(missionControl.render(100).join('\n')); - expect(output).toContain('✓ Completed'); - expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); - for (const frame of BRAILLE_SPINNER_FRAMES) expect(output).not.toContain(frame); - - state.activitySpinner?.instance.stop(); - driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); - } finally { - vi.useRealTimers(); - } - }); - - it.each(['failure', 'cancellation', 'cleanup'] as const)( - 'keeps the host loader running after Dynamic Workflow %s', - (outcome) => { - vi.useFakeTimers(); - try { - const { driver, state } = makeDriverWithTerminalProgress(); - state.livePane = { ...state.livePane, mode: 'tool' }; - driver.updateActivityPane(); - const hostLoader = state.activitySpinner?.instance; - if (hostLoader === undefined) throw new Error('expected host activity loader'); - const hostTimerCount = vi.getTimerCount(); - startDynamicWorkflow(driver, state); - - if (outcome === 'failure') { - driver.sessionEventHandler.subAgentEventHandler.handleDynamicWorkflowToolResult( - 'call_dynamic_workflow', - { - tool_call_id: 'call_dynamic_workflow', - output: 'provider request failed', - is_error: true, - }, - true, - ); - } else if (outcome === 'cancellation') { - driver.sessionEventHandler.subAgentEventHandler.markActiveDynamicWorkflowsCancelled(); - } else { - driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); - } - driver.updateActivityPane(); - - expect(state.activitySpinner?.instance).toBe(hostLoader); - expect(vi.getTimerCount()).toBe(hostTimerCount); - hostLoader.stop(); - } finally { - vi.useRealTimers(); - } - }, - ); - - it.each([ - [ - 'turn', - (driver: ActivityDriver) => { - driver.sessionEventHandler.handleEvent( - { type: 'turn.started', agentId: 'main', sessionId: 'ses-1', turnId: 2 } as Event, - () => {}, - ); - }, - ], - [ - 'error', - (driver: ActivityDriver) => { - driver.sessionEventHandler.handleEvent( - { - type: 'error', - agentId: 'main', - sessionId: 'ses-1', - code: 'provider.connection_error', - message: 'Provider disconnected', - retryable: false, - } as Event, - () => {}, - ); - }, - ], - ] as const)('leaves active Mission Control static during %s cleanup', (_kind, cleanup) => { - vi.useFakeTimers(); - try { - const { driver, state } = makeDriverWithTerminalProgress(); - state.livePane = { ...state.livePane, mode: 'tool' }; - driver.updateActivityPane(); - const missionControl = startDynamicWorkflow(driver, state); - expect(strip(missionControl.render(100).join('\n'))).toMatch( - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/u, - ); - - cleanup(driver); - driver.updateActivityPane(); + const output = strip(progress.render(80).join('\n')); + expect(output).toContain(' Working...'); + expect(output).not.toContain('⣷ Working...'); - const output = strip(missionControl.render(100).join('\n')); - expect(output).toContain('– Cancelled'); - expect(output).not.toMatch(/[◐◓◑◒] Orchestrating/); - expect(output).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN\s+Review changed files/u); state.activitySpinner?.instance.stop(); + driver.sessionEventHandler.clearAgentDynamicWorkflowProgress(); } finally { vi.useRealTimers(); } diff --git a/apps/pythinker-code/test/tui/banner/banner-provider.test.ts b/apps/pythinker-code/test/tui/banner/banner-provider.test.ts index fe246225..b21de59d 100644 --- a/apps/pythinker-code/test/tui/banner/banner-provider.test.ts +++ b/apps/pythinker-code/test/tui/banner/banner-provider.test.ts @@ -23,7 +23,6 @@ describe('selectBannerState', () => { } it('returns the active banner when enabled and no time window is set', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, @@ -68,8 +67,65 @@ describe('selectBannerState', () => { expect(result).toBeNull(); }); + it('shows the active banner only below banner_max_version', () => { + const json = { + banner_enabled: true, + banner_maintext: 'Upgrade', + banner_max_version: '0.15.0', + }; + expect(selectBannerState(json, '0.14.0', now, () => 0)).toMatchObject({ + mainText: 'Upgrade', + }); + expect(selectBannerState(json, '0.15.0', now, () => 0)).toBeNull(); + expect(selectBannerState(json, '0.16.0', now, () => 0)).toBeNull(); + }); + + it('shows the active banner only on the exact banner_version', () => { + const json = { + banner_enabled: true, + banner_maintext: 'Pinned', + banner_version: '0.14.0', + }; + expect(selectBannerState(json, '0.14.0', now, () => 0)).toMatchObject({ + mainText: 'Pinned', + }); + expect(selectBannerState(json, '0.14.1', now, () => 0)).toBeNull(); + expect(selectBannerState(json, '0.13.9', now, () => 0)).toBeNull(); + }); + + it('combines min and max version as an inclusive-exclusive range', () => { + const json = { + banner_enabled: true, + banner_maintext: 'Range', + banner_min_version: '0.13.0', + banner_max_version: '0.15.0', + }; + expect(selectBannerState(json, '0.12.9', now, () => 0)).toBeNull(); + expect(selectBannerState(json, '0.13.0', now, () => 0)).not.toBeNull(); + expect(selectBannerState(json, '0.14.9', now, () => 0)).not.toBeNull(); + expect(selectBannerState(json, '0.15.0', now, () => 0)).toBeNull(); + }); + + it('filters out the banner when a version constraint is not valid semver', () => { + for (const constraint of [ + { banner_max_version: 'not-a-version' }, + { banner_version: 'not-a-version' }, + ]) { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Broken', + ...constraint, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + } + }); + it('picks a random enabled fallback when the active banner is not shown', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: false, @@ -87,7 +143,6 @@ describe('selectBannerState', () => { }); it('filters out fallback entries when the client version is too low', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: false, @@ -104,6 +159,24 @@ describe('selectBannerState', () => { expectAlwaysBanner(result, { tag: null, mainText: 'Old tip', subText: null }); }); + it('filters out fallback entries by max and exact version', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_maintext: 'Too new', banner_max_version: '0.14.0' }, + { enabled: true, banner_maintext: 'Other version', banner_version: '0.13.0' }, + { enabled: true, banner_maintext: 'Matching', banner_version: '0.14.0' }, + ], + }, + '0.14.0', + now, + () => 0.99, + ); + expectAlwaysBanner(result, { tag: null, mainText: 'Matching', subText: null }); + }); + it('returns null when no enabled fallback entries exist', () => { const result = selectBannerState( { @@ -123,7 +196,6 @@ describe('selectBannerState', () => { }); it('falls back to the fallback list when banner_enabled is missing', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_fallback_enabled: true, @@ -137,7 +209,6 @@ describe('selectBannerState', () => { }); it('treats an empty tag as null while still showing the banner', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, @@ -152,7 +223,6 @@ describe('selectBannerState', () => { }); it('makes the active banner unavailable when mainText is empty', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, @@ -169,7 +239,6 @@ describe('selectBannerState', () => { }); it('treats missing subtext as null', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, @@ -183,7 +252,6 @@ describe('selectBannerState', () => { }); it('treats empty time fields as always valid', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, @@ -199,7 +267,6 @@ describe('selectBannerState', () => { }); it('falls back to UTC when timestamps have no timezone', () => { - expect.hasAssertions(); const result = selectBannerState( { banner_enabled: true, diff --git a/apps/pythinker-code/test/tui/commands/add-dir.test.ts b/apps/pythinker-code/test/tui/commands/add-dir.test.ts index 4251a02b..c4fc649d 100644 --- a/apps/pythinker-code/test/tui/commands/add-dir.test.ts +++ b/apps/pythinker-code/test/tui/commands/add-dir.test.ts @@ -1,88 +1,212 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SlashCommandHost } from '#/tui/commands'; import { handleAddDirCommand } from '#/tui/commands/add-dir'; +import { dispatchInput, type SlashCommandHost } from '#/tui/commands/dispatch'; -describe('add-dir slash command', () => { - it('adds a validated directory to the active session', async () => { - const host = makeHost(); +type MountedPanel = { + handleInput: (data: string) => void; + render: (width: number) => string[]; +}; - await handleAddDirCommand(host, '/tmp/extra'); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('\r'); +const ANSI_SGR = /\u001B\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function makeHost(additionalDirs: readonly string[] = []) { + const state = { + appState: { + additionalDirs, + streamingPhase: 'idle', + isCompacting: false, + }, + }; + let mountedPanel: MountedPanel | null = null; + const session = { + id: 'session-1', + summary: { + additionalDirs, + }, + addAdditionalDir: vi.fn(async (path: string, options: { persist: boolean }) => ({ + additionalDirs: [...additionalDirs, path], + projectRoot: '/repo', + configPath: '/repo/.pythinker-code/local.toml', + persisted: options.persist, + })), + }; + const host = { + state, + session, + skillCommandMap: new Map<string, string>(), + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(state.appState, patch)), + refreshSlashCommandAutocomplete: vi.fn(), + appendTranscriptEntry: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + sendNormalUserInput: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mountedPanel = panel; + }), + restoreEditor: vi.fn(() => { + mountedPanel = null; + }), + } as unknown as SlashCommandHost & { + session: typeof session; + state: typeof state; + setAppState: ReturnType<typeof vi.fn>; + refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; + appendTranscriptEntry: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + showStatus: ReturnType<typeof vi.fn>; + sendNormalUserInput: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + }; + return { + host, + session, + getMountedPanel: () => mountedPanel, + }; +} + +describe('handleAddDirCommand', () => { + it('shows the empty message when no additional dirs are configured', async () => { + const { host } = makeHost(); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith('No additional directories configured.'); + }); + + it('lists current additional dirs for no args', async () => { + const { host } = makeHost(['/repo/shared', '/repo/docs']); + + await handleAddDirCommand(host, ''); + + expect(host.showStatus).toHaveBeenCalledWith( + 'Additional directories:\n /repo/shared\n /repo/docs', + ); + }); + + it('lists current additional dirs for the list subcommand', async () => { + const { host } = makeHost(['/repo/shared']); + + await handleAddDirCommand(host, 'list'); + + expect(host.showStatus).toHaveBeenCalledWith('Additional directories:\n /repo/shared'); + }); + + it('renders the add-dir confirmation without option descriptions', async () => { + const { host, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + + const rendered = getMountedPanel()?.render(120).map(strip).join('\n') ?? ''; + expect(rendered).toContain('Add directory to workspace: ../shared'); + expect(rendered).toContain('Yes, for this session'); + expect(rendered).toContain('Yes, and remember this directory'); + expect(rendered).toContain('No'); + expect(rendered).not.toContain('Use this directory in the current session only'); + expect(rendered).not.toContain('Save this directory to the project workspace config'); + expect(rendered).not.toContain('Do not add this directory.'); + }); + + it('adds a workspace dir for this session only after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); + + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput(' '); await vi.waitFor(() => { - expect(host.session.addWorkspaceDirectory).toHaveBeenCalledWith('/tmp/extra'); + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: false }); }); - expect(host.showNotice).toHaveBeenCalledWith( - 'Added /tmp/extra as a working directory for this session', - '/permissions to manage', - ); + expect(host.restoreEditor).toHaveBeenCalledOnce(); + expect(host.setAppState).toHaveBeenCalledWith({ + additionalDirs: ['../shared'], + }); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n For this session only', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); }); - it('can remember a directory in user configuration', async () => { - const host = makeHost(); + it('adds a remembered workspace dir after confirmation', async () => { + const { host, session, getMountedPanel } = makeHost(); - await handleAddDirCommand(host, '/tmp/extra'); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); await vi.waitFor(() => { - expect(host.harness.setConfig).toHaveBeenCalledWith({ - additionalDirs: ['/tmp/existing', '/tmp/extra'], - }); + expect(session.addAdditionalDir).toHaveBeenCalledWith('../shared', { persist: true }); }); - expect(host.showNotice).toHaveBeenCalledWith( - 'Added /tmp/extra as a working directory and saved to user settings', - '/permissions to manage', - ); + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalledWith( + 'Added workspace directory:\n ../shared\n Saved to:\n /repo/.pythinker-code/local.toml', + 'success', + ); + }); + expect(host.appendTranscriptEntry).not.toHaveBeenCalled(); }); - it('opens a path input when invoked without arguments', async () => { - const host = makeHost(); + it('does not add a workspace dir when the confirmation is cancelled', async () => { + const { host, session, getMountedPanel } = makeHost(); - await handleAddDirCommand(host, ''); + await handleAddDirCommand(host, '../shared'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput('\u001B[B'); + getMountedPanel()?.handleInput(' '); - const input = host.mountEditorReplacement.mock.calls[0]?.[0] as { - render(width: number): string[]; - }; - expect(input.render(100).join('\n')).toContain('Add working directory'); + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Did not add ../shared as a working directory.'); }); -}); -function makeHost() { - const session = { - addWorkspaceDirectory: vi.fn(async () => ({ - path: '/tmp/extra', - source: 'session' as const, - })), - }; - const harness = { - getConfig: vi.fn(async () => ({ - providers: {}, - additionalDirs: ['/tmp/existing'], - })), - setConfig: vi.fn(async () => ({ providers: {} })), - }; - return { - session, - harness, - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - showError: vi.fn(), - showNotice: vi.fn(), - track: vi.fn(), - } as unknown as SlashCommandHost & { - session: typeof session; - harness: typeof harness; - mountEditorReplacement: ReturnType<typeof vi.fn>; - restoreEditor: ReturnType<typeof vi.fn>; - showError: ReturnType<typeof vi.fn>; - showNotice: ReturnType<typeof vi.fn>; - }; -} + it('routes /add-dir errors through the slash-command dispatcher error handler', async () => { + const { host, session, getMountedPanel } = makeHost(); + session.addAdditionalDir.mockRejectedValueOnce(new Error('workspace.additional_dir must exist and be a directory')); + + dispatchInput(host, '/add-dir ../other'); + await vi.waitFor(() => { + expect(getMountedPanel()).not.toBeNull(); + }); + getMountedPanel()?.handleInput(' '); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'workspace.additional_dir must exist and be a directory', + ); + }); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('re-checks the busy gate after lazy session creation (v2)', async () => { + const { host, session, getMountedPanel } = makeHost(); + // Session-less v2: the path-adding form lazy-creates via ensureSession. + Object.assign(host, { + session: undefined, + engineV2: true, + ensureSession: vi.fn(async () => { + // A first prompt starts a turn while the session is being created. + host.state.appState.streamingPhase = 'waiting'; + return session; + }), + }); + + await handleAddDirCommand(host, '../shared'); + + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /add-dir while streaming — press Esc or Ctrl-C first.', + ); + expect(getMountedPanel()).toBeNull(); + expect(session.addAdditionalDir).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/advisor.test.ts b/apps/pythinker-code/test/tui/commands/advisor.test.ts deleted file mode 100644 index ad5188a3..00000000 --- a/apps/pythinker-code/test/tui/commands/advisor.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { handleAdvisorCommand } from '#/tui/commands/index'; -import type { SlashCommandHost } from '#/tui/commands/dispatch'; - -function makeHost() { - const securityStatus = { - id: 'security', - name: 'Security', - enabled: true, - status: 'running' as const, - model: 'reviewer', - failures: 0, - notes: 2, - costUsd: 0.0123, - }; - const advisor = { - status: vi.fn(async () => [securityStatus]), - setEnabled: vi.fn(async () => [securityStatus]), - reload: vi.fn(async () => []), - }; - const host = { - session: { advisor }, - showError: vi.fn(), - showNotice: vi.fn(), - showStatus: vi.fn(), - } as unknown as SlashCommandHost; - return { host, advisor, securityStatus }; -} - -describe('handleAdvisorCommand', () => { - it('renders advisor status with runtime details', async () => { - const { host } = makeHost(); - - await handleAdvisorCommand(host, 'status'); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Advisor status', - expect.stringContaining('● Security [running]'), - ); - expect(host.showNotice).toHaveBeenCalledWith( - 'Advisor status', - expect.stringContaining('2 notes · $0.0123'), - ); - }); - it('rejects an unknown advisor instead of reporting success', async () => { - const { host, advisor } = makeHost(); - advisor.setEnabled.mockResolvedValueOnce([]); - - await handleAdvisorCommand(host, 'on securty'); - - expect(host.showError).toHaveBeenCalledWith( - 'Unknown advisor: securty. Run /advisor status to list configured advisors.', - ); - expect(host.showStatus).not.toHaveBeenCalled(); - }); - it('does not report success when the runtime status remains disabled', async () => { - const { host, advisor, securityStatus } = makeHost(); - advisor.setEnabled.mockResolvedValueOnce([{ ...securityStatus, enabled: false }]); - - await handleAdvisorCommand(host, 'on security'); - - expect(host.showError).toHaveBeenCalledWith('Advisor security remains disabled.'); - expect(host.showStatus).not.toHaveBeenCalled(); - }); - it('does not report global enable success for an empty advisor set', async () => { - const { host, advisor } = makeHost(); - advisor.status.mockResolvedValueOnce([]); - advisor.setEnabled.mockResolvedValueOnce([]); - - await handleAdvisorCommand(host, 'on'); - - expect(host.showError).toHaveBeenCalledWith('Advisor remains disabled.'); - expect(host.showStatus).not.toHaveBeenCalled(); - }); - - it('toggles one advisor without changing its configuration file', async () => { - const { host, advisor, securityStatus } = makeHost(); - advisor.setEnabled.mockResolvedValueOnce([{ ...securityStatus, enabled: false }]); - - await handleAdvisorCommand(host, 'toggle security'); - - expect(advisor.setEnabled).toHaveBeenCalledWith(false, 'security'); - expect(host.showStatus).toHaveBeenCalledWith('Advisor security disabled.'); - }); - - it('reloads watchdog configuration', async () => { - const { host, advisor } = makeHost(); - - await handleAdvisorCommand(host, 'reload'); - - expect(advisor.reload).toHaveBeenCalledOnce(); - expect(host.showStatus).toHaveBeenCalledWith('Advisor configuration reloaded.'); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/auth.test.ts b/apps/pythinker-code/test/tui/commands/auth.test.ts index 11442611..30ca06b2 100644 --- a/apps/pythinker-code/test/tui/commands/auth.test.ts +++ b/apps/pythinker-code/test/tui/commands/auth.test.ts @@ -1,184 +1,144 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CatalogProviderEntry, PythinkerConfig } from '@pymodel/pythinker-code-sdk'; - -import { connectCatalogProvider } from '#/tui/commands/auth'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; +import { + OPENAI_CODEX_OAUTH_PLATFORM_ID, + OPENAI_CODEX_PROVIDER_ID, + applyOpenAICodexOAuthConfig, + fetchOpenAICodexModels, + runOpenAICodexOAuthFlow, +} from '@pymodel/pythinker-code-oauth'; + +import { handleLoginCommand } from '#/tui/commands/auth'; +import { + promptModelSelectionForCodex, + promptPlatformSelection, +} from '#/tui/commands/prompts'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; -vi.mock('#/tui/commands/prompts', () => ({ - promptApiKey: vi.fn(), - promptLogoutProviderSelection: vi.fn(), - promptModelSelectionForCatalog: vi.fn(), - promptModelSelectionForOpenPlatform: vi.fn(), - promptPlatformSelection: vi.fn(), -})); - -const { promptApiKey, promptModelSelectionForCatalog } = await import('#/tui/commands/prompts'); +vi.mock('@pymodel/pythinker-code-oauth', async (importOriginal) => { + const actual = await importOriginal<typeof import('@pymodel/pythinker-code-oauth')>(); + return { + ...actual, + applyOpenAICodexOAuthConfig: vi.fn(actual.applyOpenAICodexOAuthConfig), + runOpenAICodexOAuthFlow: vi.fn(async () => ({ + accessToken: 'access-token-fixture', + refreshToken: 'refresh-token-fixture', + accountId: 'account-fixture', + })), + fetchOpenAICodexModels: vi.fn(async () => [ + { + id: 'gpt-5-codex', + contextLength: 256_000, + supportsReasoning: true, + supportedReasoningEfforts: ['low', 'high'], + supportsImageIn: true, + supportsVideoIn: false, + }, + ]), + }; +}); -const CATALOG_ENTRY: CatalogProviderEntry = { - id: 'anthropic', - name: 'Anthropic', - npm: '@ai-sdk/anthropic', - api: 'https://api.anthropic.com', - env: ['TEST_CATALOG_API_KEY'], - models: { - 'claude-opus-4-7': { - id: 'claude-opus-4-7', - name: 'Claude Opus 4.7', - limit: { context: 200_000, output: 64_000 }, - tool_call: true, - reasoning: true, - modalities: { input: ['text', 'image'], output: ['text'] }, - }, - }, -} as CatalogProviderEntry; +vi.mock('#/tui/commands/prompts', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); + return { + ...actual, + promptPlatformSelection: vi.fn(), + promptModelSelectionForCodex: vi.fn(), + }; +}); -function makeHost(initial: PythinkerConfig) { - let config = initial; - const errors: string[] = []; - const host = { - harness: { - getConfig: vi.fn(async () => config), - setConfig: vi.fn(async (patch: Partial<PythinkerConfig>) => { - config = { ...config, ...patch }; - }), - removeProvider: vi.fn(async (id: string) => { - delete config.providers[id]; - return config; - }), - }, - authFlow: { refreshConfigAfterLogin: vi.fn(async () => undefined) }, - showError: vi.fn((msg: string) => errors.push(msg)), - showStatus: vi.fn(), - track: vi.fn(), - restoreEditor: vi.fn(), - mountEditorReplacement: vi.fn(), - cancelInFlight: undefined, - } as unknown as SlashCommandHost; - return { host, errors, current: () => config }; -} +vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); -describe('connectCatalogProvider credential acquisition', () => { +describe('handleLoginCommand OpenAI Codex OAuth', () => { beforeEach(() => { - vi.mocked(promptModelSelectionForCatalog).mockResolvedValue({ - model: { id: 'claude-opus-4-7' } as never, - effort: 'off', - }); + vi.clearAllMocks(); }); - afterEach(() => { - vi.unstubAllEnvs(); - vi.mocked(promptApiKey).mockReset(); - vi.mocked(promptModelSelectionForCatalog).mockReset(); - }); - - it('uses the env var without prompting when it is set', async () => { - vi.stubEnv('TEST_CATALOG_API_KEY', 'from-env'); - const { host, current } = makeHost({ providers: {} } as PythinkerConfig); - - await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); - - expect(promptApiKey).not.toHaveBeenCalled(); - expect(current().providers['anthropic']).toMatchObject({ - apiKeyEnvVar: 'TEST_CATALOG_API_KEY', + it('keeps the current provider until one atomic replacement is ready', async () => { + vi.mocked(promptPlatformSelection).mockResolvedValue(OPENAI_CODEX_OAUTH_PLATFORM_ID); + vi.mocked(promptModelSelectionForCodex).mockResolvedValue({ + model: { + id: 'gpt-5-codex', + contextLength: 256_000, + supportsReasoning: true, + supportedReasoningEfforts: ['low', 'high'], + supportsImageIn: true, + supportsVideoIn: false, + }, + thinking: 'high', }); - expect(current().providers['anthropic']?.apiKey).toBeUndefined(); - }); - - it('prompts for a key and stores it literally when the env var is unset', async () => { - vi.stubEnv('TEST_CATALOG_API_KEY', ''); - vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in'); - const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig); - - await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); - - expect(errors).toEqual([]); - expect(promptApiKey).toHaveBeenCalledWith( - host, - 'Anthropic', - expect.arrayContaining([expect.stringContaining('config.toml')]), - ); - expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); - }); - - it('prompts for a key when the catalog entry declares no env var', async () => { - vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in'); - const entry = { ...CATALOG_ENTRY, env: undefined } as CatalogProviderEntry; - const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig); - - await connectCatalogProvider(host, 'anthropic', entry); - - expect(errors).toEqual([]); - expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in'); - expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); - }); - - it('aborts without writing config when the key prompt is cancelled', async () => { - vi.stubEnv('TEST_CATALOG_API_KEY', ''); - vi.mocked(promptApiKey).mockResolvedValue(undefined); - const { host, current } = makeHost({ providers: {} } as PythinkerConfig); - - await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); - - expect(current().providers['anthropic']).toBeUndefined(); - expect(host.harness.setConfig).not.toHaveBeenCalled(); - expect(promptModelSelectionForCatalog).not.toHaveBeenCalled(); - }); -}); - -describe('OpenAI Codex login keeps the existing provider until it is replaced', () => { - afterEach(() => { - vi.resetModules(); - vi.doUnmock('@pymodel/pythinker-code-oauth'); - }); - - it('leaves the configured provider intact when the model picker is cancelled', async () => { - const oauth = await import('@pymodel/pythinker-code-oauth'); - vi.doMock('@pymodel/pythinker-code-oauth', () => ({ - ...oauth, - runOpenAICodexOAuthFlow: vi.fn(async () => ({ - accessToken: 'access', - refreshToken: 'refresh', - accountId: 'account', - })), - fetchOpenAICodexModels: vi.fn(async () => [{ id: 'gpt-5-codex', name: 'GPT-5 Codex' }]), - })); - vi.resetModules(); - const { runLogin } = await import('@pymodel/pythinker-code-sdk'); - const { OPENAI_CODEX_OAUTH_PLATFORM_ID, OPENAI_CODEX_PROVIDER_ID } = oauth; let config = { - providers: { [OPENAI_CODEX_PROVIDER_ID]: { apiKey: 'already-signed-in' } }, + providers: { [OPENAI_CODEX_PROVIDER_ID]: { apiKey: 'existing-token-fixture' } }, + models: {}, } as unknown as PythinkerConfig; - const removeProvider = vi.fn(async (id: string) => { - delete config.providers[id]; - return config; + const replaceConfigSections = vi.fn(async (sections: Record<string, unknown>) => { + config = { ...config, ...sections } as PythinkerConfig; }); - const setConfig = vi.fn(); - - await runLogin({ - harness: { getConfig: async () => config, setConfig, removeProvider }, - cancelInFlight: undefined, + const host = { + harness: { + getConfig: vi.fn(async () => config), + supportsAtomicSectionReplace: () => true, + replaceConfigSections, + removeProvider: vi.fn(), + }, + authFlow: { refreshConfigAfterLogin: vi.fn(async () => undefined) }, + showError: vi.fn(), showStatus: vi.fn(), + track: vi.fn(), + restoreEditor: vi.fn(), + mountEditorReplacement: vi.fn(), + cancelInFlight: undefined, + } as unknown as SlashCommandHost; + + await handleLoginCommand(host); + + expect(runOpenAICodexOAuthFlow).toHaveBeenCalledOnce(); + expect(fetchOpenAICodexModels).toHaveBeenCalledOnce(); + expect(applyOpenAICodexOAuthConfig).toHaveBeenCalledOnce(); + expect(host.harness.removeProvider).not.toHaveBeenCalled(); + expect(replaceConfigSections).toHaveBeenCalledOnce(); + expect(config.defaultModel).toBe('openai-codex/gpt-5-codex'); + expect(host.track).toHaveBeenCalledWith('login', { + provider: OPENAI_CODEX_PROVIDER_ID, + method: 'oauth', + }); + }); + + it('does not persist credentials when cancellation arrives during config loading', async () => { + vi.mocked(promptPlatformSelection).mockResolvedValue(OPENAI_CODEX_OAUTH_PLATFORM_ID); + vi.mocked(promptModelSelectionForCodex).mockResolvedValue({ + model: { + id: 'gpt-5-codex', + contextLength: 256_000, + supportsReasoning: true, + supportsImageIn: true, + supportsVideoIn: false, + }, + thinking: 'high', + }); + const replaceConfigSections = vi.fn(); + let host!: SlashCommandHost; + host = { + harness: { + getConfig: vi.fn(async () => { + host.cancelInFlight?.(); + return { providers: {}, models: {} } as PythinkerConfig; + }), + replaceConfigSections, + }, + authFlow: { refreshConfigAfterLogin: vi.fn() }, showError: vi.fn(), - showLoginProgressSpinner: vi.fn(), - showLoginAuthorizationPrompt: vi.fn(), - promptPlatformSelection: async () => ({ - platformId: OPENAI_CODEX_OAUTH_PLATFORM_ID, - catalog: {}, - }), - promptApiKey: async () => undefined, - // The user backs out at the model picker — the most likely early return. - promptModelSelectionForOpenPlatform: async () => undefined, - promptModelSelectionForCatalog: async () => undefined, - refreshConfigAfterLogin: async () => undefined, + showStatus: vi.fn(), track: vi.fn(), - } as never); + cancelInFlight: undefined, + } as unknown as SlashCommandHost; + + await handleLoginCommand(host); - // Removing the provider before the replacement is certain would have - // signed the user out of a working Codex setup for nothing. - expect(removeProvider).not.toHaveBeenCalled(); - expect(config.providers[OPENAI_CODEX_PROVIDER_ID]).toBeDefined(); - expect(setConfig).not.toHaveBeenCalled(); + expect(replaceConfigSections).not.toHaveBeenCalled(); + expect(host.authFlow.refreshConfigAfterLogin).not.toHaveBeenCalled(); + expect(host.showError).not.toHaveBeenCalled(); }); }); diff --git a/apps/pythinker-code/test/tui/commands/copy.test.ts b/apps/pythinker-code/test/tui/commands/copy.test.ts index fdba3b42..6e980aa5 100644 --- a/apps/pythinker-code/test/tui/commands/copy.test.ts +++ b/apps/pythinker-code/test/tui/commands/copy.test.ts @@ -1,253 +1,142 @@ -import { describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands'; -import { - buildMessageActionChoices, - collectRecentAssistantTexts, - extractFencedCodeBlocks, - handleCopyCommand, - showMessageActions, -} from '#/tui/commands/copy'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; - -const fsMocks = vi.hoisted(() => ({ - mkdir: vi.fn(async () => {}), - writeFile: vi.fn(async () => {}), +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { findLastAssistantText, handleCopyCommand } from '#/tui/commands/copy'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; +import type { TranscriptEntry } from '#/tui/types'; + +const mocks = vi.hoisted(() => ({ + copyTextToClipboard: vi.fn(), })); -vi.mock('node:fs/promises', () => fsMocks); vi.mock('#/utils/clipboard/clipboard-text', () => ({ - copyTextToClipboard: vi.fn(async () => {}), + copyTextToClipboard: mocks.copyTextToClipboard, })); +let nextEntryId = 0; + +function entry(kind: TranscriptEntry['kind'], content: string): TranscriptEntry { + return { + id: `entry-${String(nextEntryId++)}`, + kind, + renderMode: 'markdown', + content, + }; +} + +function assistantEntry(content: string): TranscriptEntry { + return { ...entry('assistant', content), modelText: true }; +} + +function makeHost(entries: TranscriptEntry[]) { + const host = { + state: { transcriptEntries: entries }, + showStatus: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost & { + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + }; + return host; +} + describe('copy slash command', () => { - it('collects the newest non-empty assistant responses', () => { - expect( - collectRecentAssistantTexts([ - transcript('assistant', 'first'), - transcript('tool_call', 'ignored'), - transcript('assistant', ''), - transcript('assistant', 'latest'), - ]), - ).toEqual(['latest', 'first']); + it('is registered as an idle-only built-in', () => { + const command = findBuiltInSlashCommand('copy'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('idle-only'); }); +}); - it('extracts fenced code blocks without a Markdown dependency', () => { - expect( - extractFencedCodeBlocks( - 'Before\n```ts\nconst answer = 42;\n```\nAfter\n~~~../../sh\nprintf ok\n~~~', - ), - ).toEqual([ - { code: 'const answer = 42;', language: 'ts' }, - { code: 'printf ok', language: 'sh' }, - ]); +describe('findLastAssistantText', () => { + it('returns an empty string for an empty transcript', () => { + expect(findLastAssistantText([])).toBe(''); }); - it('copies the requested prior response and writes the fallback file', async () => { - const host = makeHost([ - transcript('assistant', 'older'), - transcript('assistant', 'latest'), - ]); + it('returns the newest assistant entry across later non-assistant entries', () => { + const entries = [ + assistantEntry('first answer'), + entry('user', 'follow-up question'), + assistantEntry('second answer'), + entry('user', 'typing…'), + entry('status', 'Working…'), + ]; + + expect(findLastAssistantText(entries)).toBe('second answer'); + }); - await handleCopyCommand(host, '2'); + it('skips assistant entries with empty or whitespace-only content', () => { + const entries = [assistantEntry('real answer'), assistantEntry(' \n ')]; - expect(copyTextToClipboard).toHaveBeenCalledWith('older'); - expect(fsMocks.writeFile).toHaveBeenCalledWith( - expect.stringMatching(/response\.md$/u), - 'older', - 'utf8', - ); - expect(host.showStatus).toHaveBeenCalledWith( - expect.stringContaining('Copied to clipboard'), - 'success', - ); + expect(findLastAssistantText(entries)).toBe('real answer'); }); - it('opens a picker for full response, code blocks, and persistent full-copy mode', async () => { - const host = makeHost([ - transcript('assistant', 'Use this:\n```ts\nconst answer = 42;\n```'), - ]); - - await handleCopyCommand(host, ''); - - expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - render(width: number): string[]; - }; - const rendered = picker.render(100).join('\n'); - expect(rendered).toContain('Copy response'); - expect(rendered).toContain('Full response'); - expect(rendered).toContain('const answer = 42;'); - expect(rendered).toContain('Always copy full responses'); - expect(rendered).toContain('W write to file'); + it('ignores thinking and other non-visible-reply kinds', () => { + const entries = [ + assistantEntry('visible reply'), + entry('thinking', 'hidden reasoning'), + entry('tool_call', 'Bash ls'), + ]; + + expect(findLastAssistantText(entries)).toBe('visible reply'); }); - it('builds newest-first transcript actions for user, assistant, and tool messages', () => { - expect( - buildMessageActionChoices([ - transcript('user', 'fix the parser'), - transcript('thinking', 'private reasoning'), - transcript('tool_call', 'Ran tests', { - name: 'Bash', - args: { command: 'pnpm test' }, - }), - transcript('assistant', 'All checks pass.'), - ]).map(({ label, description }) => ({ label, description })), - ).toEqual([ - { label: 'Assistant', description: 'All checks pass.' }, - { label: 'Bash', description: 'pnpm test' }, - { label: 'User', description: 'fix the parser' }, - ]); + it('skips synthetic assistant cards like hook results and goal completions', () => { + const entries = [ + assistantEntry('real reply'), + entry('assistant', '*PostToolUse hook* ran something'), + entry('assistant', 'Goal completed: shipped the feature'), + ]; + + expect(findLastAssistantText(entries)).toBe('real reply'); + }); +}); + +describe('handleCopyCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.copyTextToClipboard.mockResolvedValue('native'); }); - it('restores a selected user message for editing', () => { - const host = makeHost([transcript('user', 'change this prompt')]); + it('copies the last visible assistant text and reports the character count', async () => { + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); - showMessageActions(host); + await handleCopyCommand(host); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('\r'); - expect(host.restoreInputText).toHaveBeenCalledWith('change this prompt'); + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('final summary'); + expect(host.showStatus).toHaveBeenCalledWith( + `Copied to clipboard (${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); }); - it('copies a selected transcript message with the C action', async () => { - const host = makeHost([transcript('assistant', 'copy this answer')]); + it('marks the copy as unverified when only the terminal escape delivered it', async () => { + mocks.copyTextToClipboard.mockResolvedValue('osc52'); + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); - showMessageActions(host); + await handleCopyCommand(host); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('c'); - await vi.waitFor(() => { - expect(copyTextToClipboard).toHaveBeenCalledWith('copy this answer'); - }); + expect(host.showStatus).toHaveBeenCalledWith( + `Copied via terminal escape sequence (unverified, ${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); }); - it('routes remapped message actions to full copy, primary input, selection, and cancel', async () => { - vi.mocked(copyTextToClipboard).mockClear(); - const host = makeHost([ - transcript('user', 'older draft'), - transcript('assistant', 'assistant action'), - transcript('tool_call', 'full tool action', { name: 'Bash', args: { command: 'pnpm test' } }), - transcript('user', 'newer draft'), - ]); - showMessageActions(host); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - render(width: number): string[]; - setKeybindings(bindings: ReturnType<typeof defaultKeybindings>): void; - }; - const bindings = [ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'MessageActions', - bindings: { - up: null, down: null, enter: null, escape: null, c: null, p: null, - 'ctrl+p': 'messageActions:prev', 'ctrl+n': 'messageActions:next', - 'ctrl+up': 'messageActions:top', 'ctrl+down': 'messageActions:bottom', - 'alt+up': 'messageActions:prevUser', 'alt+down': 'messageActions:nextUser', - 'alt+c': 'messageActions:c', 'alt+p': 'messageActions:p', - 'alt+e': 'messageActions:enter', 'alt+x': 'messageActions:escape', - }, - }, - ]), - ]; - picker.setKeybindings(bindings); - - picker.handleInput('\r'); - expect(host.restoreInputText).not.toHaveBeenCalled(); - expect(copyTextToClipboard).not.toHaveBeenCalled(); - expectSelected(picker, 'User', 'newer draft'); - picker.handleInput('\u001B[A'); - expectSelected(picker, 'User', 'newer draft'); - picker.handleInput('\u001B[B'); - expectSelected(picker, 'User', 'newer draft'); - - picker.handleInput('ctrl+down'); - expectSelected(picker, 'User', 'older draft'); - picker.handleInput('alt+up'); - expectSelected(picker, 'User', 'newer draft'); - picker.handleInput('ctrl+n'); - expectSelected(picker, 'Bash', 'pnpm test'); - picker.handleInput('alt+down'); - expectSelected(picker, 'User', 'older draft'); - picker.handleInput('ctrl+p'); - expectSelected(picker, 'Assistant', 'assistant action'); - picker.handleInput('ctrl+up'); - expectSelected(picker, 'User', 'newer draft'); - picker.handleInput('ctrl+n'); - expectSelected(picker, 'Bash', 'pnpm test'); - picker.handleInput('\u001Bp'); - await vi.waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledWith('pnpm test')); - picker.handleInput('\u001Bc'); - await vi.waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledWith('full tool action')); - picker.handleInput('ctrl+up'); - expectSelected(picker, 'User', 'newer draft'); - picker.handleInput('\u001Be'); - expect(host.restoreInputText).toHaveBeenCalledWith('newer draft'); - - vi.mocked(host.restoreEditor).mockClear(); - showMessageActions(host); - const cancelled = host.mountEditorReplacement.mock.calls[1]?.[0] as { handleInput(data: string): void; setKeybindings(bindings: ReturnType<typeof defaultKeybindings>): void }; - cancelled.setKeybindings(bindings); - cancelled.handleInput('\u001B'); - expect(host.restoreEditor).not.toHaveBeenCalled(); - cancelled.handleInput('\u001Bx'); - expect(host.restoreEditor).toHaveBeenCalled(); + it('warns when there is no assistant message to copy', async () => { + const host = makeHost([entry('user', 'hi')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('No assistant message to copy.', 'warning'); }); -}); -function expectSelected( - picker: { render(width: number): string[] }, - label: string, - description: string, -): void { - const rendered = picker.render(120).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); - expect(rendered).toContain(`❯ ${label}\n ${description}`); -} + it('shows an error when the clipboard write fails', async () => { + mocks.copyTextToClipboard.mockRejectedValue(new Error('pbcopy exited')); + const host = makeHost([assistantEntry('final summary')]); -function transcript( - kind: 'user' | 'assistant' | 'tool_call' | 'thinking', - content: string, - tool?: { readonly name: string; readonly args: Record<string, unknown> }, -) { - return { - id: `${kind}-${content}`, - kind, - renderMode: 'markdown' as const, - content, - ...(tool === undefined - ? {} - : { - toolCallData: { - id: `${kind}-${content}-tool`, - name: tool.name, - args: tool.args, - }, - }), - }; -} + await handleCopyCommand(host); -function makeHost(entries: ReturnType<typeof transcript>[]) { - return { - state: { - copyFullResponse: false, - transcriptEntries: entries, - }, - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - restoreInputText: vi.fn(), - showError: vi.fn(), - showStatus: vi.fn(), - track: vi.fn(), - } as unknown as SlashCommandHost & { - mountEditorReplacement: ReturnType<typeof vi.fn>; - showError: ReturnType<typeof vi.fn>; - showStatus: ReturnType<typeof vi.fn>; - }; -} + expect(host.showError).toHaveBeenCalledWith('Failed to copy to clipboard: pbcopy exited'); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/diff.test.ts b/apps/pythinker-code/test/tui/commands/diff.test.ts deleted file mode 100644 index c13ea314..00000000 --- a/apps/pythinker-code/test/tui/commands/diff.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands'; -import { - buildWorkingTreeDiffLines, - handleDiffCommand, -} from '#/tui/commands/diff'; - -function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -describe('diff slash command', () => { - it('opens a native file picker for current working-tree changes', async () => { - const mountEditorReplacement = vi.fn(); - const host = { - session: { - listWorkingTreeChanges: vi.fn(async () => ({ - branch: 'feature', - additions: 3, - deletions: 1, - truncated: false, - files: [ - { - path: 'src/main.ts', - status: 'modified', - additions: 3, - deletions: 1, - binary: false, - }, - ], - })), - }, - mountEditorReplacement, - restoreEditor: vi.fn(), - showNotice: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleDiffCommand(host, ''); - - expect(mountEditorReplacement).toHaveBeenCalledOnce(); - expect(mountEditorReplacement.mock.calls[0]?.[0].render(100).join('\n')).toContain( - 'src/main.ts', - ); - }); - - it('colors and labels a unified per-file diff', () => { - const lines = buildWorkingTreeDiffLines({ - path: 'src/main.ts', - diff: [ - 'diff --git a/src/main.ts b/src/main.ts', - '@@ -1 +1 @@', - '-old', - '+new', - ].join('\n'), - truncated: false, - }).map(strip); - - expect(lines[0]).toBe('src/main.ts'); - expect(lines).toContain('@@ -1 +1 @@'); - expect(lines).toContain('-old'); - expect(lines).toContain('+new'); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts b/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts deleted file mode 100644 index e24b5312..00000000 --- a/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { promises as fs } from 'node:fs'; -import { tmpdir } from 'node:os'; - -import { join } from 'pathe'; -import { describe, expect, it, vi } from 'vitest'; - -import { handleDynamicWorkflowCommand } from '#/tui/commands/index'; -import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { setDynamicWorkflowDisabled, setWorkflowSizeGuideline } from '#/tui/commands/workflow-availability'; -import { currentTheme } from '#/tui/theme'; - -const ENTER = '\r'; -const ESCAPE = '\u001B'; -const DOWN = '\u001B[B'; - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -interface TestComponent { - render(width: number): string[]; -} - -function makeHost( - overrides: { - model?: string; - hasSession?: boolean; - permissionMode?: 'manual' | 'auto' | 'yolo'; - dynamicWorkflowMode?: boolean; - availableModels?: Record<string, unknown>; - workDir?: string; - lastDynamicWorkflowArgs?: Record<string, unknown>; - } = {}, -) { - const session = { - setPermission: vi.fn(async () => {}), - setDynamicWorkflowMode: vi.fn(async () => {}), - reloadSkills: vi.fn(async () => {}), - }; - const hasSession = overrides.hasSession ?? true; - const host = { - state: { - appState: { - model: overrides.model ?? 'pythinker-model', - permissionMode: overrides.permissionMode ?? 'auto', - dynamicWorkflowMode: overrides.dynamicWorkflowMode ?? false, - availableModels: overrides.availableModels ?? { - 'deepseek-v4': { provider: 'deepseek', model: 'deepseek-v4' }, - }, - workDir: overrides.workDir ?? '/workspace', - }, - theme: currentTheme, - transcriptContainer: { addTranscriptChild: vi.fn() }, - ui: { requestRender: vi.fn() }, - lastDynamicWorkflowArgs: overrides.lastDynamicWorkflowArgs, - }, - session: hasSession ? session : undefined, - requireSession: () => session, - setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), - showError: vi.fn(), - showStatus: vi.fn(), - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - restoreInputText: vi.fn(), - sendNormalUserInput: vi.fn(), - refreshSkillCommands: vi.fn(async () => {}), - } as unknown as SlashCommandHost; - return { host, session }; -} - -interface TestPicker { - handleInput(data: string): void; - render(width: number): string[]; -} - -function mountedPicker(host: SlashCommandHost): TestPicker { - const mock = host.mountEditorReplacement as ReturnType<typeof vi.fn>; - return mock.mock.calls[0]?.[0] as TestPicker; -} - -function markerAddChild(host: SlashCommandHost): ReturnType<typeof vi.fn> { - return host.state.transcriptContainer.addTranscriptChild as ReturnType<typeof vi.fn>; -} - -function expectDynamicWorkflowMarker(host: SlashCommandHost, text: string): void { - const components = markerAddChild(host).mock.calls.map(([component]) => component as TestComponent); - const rendered = stripAnsi(components.at(-1)?.render(80).join('\n') ?? ''); - expect(rendered).toContain(text); -} - -describe('handleDynamicWorkflowCommand', () => { - it('refuses to run when Dynamic Workflow is disabled by configuration', async () => { - setDynamicWorkflowDisabled(true, {}); - - try { - const { host, session } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - - expect(host.showError).toHaveBeenCalledWith('Dynamic Workflow is disabled by configuration.'); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - } finally { - setDynamicWorkflowDisabled(false, {}); - } - }); - - it('sends the Dynamic Workflow prompt as a normal prompt after enabling Dynamic Workflow mode', async () => { - const { host, session } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - - expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - expect(host.state.dynamicWorkflowModeEntry).toBe('task'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - expect(host.mountEditorReplacement).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); - }); - - it('sends the Dynamic Workflow prompt without re-entering Dynamic Workflow mode when already on', async () => { - const { host, session } = makeHost({ permissionMode: 'auto', dynamicWorkflowMode: true }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); - }); - - it('turns Dynamic Workflow mode on without sending a prompt', async () => { - const { host, session } = makeHost({ model: '' }); - - await handleDynamicWorkflowCommand(host, 'on'); - - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - expect(host.showStatus).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('asks before turning Dynamic Workflow mode on in Manual mode', async () => { - const { host, session } = makeHost({ model: '', permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'on'); - - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - expect(session.setPermission).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - const text = stripAnsi(mountedPicker(host).render(80).join('\n')); - expect(text).toContain('Manual mode can block Dynamic Workflow work'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); - }); - expect(session.setPermission).toHaveBeenCalledWith('auto'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); - expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('turns Dynamic Workflow mode on when called without args while Dynamic Workflow mode is off', async () => { - const { host, session } = makeHost({ model: '', dynamicWorkflowMode: false }); - - await handleDynamicWorkflowCommand(host, ''); - - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - expect(host.showError).not.toHaveBeenCalled(); - expect(host.showStatus).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('does not call the session when Dynamic Workflow mode is already on', async () => { - const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); - - await handleDynamicWorkflowCommand(host, 'on'); - - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(host.setAppState).not.toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.showStatus).toHaveBeenCalledWith('Dynamic Workflow mode is already on.'); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('turns Dynamic Workflow mode off without sending a prompt', async () => { - const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); - - await handleDynamicWorkflowCommand(host, 'off'); - - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(false, 'manual'); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: false }); - expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow deactivated'); - expect(host.showStatus).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('turns Dynamic Workflow mode off when called without args while Dynamic Workflow mode is on', async () => { - const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); - - await handleDynamicWorkflowCommand(host, ''); - - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(false, 'manual'); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: false }); - expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow deactivated'); - expect(host.showError).not.toHaveBeenCalled(); - expect(host.showStatus).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('does not call the session when Dynamic Workflow mode is already off', async () => { - const { host, session } = makeHost({ model: '', dynamicWorkflowMode: false }); - - await handleDynamicWorkflowCommand(host, 'off'); - - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(host.setAppState).not.toHaveBeenCalledWith({ dynamicWorkflowMode: false }); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.showStatus).toHaveBeenCalledWith('Dynamic Workflow mode is already off.'); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('asks before starting a Dynamic Workflow task in Manual mode', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - expect(session.setPermission).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - const text = stripAnsi(mountedPicker(host).render(80).join('\n')); - expect(text).toContain('Manual mode can block Dynamic Workflow work'); - expect(text).toContain('Switch to YOLO and start'); - expect(text).not.toContain('Do not start'); - }); - - it('defaults to Auto when confirming a Manual-mode Dynamic Workflow start', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); - }); - expect(session.setPermission).toHaveBeenCalledWith('auto'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); - expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(host.state.dynamicWorkflowModeEntry).toBe('task'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - }); - - it('can start a Manual-mode Dynamic Workflow task without changing permission', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - const picker = mountedPicker(host); - picker.handleInput(DOWN); - picker.handleInput(DOWN); - picker.handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); - }); - expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); - expect(host.state.dynamicWorkflowModeEntry).toBe('task'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - }); - - it('can start a Manual-mode Dynamic Workflow task after switching to YOLO', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - const picker = mountedPicker(host); - picker.handleInput(DOWN); - picker.handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); - }); - expect(session.setPermission).toHaveBeenCalledWith('yolo'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); - expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); - expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); - expect(host.state.dynamicWorkflowModeEntry).toBe('task'); - expectDynamicWorkflowMarker(host, 'Dynamic Workflow activated'); - }); - - it('returns the command to the input box when a Manual-mode Dynamic Workflow start is cancelled', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - mountedPicker(host).handleInput(ESCAPE); - - expect(host.restoreInputText).toHaveBeenCalledWith('/workflow Ship feature X'); - expect(host.showStatus).toHaveBeenCalledWith('Dynamic Workflow task not started.'); - expect(session.setPermission).not.toHaveBeenCalled(); - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('does not start when permission update fails', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - session.setPermission.mockRejectedValueOnce(new Error('denied')); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.showError).toHaveBeenCalledWith( - expect.stringContaining('Failed to set permission mode'), - ); - }); - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('does not send from Manual mode when enabling Dynamic Workflow mode fails after confirmation', async () => { - const { host, session } = makeHost({ permissionMode: 'manual' }); - session.setDynamicWorkflowMode.mockRejectedValueOnce(new Error('denied')); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.showError).toHaveBeenCalledWith( - expect.stringContaining('Failed to enable Dynamic Workflow mode'), - ); - }); - expect(session.setPermission).toHaveBeenCalledWith('auto'); - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('does not send a prompt when enabling Dynamic Workflow mode fails', async () => { - const { host, session } = makeHost({ permissionMode: 'auto' }); - session.setDynamicWorkflowMode.mockRejectedValueOnce(new Error('denied')); - - await handleDynamicWorkflowCommand(host, 'Ship feature X'); - - expect(host.showError).toHaveBeenCalledWith( - expect.stringContaining('Failed to enable Dynamic Workflow mode'), - ); - expect(markerAddChild(host)).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('sets, reports, and clears the Dynamic Workflow subagent model', async () => { - const { host, session } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'model'); - expect(host.showStatus).toHaveBeenLastCalledWith( - expect.stringContaining('use this session model'), - ); - - await handleDynamicWorkflowCommand(host, 'model deepseek-v4'); - expect(host.showStatus).toHaveBeenLastCalledWith('Dynamic Workflow subagents will use deepseek-v4.'); - expect(host.state.appState.dynamicWorkflowModel).toBe('deepseek-v4'); - - await handleDynamicWorkflowCommand(host, 'model'); - expect(host.showStatus).toHaveBeenLastCalledWith( - expect.stringContaining('subagents use deepseek-v4'), - ); - - await handleDynamicWorkflowCommand(host, 'model off'); - expect(host.showStatus).toHaveBeenLastCalledWith( - 'Dynamic Workflow subagents now use this session model.', - ); - expect(host.state.appState.dynamicWorkflowModel).toBeUndefined(); - - // A model subcommand must never be mistaken for a task prompt. - expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('rejects a model alias that is not configured', async () => { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'model not-a-real-alias'); - - expect(host.showError).toHaveBeenCalledWith( - expect.stringContaining('Unknown model: not-a-real-alias'), - ); - expect(host.state.appState.dynamicWorkflowModel).toBeUndefined(); - expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - }); - - it('asks the task to route subagents to the configured model', async () => { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'model deepseek-v4'); - await handleDynamicWorkflowCommand(host, 'Audit every route for missing auth'); - - expect(host.sendNormalUserInput).toHaveBeenCalledWith( - 'Audit every route for missing auth\n\nUse model "deepseek-v4" for the DynamicWorkflow subagents in this task.', - ); - }); -}); - -describe('/workflow save', () => { - it('writes the last run as a skill and refreshes the command list', async () => { - const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-')); - try { - const { host, session } = makeHost({ - permissionMode: 'auto', - workDir, - lastDynamicWorkflowArgs: { - description: 'Audit routes for missing auth', - subagent_type: 'reviewer', - prompt_template: 'Audit {{item}}', - model: 'deepseek-v4', - items: ['a.ts', 'b.ts'], - }, - }); - - await handleDynamicWorkflowCommand(host, 'save Audit Routes'); - - const saved = await fs.readFile( - join(workDir, '.pythinker-code', 'skills', 'audit-routes', 'SKILL.md'), - 'utf8', - ); - expect(saved).toContain('name: "audit-routes"'); - expect(saved).toContain('description: "Audit routes for missing auth"'); - expect(saved).toContain('subagent-type: "reviewer"'); - expect(saved).toContain('Audit {{item}}'); - // Re-discovery must happen before the command set is rebuilt, or the - // freshly written skill is rebuilt from a registry that never saw it. - expect(session.reloadSkills).toHaveBeenCalledOnce(); - expect(host.refreshSkillCommands).toHaveBeenCalledWith(session); - expect(session.reloadSkills.mock.invocationCallOrder[0]).toBeLessThan( - (host.refreshSkillCommands as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0] ?? 0, - ); - expect(host.showError).not.toHaveBeenCalled(); - } finally { - await fs.rm(workDir, { recursive: true, force: true }); - } - }); - - it('refuses a name that would escape the project skills directory', async () => { - const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-')); - try { - const { host, session } = makeHost({ - permissionMode: 'auto', - workDir, - lastDynamicWorkflowArgs: { description: 'Audit routes' }, - }); - - await handleDynamicWorkflowCommand(host, 'save ../../../../tmp/pwned'); - - expect(host.showError).toHaveBeenCalledWith( - expect.stringContaining('not a valid skill name'), - ); - expect(host.refreshSkillCommands).not.toHaveBeenCalled(); - expect(session.reloadSkills).not.toHaveBeenCalled(); - await expect(fs.stat(join(workDir, '.pythinker-code'))).rejects.toThrow(/ENOENT/u); - } finally { - await fs.rm(workDir, { recursive: true, force: true }); - } - }); - - it('explains itself when no workflow has run yet', async () => { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'save nightly-audit'); - - expect(host.showError).toHaveBeenCalledWith( - 'No Dynamic Workflow has run in this session yet.', - ); - }); - - it('asks for a name when given none', async () => { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'save'); - - expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]'); - }); - - it('asks for a name when given only the --personal flag', async () => { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, 'save --personal'); - - expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]'); - }); - - it('saves --personal into the data dir and records the size guideline', async () => { - const home = await fs.mkdtemp(join(tmpdir(), 'workflow-home-')); - vi.stubEnv('PYTHINKER_CODE_HOME', home); - // Explicit empty env: the default is process.env, where an exported - // PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE would override 'small' and fail - // this test for reasons unrelated to the change under test. - setWorkflowSizeGuideline('small', {}); - try { - const { host, session } = makeHost({ - permissionMode: 'auto', - lastDynamicWorkflowArgs: { description: 'Audit routes for missing auth' }, - }); - - await handleDynamicWorkflowCommand(host, 'save --personal Audit Routes'); - - const saved = await fs.readFile(join(home, 'skills', 'audit-routes', 'SKILL.md'), 'utf8'); - expect(saved).toContain('name: "audit-routes"'); - expect(saved).toContain('size-guideline: "small"'); - // The body line is what shapes the re-run; the frontmatter alone is inert. - expect(saved).toContain('at most about 5 subagents'); - expect(session.reloadSkills).toHaveBeenCalledOnce(); - expect(host.showError).not.toHaveBeenCalled(); - } finally { - vi.unstubAllEnvs(); - // The module-level cache cannot return to unset; the resolved default - // ('medium') matches what TUI startup would have cached in production. - setWorkflowSizeGuideline(undefined, {}); - await fs.rm(home, { recursive: true, force: true }); - } - }); - - it('rejects --personal when it is repeated or not at either end', async () => { - for (const input of [ - 'save Audit --personal Routes', - 'save --personal Audit --personal', - 'save --personal --personal', - ]) { - const { host } = makeHost({ permissionMode: 'auto' }); - - await handleDynamicWorkflowCommand(host, input); - - expect(host.showError).toHaveBeenCalledWith('Usage: /workflow save <name> [--personal]'); - } - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/dynamic_workflow.test.ts b/apps/pythinker-code/test/tui/commands/dynamic_workflow.test.ts new file mode 100644 index 00000000..f6ef7ff1 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/dynamic_workflow.test.ts @@ -0,0 +1,339 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleDynamicWorkflowCommand } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { currentTheme } from '#/tui/theme'; + +const ENTER = '\r'; +const ESCAPE = '\u001B'; +const DOWN = '\u001B[B'; + +function stripAnsi(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +interface TestComponent { + render(width: number): string[]; +} + +function makeHost( + overrides: { + model?: string; + hasSession?: boolean; + permissionMode?: 'manual' | 'auto' | 'yolo'; + dynamicWorkflowMode?: boolean; + } = {}, +) { + const session = { + setPermission: vi.fn(async () => {}), + setDynamicWorkflowMode: vi.fn(async () => {}), + }; + const hasSession = overrides.hasSession ?? true; + const host = { + state: { + appState: { + model: overrides.model ?? 'pythinker-model', + permissionMode: overrides.permissionMode ?? 'auto', + dynamicWorkflowMode: overrides.dynamicWorkflowMode ?? false, + }, + theme: currentTheme, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: hasSession ? session : undefined, + requireSession: () => session, + setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), + showError: vi.fn(), + showStatus: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + sendNormalUserInput: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +interface TestPicker { + handleInput(data: string): void; + render(width: number): string[]; +} + +function mountedPicker(host: SlashCommandHost): TestPicker { + const mock = host.mountEditorReplacement as ReturnType<typeof vi.fn>; + return mock.mock.calls[0]?.[0] as TestPicker; +} + +function markerAddChild(host: SlashCommandHost): ReturnType<typeof vi.fn> { + return host.state.transcriptContainer.addChild as ReturnType<typeof vi.fn>; +} + +function expectDynamicWorkflowMarker(host: SlashCommandHost, text: string): void { + const components = markerAddChild(host).mock.calls.map(([component]) => component as TestComponent); + const rendered = stripAnsi(components.at(-1)?.render(80).join('\n') ?? ''); + expect(rendered).toContain(text); +} + +describe('handleDynamicWorkflowCommand', () => { + it('sends the dynamic_workflow prompt as a normal prompt after enabling dynamic_workflow mode', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + expect(host.state.dynamicWorkflowModeEntry).toBe('task'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('sends the dynamic_workflow prompt without re-entering dynamic_workflow mode when already on', async () => { + const { host, session } = makeHost({ permissionMode: 'auto', dynamicWorkflowMode: true }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('turns dynamic_workflow mode on without sending a prompt', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleDynamicWorkflowCommand(host, 'on'); + + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before turning dynamic_workflow mode on in Manual mode', async () => { + const { host, session } = makeHost({ model: '', permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'on'); + + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block dynamic_workflow work'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns dynamic_workflow mode on when called without args while dynamic_workflow mode is off', async () => { + const { host, session } = makeHost({ model: '', dynamicWorkflowMode: false }); + + await handleDynamicWorkflowCommand(host, ''); + + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(host.state.dynamicWorkflowModeEntry).toBe('manual'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when dynamic_workflow mode is already on', async () => { + const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); + + await handleDynamicWorkflowCommand(host, 'on'); + + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('DynamicWorkflow mode is already on.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns dynamic_workflow mode off without sending a prompt', async () => { + const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); + + await handleDynamicWorkflowCommand(host, 'off'); + + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: false }); + expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow deactivated'); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('turns dynamic_workflow mode off when called without args while dynamic_workflow mode is on', async () => { + const { host, session } = makeHost({ model: '', dynamicWorkflowMode: true }); + + await handleDynamicWorkflowCommand(host, ''); + + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(false, 'manual'); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: false }); + expect(host.state.dynamicWorkflowModeEntry).toBeUndefined(); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow deactivated'); + expect(host.showError).not.toHaveBeenCalled(); + expect(host.showStatus).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not call the session when dynamic_workflow mode is already off', async () => { + const { host, session } = makeHost({ model: '', dynamicWorkflowMode: false }); + + await handleDynamicWorkflowCommand(host, 'off'); + + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(host.setAppState).not.toHaveBeenCalledWith({ dynamicWorkflowMode: false }); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('DynamicWorkflow mode is already off.'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('asks before starting a dynamic_workflow task in Manual mode', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(host).render(80).join('\n')); + expect(text).toContain('Manual mode can block dynamic_workflow work'); + expect(text).toContain('Switch to YOLO and start'); + expect(text).not.toContain('Do not start'); + }); + + it('defaults to Auto when confirming a Manual-mode dynamic_workflow start', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(host.state.dynamicWorkflowModeEntry).toBe('task'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + }); + + it('can start a Manual-mode dynamic_workflow task without changing permission', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); + expect(host.state.dynamicWorkflowModeEntry).toBe('task'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + }); + + it('can start a Manual-mode dynamic_workflow task after switching to YOLO', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + const picker = mountedPicker(host); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledTimes(1); + expect(host.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); + expect(host.setAppState).toHaveBeenCalledWith({ dynamicWorkflowMode: true }); + expect(host.state.dynamicWorkflowModeEntry).toBe('task'); + expectDynamicWorkflowMarker(host, 'DynamicWorkflow activated'); + }); + + it('returns the command to the input box when a Manual-mode dynamic_workflow start is cancelled', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ESCAPE); + + expect(host.restoreInputText).toHaveBeenCalledWith('/dynamic_workflow Ship feature X'); + expect(host.showStatus).toHaveBeenCalledWith('DynamicWorkflow task not started.'); + expect(session.setPermission).not.toHaveBeenCalled(); + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not start when permission update fails', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setPermission.mockRejectedValueOnce(new Error('denied')); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to set permission mode'), + ); + }); + expect(session.setDynamicWorkflowMode).not.toHaveBeenCalled(); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send from Manual mode when enabling dynamic_workflow mode fails after confirmation', async () => { + const { host, session } = makeHost({ permissionMode: 'manual' }); + session.setDynamicWorkflowMode.mockRejectedValueOnce(new Error('denied')); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + mountedPicker(host).handleInput(ENTER); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable dynamic_workflow mode'), + ); + }); + expect(session.setPermission).toHaveBeenCalledWith('auto'); + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send a prompt when enabling dynamic_workflow mode fails', async () => { + const { host, session } = makeHost({ permissionMode: 'auto' }); + session.setDynamicWorkflowMode.mockRejectedValueOnce(new Error('denied')); + + await handleDynamicWorkflowCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith( + expect.stringContaining('Failed to enable dynamic_workflow mode'), + ); + expect(markerAddChild(host)).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/experimental-flags.test.ts b/apps/pythinker-code/test/tui/commands/experimental-flags.test.ts deleted file mode 100644 index a1d70cae..00000000 --- a/apps/pythinker-code/test/tui/commands/experimental-flags.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { createPythinkerHarness } from '@pymodel/pythinker-code-sdk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - isExperimentalFlagEnabled, - onExperimentalFeaturesChanged, - setExperimentalFeatureForRun, - setExperimentalFeatures, -} from '#/tui/commands/experimental-flags'; -import { handleVimCommand, type SlashCommandHost } from '#/tui/commands/index'; - -afterEach(() => { - setExperimentalFeatures([]); - vi.unstubAllEnvs(); -}); - -describe('experimental feature snapshot', () => { - it('loads vim mode as disabled by default through the harness', async () => { - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '0'); - vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_VIM_MODE', ''); - const homeDir = await mkdtemp(join(tmpdir(), 'pythinker-vim-mode-')); - const harness = createPythinkerHarness({ homeDir }); - - try { - const features = await harness.getExperimentalFeatures(); - - expect(features.find((feature) => feature.id === 'vim_mode')).toMatchObject({ - id: 'vim_mode', - surface: 'tui', - env: 'PYTHINKER_CODE_EXPERIMENTAL_VIM_MODE', - defaultEnabled: false, - enabled: false, - source: 'default', - }); - } finally { - await harness.close(); - await rm(homeDir, { recursive: true, force: true }); - } - }); - - it('notifies listeners after replacing the snapshot', () => { - setExperimentalFeatures([{ id: 'vim_mode', enabled: false }]); - let observedEnabled = false; - onExperimentalFeaturesChanged(() => { - observedEnabled = isExperimentalFlagEnabled('vim_mode'); - }); - - setExperimentalFeatures([{ id: 'vim_mode', enabled: true }]); - - expect(observedEnabled).toBe(true); - }); - - it('overrides one feature for the current run without dropping other flags', () => { - setExperimentalFeatures([ - { id: 'vim_mode', enabled: false }, - { id: 'lsp', enabled: true }, - ]); - - setExperimentalFeatureForRun('vim_mode', true); - - expect(isExperimentalFlagEnabled('vim_mode')).toBe(true); - expect(isExperimentalFlagEnabled('lsp')).toBe(true); - }); - - it('resets Vim mode after the CLI harness restarts', async () => { - setExperimentalFeatures([{ id: 'vim_mode', enabled: false }]); - const homeDir = await mkdtemp(join(tmpdir(), 'pythinker-vim-session-')); - const harness = createPythinkerHarness({ homeDir }); - const host = { - harness, - state: { - editor: { - isVimModeEnabled: () => false, - setVimMode: vi.fn(), - }, - ui: { requestRender: vi.fn() }, - }, - showStatus: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - try { - await handleVimCommand(host); - expect(host.state.editor.setVimMode).toHaveBeenCalledWith(true); - } finally { - await harness.close(); - } - - const restartedHarness = createPythinkerHarness({ homeDir }); - try { - const features = await restartedHarness.getExperimentalFeatures(); - expect(features.find((feature) => feature.id === 'vim_mode')?.enabled).toBe(false); - } finally { - await restartedHarness.close(); - await rm(homeDir, { recursive: true, force: true }); - } - }); - - it('enables Vim for the current run without persisting it', async () => { - setExperimentalFeatures([{ id: 'vim_mode', enabled: false }]); - const setVimMode = vi.fn(); - const requestRender = vi.fn(); - const host = { - harness: { - setConfig: vi.fn(async () => ({})), - getExperimentalFeatures: vi.fn(async () => []), - }, - state: { - editor: { - isVimModeEnabled: () => false, - setVimMode, - }, - ui: { requestRender }, - }, - showStatus: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleVimCommand(host); - - expect(host.harness.setConfig).not.toHaveBeenCalled(); - expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); - expect(isExperimentalFlagEnabled('vim_mode')).toBe(true); - expect(setVimMode).toHaveBeenCalledWith(true); - expect(requestRender).toHaveBeenCalledOnce(); - expect(host.showStatus).toHaveBeenCalledWith( - 'Editor mode set to vim (NORMAL) for this run. Press i to enter INSERT mode.', - 'success', - ); - }); - - it('clears a legacy persisted Vim setting when disabling it', async () => { - setExperimentalFeatures([{ id: 'vim_mode', enabled: true }]); - const setVimMode = vi.fn(); - const host = { - harness: { - setConfig: vi.fn(async () => ({})), - getExperimentalFeatures: vi.fn(async () => [ - { id: 'vim_mode', enabled: false }, - ]), - }, - state: { - editor: { - isVimModeEnabled: () => true, - setVimMode, - }, - ui: { requestRender: vi.fn() }, - }, - showStatus: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleVimCommand(host); - - expect(host.harness.setConfig).toHaveBeenCalledWith({ - experimental: { vim_mode: false }, - }); - expect(isExperimentalFlagEnabled('vim_mode')).toBe(false); - expect(setVimMode).toHaveBeenLastCalledWith(false); - expect(host.showStatus).toHaveBeenCalledWith( - 'Editor mode set to normal.', - 'success', - ); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index 594d55ec..e41a75ac 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -44,7 +44,7 @@ function makeHost() { ]), }, session, - refreshSkillCommands: vi.fn(async () => {}), + refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), @@ -56,7 +56,7 @@ function makeHost() { setConfig: ReturnType<typeof vi.fn>; getExperimentalFeatures: ReturnType<typeof vi.fn>; }; - refreshSkillCommands: ReturnType<typeof vi.fn>; + refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; reloadCurrentSessionView: ReturnType<typeof vi.fn>; mountEditorReplacement: ReturnType<typeof vi.fn>; restoreEditor: ReturnType<typeof vi.fn>; @@ -85,14 +85,9 @@ describe('experimental feature command handlers', () => { }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(false); - expect(host.refreshSkillCommands).toHaveBeenCalled(); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); expect(host.session.reloadSession).toHaveBeenCalledOnce(); - // A flag can gate which skills exist, so rebuilding the command set before - // the reload read the registry the reload was about to replace. - expect(host.session.reloadSession.mock.invocationCallOrder[0]).toBeLessThan( - host.refreshSkillCommands.mock.invocationCallOrder[0]!, - ); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( host.session, 'Experimental features updated. Session reloaded.', diff --git a/apps/pythinker-code/test/tui/commands/fast.test.ts b/apps/pythinker-code/test/tui/commands/fast.test.ts deleted file mode 100644 index 0226a07d..00000000 --- a/apps/pythinker-code/test/tui/commands/fast.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { handleFastCommand } from '#/tui/commands/fast'; -import type { SlashCommandHost } from '#/tui/commands/dispatch'; - -function makeHost(options: { - readonly hasSession?: boolean; - readonly model?: string; - readonly fastMode?: boolean; - readonly fastModeSupported?: boolean; -} = {}) { - const session = { - getStatus: vi.fn(async () => ({ - fastMode: options.fastMode ?? false, - fastModeSupported: options.fastModeSupported ?? true, - })), - setFastMode: vi.fn(async () => {}), - }; - const host = { - session: options.hasSession === false ? undefined : session, - state: { - appState: { - model: options.model ?? 'openai/gpt-5.6-sol', - fastMode: options.fastMode ?? false, - fastModeSupported: options.fastModeSupported ?? true, - }, - }, - requireSession: () => session, - setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), - showError: vi.fn(), - showStatus: vi.fn(), - showNotice: vi.fn(), - } as unknown as SlashCommandHost; - return { host, session }; -} - -describe('handleFastCommand', () => { - it('enables provider-native Fast mode and warns about premium usage', async () => { - const { host, session } = makeHost(); - - await handleFastCommand(host, 'on'); - - expect(session.setFastMode).toHaveBeenCalledWith(true); - expect(host.setAppState).toHaveBeenCalledWith({ - fastMode: true, - fastModeSupported: true, - }); - expect(host.showNotice).toHaveBeenCalledWith( - '↯ Fast mode on', - expect.stringContaining('premium'), - ); - }); - - it('toggles Fast mode off when called without arguments', async () => { - const { host, session } = makeHost({ fastMode: true }); - - await handleFastCommand(host, ''); - - expect(session.setFastMode).toHaveBeenCalledWith(false); - expect(host.setAppState).toHaveBeenCalledWith({ - fastMode: false, - fastModeSupported: true, - }); - expect(host.showStatus).toHaveBeenCalledWith('Fast mode off.'); - }); - - it('reports current status without changing it', async () => { - const { host, session } = makeHost({ fastMode: true }); - - await handleFastCommand(host, 'status'); - - expect(session.setFastMode).not.toHaveBeenCalled(); - expect(host.showStatus).toHaveBeenCalledWith('↯ Fast mode is on.'); - }); - - it('rejects enabling Fast mode when the current model/provider does not support it', async () => { - const { host, session } = makeHost({ fastModeSupported: false }); - - await handleFastCommand(host, 'on'); - - expect(session.setFastMode).not.toHaveBeenCalled(); - expect(host.showError).toHaveBeenCalledWith( - 'Fast mode is unavailable for the current model and provider.', - ); - }); - - it('reports unavailable status without changing the session', async () => { - const { host, session } = makeHost({ fastModeSupported: false }); - - await handleFastCommand(host, 'status'); - - expect(session.setFastMode).not.toHaveBeenCalled(); - expect(host.showStatus).toHaveBeenCalledWith( - 'Fast mode is unavailable for the current model and provider.', - 'warning', - ); - }); - - it('rejects unknown subcommands', async () => { - const { host, session } = makeHost(); - - await handleFastCommand(host, 'turbo'); - - expect(session.getStatus).not.toHaveBeenCalled(); - expect(session.setFastMode).not.toHaveBeenCalled(); - expect(host.showError).toHaveBeenCalledWith('Usage: /fast [on|off|status]'); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/goal.test.ts b/apps/pythinker-code/test/tui/commands/goal.test.ts index bdb88da0..76e6f8f1 100644 --- a/apps/pythinker-code/test/tui/commands/goal.test.ts +++ b/apps/pythinker-code/test/tui/commands/goal.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { dispatchInput, goalArgumentCompletions, + goalObjectiveLengthWarning, handleGoalCommand, parseGoalCommand, setExperimentalFeatures, @@ -97,7 +98,7 @@ function makeHost( cancel: vi.fn(async () => {}), }; const hasSession = overrides.hasSession ?? true; - const transcriptContainer = { addTranscriptChild: vi.fn() }; + const transcriptContainer = { addChild: vi.fn() }; const host = { state: { appState: { @@ -106,6 +107,7 @@ function makeHost( streamingPhase: overrides.streaming ? 'streaming' : 'idle', isCompacting: false, }, + editor: { getText: vi.fn(() => '') }, transcriptContainer, ui: { requestRender: vi.fn() }, theme: { palette: getBuiltInPalette('dark') }, @@ -213,8 +215,61 @@ describe('parseGoalCommand', () => { }); }); - it('rejects objectives longer than 4000 characters', () => { - expect(parseGoalCommand('x'.repeat(4001))).toMatchObject({ kind: 'error' }); + it('rejects objectives longer than 4000 characters with a file-reference hint', () => { + expect(parseGoalCommand('x'.repeat(4001))).toEqual({ + kind: 'error', + restoreInput: true, + message: + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + }); + expect(parseGoalCommand(`next ${'x'.repeat(4001)}`)).toEqual({ + kind: 'error', + restoreInput: true, + message: + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + }); + }); +}); + +describe('goalObjectiveLengthWarning', () => { + it('warns once the typed /goal objective exceeds the limit', () => { + const warning = goalObjectiveLengthWarning(`/goal ${'x'.repeat(4001)}`); + expect(warning).toContain('(4001/4000 characters)'); + expect(warning).toContain('reference the file path'); + }); + + it('ignores leading whitespace because submitted text is trimmed', () => { + expect(goalObjectiveLengthWarning(` /goal ${'x'.repeat(4001)}`)).toBeDefined(); + }); + + it('warns for over-limit /goal next and /goal replace objectives', () => { + expect(goalObjectiveLengthWarning(`/goal next ${'x'.repeat(4001)}`)).toBeDefined(); + expect(goalObjectiveLengthWarning(`/goal replace ${'x'.repeat(4001)}`)).toBeDefined(); + expect(goalObjectiveLengthWarning(`/goal -- ${'x'.repeat(4001)}`)).toBeDefined(); + }); + + it('stays quiet for valid objectives and non-goal input', () => { + expect(goalObjectiveLengthWarning(`/goal ${'x'.repeat(4000)}`)).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal Ship feature X')).toBeUndefined(); + expect(goalObjectiveLengthWarning('Ship feature X')).toBeUndefined(); + }); + + it('stays quiet for control forms and lookalike commands', () => { + expect(goalObjectiveLengthWarning('/goal')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal status')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal pause')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal next manage')).toBeUndefined(); + expect(goalObjectiveLengthWarning(`/goalie ${'x'.repeat(4001)}`)).toBeUndefined(); + }); + + it('stays quiet when the boundary is a newline or tab (dispatch sends those as plain messages)', () => { + expect(goalObjectiveLengthWarning(`/goal\n${'x'.repeat(4001)}`)).toBeUndefined(); + expect(goalObjectiveLengthWarning(`/goal\t${'x'.repeat(4001)}`)).toBeUndefined(); + }); + + it('still warns for multiline objectives after a literal-space boundary', () => { + const objective = `${'x'.repeat(2000)}\n${'x'.repeat(2001)}`; + expect(goalObjectiveLengthWarning(`/goal ${objective}`)).toBeDefined(); }); }); @@ -251,7 +306,6 @@ describe('handleGoalCommand', () => { expect(session.createGoal).toHaveBeenCalledWith( expect.objectContaining({ objective: 'Ship feature X', replace: false }), ); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); @@ -267,6 +321,34 @@ describe('handleGoalCommand', () => { expect(calls).toEqual([{ receiver: host, text: 'Ship feature X' }]); }); + it('rejects an over-limit objective before sending and restores the typed input', async () => { + const args = 'x'.repeat(4001); + await handleGoalCommand(host, args); + + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledWith( + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + ); + expect(host.restoreInputText).toHaveBeenCalledWith(`/goal ${args}`); + }); + + it('does not restore input for the empty-objective usage hint', async () => { + await handleGoalCommand(host, 'replace'); + + expect(host.showStatus).toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + + it('does not restore over a draft typed while validation was pending', async () => { + vi.mocked(host.state.editor.getText).mockReturnValue('a newer draft'); + + await handleGoalCommand(host, 'x'.repeat(4001)); + + expect(host.showError).toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + it('asks before starting a goal in Manual mode', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); @@ -331,6 +413,25 @@ describe('handleGoalCommand', () => { expect(manualHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'yolo' }); }); + it('restores the previous permission mode when the goal fails to start', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + s.createGoal = vi.fn(async () => { + throw new PythinkerError(ErrorCodes.GOAL_ALREADY_EXISTS, 'A goal already exists'); + }); + + await handleGoalCommand(manualHost, 'Ship feature X'); + const picker = mountedPicker(manualHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + // Switched to YOLO to run the goal, then restored to Manual on failure. + expect(s.setPermission).toHaveBeenLastCalledWith('manual'); + }); + expect(s.setPermission).toHaveBeenCalledWith('yolo'); + expect(manualHost.setAppState).toHaveBeenLastCalledWith({ permissionMode: 'manual' }); + }); + it('returns the command to the input box when a Manual-mode goal start is cancelled', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); @@ -451,8 +552,8 @@ describe('handleGoalCommand', () => { expect(host.showStatus).not.toHaveBeenCalledWith( 'Upcoming goal added. It will start after the current goal is complete.', ); - const addTranscriptChild = host.state.transcriptContainer.addTranscriptChild as ReturnType<typeof vi.fn>; - const message = addTranscriptChild.mock.calls[0]?.[0] as { render(width: number): string[] }; + const addChild = host.state.transcriptContainer.addChild as ReturnType<typeof vi.fn>; + const message = addChild.mock.calls[0]?.[0] as { render(width: number): string[] }; expect(stripAnsi(message.render(80).join('\n'))).toBe( '\n● Upcoming goal added. It will start after the current goal is complete.', ); @@ -705,6 +806,102 @@ describe('dispatchInput /goal integration', () => { expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); + + it('restores the input when /goal is rejected by the busy gate while streaming', async () => { + const { host, session } = makeHost({ streaming: true }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('restores the input when the post-creation busy re-check rejects /goal', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + // A first prompt starts a turn while the lazy session creation awaits. + ensureSession: vi.fn(async () => { + host.state.appState.streamingPhase = 'thinking'; + return session; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('does not restore over a draft typed while lazy session creation was pending', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => { + host.state.appState.streamingPhase = 'thinking'; + // The user kept typing after submitting /goal. + vi.mocked(host.state.editor.getText).mockReturnValue('a newer draft'); + return session; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + + it('restores the input when lazy session creation fails before /goal runs', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => undefined), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('does not restore when an editor-replacement panel opened during creation', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => { + // The user opened a panel (e.g. /help) while creation was pending. + Object.assign(host.state, { editorReplacementMounted: true }); + return undefined; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.state.editorReplacementMounted).toBe(true); + }); + // Allow the post-creation branch to run before asserting. + await new Promise((resolve) => setImmediate(resolve)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); }); describe('goalArgumentCompletions', () => { diff --git a/apps/pythinker-code/test/tui/commands/hooks.test.ts b/apps/pythinker-code/test/tui/commands/hooks.test.ts deleted file mode 100644 index f18bb280..00000000 --- a/apps/pythinker-code/test/tui/commands/hooks.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands'; -import { handleDebugCommand } from '#/tui/commands/debug'; -import { - handleDoctorCommand, - handleHooksCommand, - showContextReport, - showContextFiles, -} from '#/tui/commands/info'; - -describe('debug slash command', () => { - it('injects the bounded current-session log tail and issue description', async () => { - const root = await mkdtemp(join(tmpdir(), 'pythinker-debug-')); - const sessionDir = join(root, 'session'); - const logPath = join(sessionDir, 'logs', 'pythinker-code.log'); - await mkdir(join(sessionDir, 'logs'), { recursive: true }); - await writeFile( - logPath, - `old marker\n${'discarded line\n'.repeat(6_000)}[WARN] newest failure\n`, - ); - const sendNormalUserInput = vi.fn(); - const host = { - harness: { homeDir: root }, - session: { id: 'session-1', summary: { sessionDir } }, - state: { appState: { model: 'test-model' } }, - sendNormalUserInput, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - try { - await handleDebugCommand(host, 'the renderer wrapped a tool row'); - } finally { - await rm(root, { recursive: true, force: true }); - } - - const prompt = String(sendNormalUserInput.mock.calls[0]?.[0]); - expect(prompt).toContain(logPath); - expect(prompt).toContain('[WARN] newest failure'); - expect(prompt).not.toContain('old marker'); - expect(prompt).toContain('the renderer wrapped a tool row'); - }); -}); - -describe('hooks slash command', () => { - it('renders configured command hooks with their event, matcher, and timeout', async () => { - const showNotice = vi.fn(); - const host = { - harness: { - getConfig: vi.fn(async () => ({ - providers: {}, - hooks: [ - { - event: 'PreToolUse', - matcher: '^Bash$', - command: 'check-command', - timeout: 5, - once: true, - async: true, - statusMessage: 'Checking command', - }, - { - event: 'Stop', - command: 'verify-result', - asyncRewake: true, - shell: 'powershell', - }, - { - event: 'Notification', - type: 'http', - url: 'https://hooks.example.test/notify', - headers: { Authorization: 'Bearer SECRET' }, - async: true, - }, - { - event: 'Stop', - type: 'prompt', - prompt: 'Check the result', - model: 'fast-model', - if: 'Bash(git *)', - }, - { - event: 'Stop', - type: 'agent', - prompt: 'Verify the repository', - }, - ], - })), - }, - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleHooksCommand(host, ''); - - expect(showNotice).toHaveBeenCalledWith( - 'Hooks (5)', - 'PreToolUse · ^Bash$ · check-command · 5s · once · async · status Checking command\n' + - 'Stop · all · verify-result · 30s · async rewake · powershell\n' + - 'Notification · all · https://hooks.example.test/notify · 30s · async\n' + - 'Stop · all · Check the result · 30s · if Bash(git *) · model fast-model\n' + - 'Stop · all · Verify the repository · 60s', - ); - expect(showNotice.mock.calls[0]?.[1]).not.toContain('SECRET'); - }); -}); - -describe('files slash command', () => { - it('lists read files relative to the working directory', async () => { - const host = { - state: { appState: { workDir: '/workspace' } }, - requireSession: () => ({ - listContextFiles: vi.fn(async () => [ - '/workspace/src/main.ts', - '/tmp/shared.ts', - ]), - }), - showNotice: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await showContextFiles(host, ''); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Files in context (2)', - `src/main.ts\n../tmp/shared.ts`, - ); - }); -}); - -describe('context slash command', () => { - it('renders the model-visible context report in the existing usage panel', async () => { - const addTranscriptChild = vi.fn(); - const requestRender = vi.fn(); - const host = { - state: { - transcriptContainer: { addTranscriptChild }, - ui: { requestRender }, - }, - requireSession: () => ({ - getContextUsage: vi.fn(async () => ({ - model: 'mock-model', - estimatedTokens: 2_500, - maxTokens: 10_000, - percentage: 25, - messageCount: 2, - categories: [ - { name: 'System prompt', tokens: 1_000, percentage: 10 }, - { name: 'Free space', tokens: 7_500, percentage: 75 }, - ], - tools: [], - })), - }), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await showContextReport(host, ''); - - expect(addTranscriptChild).toHaveBeenCalledOnce(); - const panel = addTranscriptChild.mock.calls[0]?.[0] as { render(width: number): string[] }; - expect(panel.render(100).join('\n')).toContain('Context'); - expect(panel.render(100).join('\n')).toContain('mock-model'); - expect(requestRender).toHaveBeenCalledOnce(); - }); -}); - -describe('doctor slash command', () => { - it('reuses the CLI validators and reports keybinding warnings', async () => { - const showNotice = vi.fn(); - const host = { - state: { appState: { workDir: '/workspace' } }, - harness: { - homeDir: '/missing-pythinker-doctor-home', - configPath: '/missing-pythinker-doctor-home/config.toml', - getConfigDiagnostics: vi.fn(async () => ({ - warnings: ['config.toml kept the previous valid configuration'], - })), - listAgentProfiles: vi.fn(async () => ({ - profiles: [ - { - name: 'verbose-agent', - source: 'project', - tools: [], - subagents: [], - whenToUse: 'x'.repeat(60_100), - }, - ], - warnings: [{ path: '/workspace/.pythinker-code/agents/bad.yaml', error: 'Invalid YAML' }], - })), - }, - session: { - getContextUsage: vi.fn(async () => ({ - estimatedTokens: 30_000, - maxTokens: 100_000, - percentage: 30, - messageCount: 1, - categories: [], - tools: [{ name: 'mcp__large__tool', source: 'mcp', tokens: 25_001 }], - })), - listPlugins: vi.fn(async () => [ - { - id: 'broken-plugin', - displayName: 'Broken plugin', - enabled: true, - state: 'error', - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: true, - source: 'local-path', - }, - ]), - getPluginInfo: vi.fn(async () => ({ - diagnostics: [{ severity: 'error', message: 'Manifest is invalid' }], - })), - }, - reloadKeybindings: vi.fn(() => ['Invalid keybinding override.']), - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleDoctorCommand(host, ''); - - expect(showNotice).toHaveBeenCalledOnce(); - expect(showNotice.mock.calls[0]?.[0]).toBe('Doctor found warnings'); - expect(showNotice.mock.calls[0]?.[1]).toContain('SKIP config.toml'); - expect(showNotice.mock.calls[0]?.[1]).toContain('SKIP tui.toml'); - expect(showNotice.mock.calls[0]?.[1]).toContain('Invalid keybinding override.'); - expect(showNotice.mock.calls[0]?.[1]).toContain( - 'config.toml kept the previous valid configuration', - ); - expect(showNotice.mock.calls[0]?.[1]).toContain( - '/workspace/.pythinker-code/agents/bad.yaml: Invalid YAML', - ); - expect(showNotice.mock.calls[0]?.[1]).toContain( - 'broken-plugin: Manifest is invalid', - ); - expect(showNotice.mock.calls[0]?.[1]).toContain('Large agent descriptions'); - expect(showNotice.mock.calls[0]?.[1]).toContain('Large MCP tools context'); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/memory.test.ts b/apps/pythinker-code/test/tui/commands/memory.test.ts deleted file mode 100644 index 1742e9b9..00000000 --- a/apps/pythinker-code/test/tui/commands/memory.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands'; -import { handleMemoryCommand } from '#/tui/commands/memory'; -import { openFileInExternalEditor } from '#/utils/process/external-editor'; - -vi.mock('#/utils/process/external-editor', () => ({ - openFileInExternalEditor: vi.fn(async () => true), - resolveEditorCommand: vi.fn(() => 'test-editor'), -})); - -const tempDirs: string[] = []; - -afterEach(async () => { - vi.clearAllMocks(); - for (const directory of tempDirs.splice(0)) { - await rm(directory, { recursive: true, force: true }); - } -}); - -describe('memory slash command', () => { - it('opens user instructions and refreshes the active model context', async () => { - const homeDir = await mkdtemp(join(tmpdir(), 'pythinker-memory-home-')); - const workDir = await mkdtemp(join(tmpdir(), 'pythinker-memory-work-')); - tempDirs.push(homeDir, workDir); - const refreshInstructions = vi.fn(async () => {}); - const showNotice = vi.fn(); - const host = { - harness: { homeDir }, - session: { refreshInstructions }, - state: { - appState: { editorCommand: null, workDir }, - editor: {}, - ui: { - stop: vi.fn(), - start: vi.fn(), - setFocus: vi.fn(), - requestRender: vi.fn(), - }, - }, - setExternalEditorRunning: vi.fn(), - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleMemoryCommand(host, 'user'); - - const path = join(homeDir, 'AGENTS.md'); - await expect(readFile(path, 'utf-8')).resolves.toBe(''); - expect(openFileInExternalEditor).toHaveBeenCalledWith(path, 'test-editor'); - expect(refreshInstructions).toHaveBeenCalledOnce(); - expect(showNotice).toHaveBeenCalledWith(`Opened ${path} in your editor.`, 'Instructions refreshed.'); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/model-roles.test.ts b/apps/pythinker-code/test/tui/commands/model-roles.test.ts deleted file mode 100644 index 5cffdacb..00000000 --- a/apps/pythinker-code/test/tui/commands/model-roles.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { handleModelCommand } from '#/tui/commands/index'; -import type { SlashCommandHost } from '#/tui/commands/dispatch'; - -const ENTER = '\r'; - -interface TestPicker { - handleInput(data: string): void; -} - -function model(name: string) { - return { - provider: 'test', - model: name, - maxContextSize: 200_000, - displayName: name, - capabilities: [], - }; -} - -function makeHost(options: { - currentModel?: string; - availableModels?: Record<string, ReturnType<typeof model>>; - modelRoles?: Record<string, string>; - setConfig?: ReturnType<typeof vi.fn>; -} = {}) { - const session = { - setModel: vi.fn(async () => {}), - setThinking: vi.fn(async () => {}), - }; - const getConfig = vi.fn(async () => ({ - providers: {}, - modelRoles: options.modelRoles, - })); - const setConfig = options.setConfig ?? vi.fn(async () => {}); - const host = { - state: { - appState: { - model: options.currentModel ?? 'worker', - thinkingLevel: 'off', - streamingPhase: 'idle', - availableModels: options.availableModels ?? { worker: model('worker') }, - }, - editorContainer: { children: [] }, - }, - session, - harness: { getConfig, setConfig }, - authFlow: { - refreshProviderModels: vi.fn(async () => ({ failed: [] })), - }, - setAppState: vi.fn((patch: Record<string, unknown>) => Object.assign(host.state.appState, patch)), - showError: vi.fn(), - showStatus: vi.fn(), - showNotice: vi.fn(), - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - track: vi.fn(), - } as unknown as SlashCommandHost; - return { host, session, setConfig }; -} - -function mountedPicker(host: SlashCommandHost, index = 0): TestPicker { - const mount = host.mountEditorReplacement as ReturnType<typeof vi.fn>; - return mount.mock.calls[index]?.[0] as TestPicker; -} - -describe('/model roles', () => { - it('lists every built-in role as not set when no assignments exist', async () => { - const { host } = makeHost(); - - await handleModelCommand(host, 'roles'); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Model roles', - 'small: (not set)\nimplementer: (not set)\nadvisor: (not set)', - ); - }); - - it('locks a selected alias to a role without switching the session model', async () => { - const { host, session, setConfig } = makeHost(); - - await handleModelCommand(host, 'small'); - expect(host.authFlow.refreshProviderModels).toHaveBeenCalledOnce(); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } }); - }); - expect(session.setModel).not.toHaveBeenCalled(); - }); - - it('keeps role assignment active after the picker refreshes', async () => { - const { host, session, setConfig } = makeHost({ - currentModel: 'parent', - availableModels: { - parent: model('parent'), - worker: model('worker'), - }, - modelRoles: { small: 'worker' }, - }); - vi.mocked(host.mountEditorReplacement).mockImplementation((picker) => { - host.state.editorContainer.children[0] = picker; - }); - vi.mocked(host.authFlow.refreshProviderModels).mockImplementation(async () => { - host.state.appState.availableModels['reviewer'] = model('reviewer'); - return { changed: [], unchanged: [], failed: [] }; - }); - - await handleModelCommand(host, 'small'); - await vi.waitFor(() => { - expect(host.mountEditorReplacement).toHaveBeenCalledTimes(2); - }); - mountedPicker(host, 1).handleInput(ENTER); - - await vi.waitFor(() => { - expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: 'worker' } }); - }); - expect(session.setModel).not.toHaveBeenCalled(); - }); - - it('reports a role persistence failure without showing success', async () => { - const setConfig = vi.fn(async () => { - throw new Error('disk full'); - }); - const { host } = makeHost({ setConfig }); - - await handleModelCommand(host, 'small'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('disk full')); - }); - expect(host.showStatus).not.toHaveBeenCalled(); - }); - - it('clears a role with an empty-string tombstone', async () => { - const { host, setConfig } = makeHost({ modelRoles: { small: 'worker' } }); - - await handleModelCommand(host, 'small clear'); - - expect(setConfig).toHaveBeenCalledWith({ modelRoles: { small: '' } }); - }); - - it('keeps an existing model alias on the default switch path', async () => { - const { host, session } = makeHost({ - currentModel: 'parent', - availableModels: { - parent: model('parent'), - worker: model('worker'), - }, - }); - - await handleModelCommand(host, 'worker'); - mountedPicker(host).handleInput(ENTER); - - await vi.waitFor(() => { - expect(session.setModel).toHaveBeenCalledWith('worker'); - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/commands/plugin-commands.test.ts b/apps/pythinker-code/test/tui/commands/plugin-commands.test.ts new file mode 100644 index 00000000..eae75a09 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/plugin-commands.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { buildPluginSlashCommands, pluginCommandName } from '#/tui/commands/plugin-commands'; + +describe('pluginCommandName', () => { + it('namespaces a command with its plugin id', () => { + expect(pluginCommandName('my-plugin', 'deploy')).toBe('my-plugin:deploy'); + }); +}); + +describe('buildPluginSlashCommands', () => { + it('namespaces commands and maps them to their bodies', () => { + const { commands, commandMap } = buildPluginSlashCommands([ + { + pluginId: 'my-plugin', + name: 'deploy', + description: 'Deploy', + body: 'Deploy $ARGUMENTS', + path: '/p/deploy.md', + }, + ]); + expect(commands).toEqual([{ name: 'my-plugin:deploy', aliases: [], description: 'Deploy' }]); + expect(commandMap.get('my-plugin:deploy')).toBe('Deploy $ARGUMENTS'); + }); + + it('returns empty commands for no defs', () => { + const { commands, commandMap } = buildPluginSlashCommands([]); + expect(commands).toEqual([]); + expect(commandMap.size).toBe(0); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/plugins-capability.test.ts b/apps/pythinker-code/test/tui/commands/plugins-capability.test.ts new file mode 100644 index 00000000..18707502 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/plugins-capability.test.ts @@ -0,0 +1,368 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '@pymodel/pythinker-code-sdk'; +import { resetCapabilitiesCache, setCapabilities, type Component } from '@pymodel/pi-tui'; + +import { __pluginsCommandInternals } from '#/tui/commands/plugins'; +import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; + +const { + isCapabilityEntry, + installCapabilityFromPanel, + isDefaultMarketplaceCatalog, + pollCapabilityInstall, + removePlugin, +} = __pluginsCommandInternals; + +function fakeHost(overrides: { + engineV2?: boolean; + capabilityStatus?: () => Promise<{ + state?: string; + steps?: readonly unknown[]; + install: { running: boolean; step?: string; percent?: number; error?: string }; + }>; +}) { + const statuses: string[] = []; + const notices: { title: string; detail?: string }[] = []; + const renders: number[] = []; + const transcriptEntries: Component[] = []; + const installCapability = vi.fn(() => Promise.resolve()); + const getCapability = + overrides.capabilityStatus ?? + (() => Promise.resolve({ state: 'ready', steps: [], install: { running: false } })); + const host = { + engineV2: overrides.engineV2 ?? false, + // Session-less (lazy session): plugin and capability calls fall back to + // the harness facade. + session: undefined, + harness: { + removePlugin: () => Promise.resolve(), + getCapability, + installCapability, + listCapabilities: () => Promise.resolve([]), + }, + requireSession: () => ({ + getCapability, + installCapability, + }), + showStatus: (text: string) => { + statuses.push(text); + }, + showError: (text: string) => { + statuses.push(text); + }, + showNotice: (title: string, detail?: string) => { + notices.push({ title, detail }); + transcriptEntries.push(new NoticeMessageComponent(title, detail)); + }, + restoreEditor: () => undefined, + state: { + ui: { requestRender: () => renders.push(1) }, + transcriptContainer: { + addChild: (entry: Component) => transcriptEntries.push(entry), + }, + }, + }; + return { host: host as never, statuses, notices, renders, transcriptEntries, installCapability }; +} + +function fakePanel() { + const lines: (string | undefined)[] = []; + return { + panel: { + setInstalling: (label: string) => { + lines.push(label); + }, + clearInstalling: () => { + lines.push(undefined); + }, + } as never, + lines, + }; +} + +function visibleLines(entries: readonly Component[], width = 100): string[] { + return entries + .flatMap((entry) => entry.render(width)) + .map((line) => + line + .replaceAll(/\u001B]8;;[^\u001B]*\u001B\\/g, '') + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .trimEnd(), + ); +} + +function unwrappedVisibleText(entries: readonly Component[]): string { + return visibleLines(entries) + .join('\n') + .replaceAll(/\s+/g, ''); +} + +describe('plugins command capability surface', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(log, 'info').mockImplementation(() => undefined); + vi.spyOn(log, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + resetCapabilitiesCache(); + }); + + it('routes built-in entries through capabilities only on v2', () => { + const v2 = fakeHost({ engineV2: true }); + expect( + isCapabilityEntry(v2.host, { id: 'pythinker-cu', source: 'capability:pythinker-cu', builtIn: true } as never), + ).toBe(true); + expect( + isCapabilityEntry(v2.host, { + id: 'pythinker-webbridge', + source: 'capability:pythinker-webbridge', + builtIn: true, + } as never), + ).toBe(true); + expect( + isCapabilityEntry(v2.host, { id: 'pythinker-cu', source: 'https://example.test/plugin.zip' } as never), + ).toBe(false); + // A forged capability: source without the parser-proof flag is a plain row. + expect( + isCapabilityEntry(v2.host, { id: 'pythinker-cu', source: 'capability:pythinker-cu' } as never), + ).toBe(false); + + const v1 = fakeHost({}); + expect( + isCapabilityEntry(v1.host, { id: 'pythinker-cu', source: 'capability:pythinker-cu', builtIn: true } as never), + ).toBe(false); + }); + + it('logs progress without replacing the generic installing label', async () => { + let calls = 0; + const { host } = fakeHost({ + engineV2: true, + capabilityStatus: () => { + calls += 1; + if (calls === 1) { + return Promise.resolve({ install: { running: true, step: 'download', percent: 40 } }); + } + return Promise.resolve({ install: { running: false } }); + }, + }); + const result = await pollCapabilityInstall(host, 'pythinker-cu'); + + expect(result?.install.running).toBe(false); + expect(log.info).toHaveBeenCalledWith('capability install progress', { + capabilityId: 'pythinker-cu', + step: 'download', + percent: 40, + }); + }); + + it('removePlugin notes that capability runtimes are left untouched', async () => { + const { host, statuses } = fakeHost({ engineV2: true }); + await removePlugin(host, 'pythinker-cu'); + expect(statuses.some((s) => s.includes('Removed pythinker-cu'))).toBe(true); + expect(statuses.some((s) => s.includes('runtime binaries were left untouched'))).toBe(true); + expect(statuses.some((s) => s.includes('plugin wiring is disabled for new sessions'))).toBe(true); + expect(statuses.some((s) => s.includes('Restart Pythinker Code before reinstalling'))).toBe(true); + expect(statuses.some((s) => s.includes('Run /new or /reload'))).toBe(false); + }); + + it('keeps the runtime note for the Windows backing plugin id', async () => { + const { host, statuses } = fakeHost({ engineV2: true }); + await removePlugin(host, 'pythinker-cu-win'); + expect(statuses.some((s) => s.includes('Removed pythinker-cu-win'))).toBe(true); + expect(statuses.some((s) => s.includes('runtime binaries were left untouched'))).toBe(true); + }); + + it('treats only the default catalog (and the dev server) as injectable', () => { + expect(isDefaultMarketplaceCatalog(undefined, {})).toBe(true); + // The dev marketplace server started by scripts/dev.mjs serves this + // repo's own catalog — it counts as default, not as a user override. + expect( + isDefaultMarketplaceCatalog(undefined, { + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL: 'http://127.0.0.1:60056/marketplace.json', + PYTHINKER_CODE_PLUGIN_MARKETPLACE_FROM_DEV_SERVER: '1', + }), + ).toBe(true); + expect( + isDefaultMarketplaceCatalog(undefined, { + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL: 'https://example.test/marketplace.json', + }), + ).toBe(false); + expect(isDefaultMarketplaceCatalog('https://example.test/marketplace.json', {})).toBe(false); + }); + + it('removePlugin stays quiet for non-capability plugins', async () => { + const { host, statuses } = fakeHost({ engineV2: true }); + await removePlugin(host, 'superpowers'); + expect(statuses.some((s) => s.includes('runtime binaries'))).toBe(false); + }); + + it('starts a capability install only when none is running', async () => { + const idle = fakeHost({ engineV2: true }); + await installCapabilityFromPanel( + idle.host, + fakePanel().panel, + { id: 'pythinker-cu', displayName: 'Pythinker Computer Use', source: 'capability:pythinker-cu' } as never, + ); + expect(idle.installCapability).toHaveBeenCalledWith('pythinker-cu'); + }); + + it('follows an in-progress capability install instead of restarting it', async () => { + let calls = 0; + const { host, installCapability, statuses } = fakeHost({ + engineV2: true, + capabilityStatus: () => { + calls += 1; + // The pre-check sees the running install; the poll then sees it settle. + return Promise.resolve( + calls === 1 + ? { state: 'partial', steps: [], install: { running: true, step: 'download', percent: 40 } } + : { state: 'ready', steps: [], install: { running: false } }, + ); + }, + }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { id: 'pythinker-cu', displayName: 'Pythinker Computer Use', source: 'capability:pythinker-cu' } as never, + ); + + // The service rejects duplicate starts (40922) — a healthy in-progress + // install must be followed via polling, never reported as a failure. + expect(installCapability).not.toHaveBeenCalled(); + expect(statuses.some((s) => s.includes('Failed to install'))).toBe(false); + expect(statuses.some((s) => s.includes('is installed'))).toBe(true); + }); + + it('renders visible clickable store URLs after WebBridge installs in a hyperlink-capable terminal', async () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const { host, statuses, notices, transcriptEntries } = fakeHost({ engineV2: true }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + source: 'capability:pythinker-webbridge', + } as never, + ); + + expect(notices).toContainEqual({ title: 'Pythinker WebBridge is installed.', detail: undefined }); + expect(statuses).not.toContain('Run /new or /reload to apply plugin changes.'); + const rendered = transcriptEntries.flatMap((entry) => entry.render(100)).join('\n'); + expect(rendered).toContain( + '\u001B]8;;https://chromewebstore.google.com/detail/pythinker-webbridge/fldmhceldgbpfpkbgopacenieobmligc\u001B\\', + ); + expect(rendered).toContain('Chrome Web Store'); + expect(rendered).toContain('Edge Add-ons'); + expect(rendered).toContain('Manual installation guide'); + expect(rendered).toContain('/reload'); + expect(rendered).toContain('/new'); + }); + + it('renders full store URLs after WebBridge installs in a terminal without hyperlinks', async () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: false }); + const { host, transcriptEntries } = fakeHost({ engineV2: true }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + source: 'capability:pythinker-webbridge', + } as never, + ); + + const rendered = transcriptEntries.flatMap((entry) => entry.render(100)).join('\n'); + expect(rendered).not.toContain('\u001B]8;;'); + expect(unwrappedVisibleText(transcriptEntries)).toContain( + 'https://chromewebstore.google.com/detail/pythinker-webbridge/fldmhceldgbpfpkbgopacenieobmligc', + ); + }); + + it('separates the WebBridge install result from its setup steps with one blank line', async () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const { host, transcriptEntries } = fakeHost({ engineV2: true }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + source: 'capability:pythinker-webbridge', + } as never, + ); + + const lines = visibleLines(transcriptEntries, 180); + const installed = lines.findIndex((line) => line.includes('Pythinker WebBridge is installed.')); + const intro = lines.findIndex((line) => + line.includes('Two steps left to use Pythinker WebBridge:'), + ); + const firstStep = lines.findIndex((line) => + line.includes('Install the browser extension'), + ); + const secondStep = lines.findIndex((line) => line.includes('Run /reload or /new to apply it.')); + expect(lines.slice(installed + 1, intro)).toEqual(['']); + expect(firstStep).toBe(intro + 1); + expect(lines[firstStep]).toContain('1.'); + expect(lines.find((line) => line.includes('Chrome Web Store'))).toContain('•'); + expect(lines.find((line) => line.includes('Edge Add-ons'))).toContain('•'); + expect(lines.find((line) => line.includes('Manual installation guide'))).toContain('•'); + expect(lines[secondStep]).toContain('2.'); + }); + + it('shows the engine error when a background capability install fails', async () => { + const { host, statuses } = fakeHost({ + engineV2: true, + capabilityStatus: () => + Promise.resolve({ + state: 'not_installed', + steps: [], + install: { running: false, error: 'Authenticode signature is not valid' }, + }), + }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { id: 'pythinker-cu', displayName: 'Pythinker Computer Use', source: 'capability:pythinker-cu' } as never, + ); + + expect(statuses).toContain( + 'Pythinker Computer Use installation failed: Authenticode signature is not valid', + ); + expect(statuses).toContain('Fix the reported error, then install again from /plugins.'); + }); + + it('shows required permissions once after installation instead of exposing step details', async () => { + const { host, statuses } = fakeHost({ + engineV2: true, + capabilityStatus: () => Promise.resolve({ + id: 'pythinker-cu', + state: 'partial', + steps: [{ id: 'permissions', state: 'missing', detail: 'screenRecording' }], + install: { running: false }, + }), + }); + + await installCapabilityFromPanel( + host, + fakePanel().panel, + { id: 'pythinker-cu', displayName: 'Pythinker Computer Use', source: 'capability:pythinker-cu' } as never, + ); + + expect(statuses.some((s) => s.includes('Grant Accessibility and Screen Recording'))).toBe(true); + expect(statuses.some((s) => s.includes('screenRecording'))).toBe(false); + expect(log.warn).toHaveBeenCalledWith( + 'capability needs attention', + expect.objectContaining({ + capabilityId: 'pythinker-cu', + steps: [expect.objectContaining({ detail: 'screenRecording' })], + }), + ); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/registry.test.ts b/apps/pythinker-code/test/tui/commands/registry.test.ts index 0dea3516..fa2bb88a 100644 --- a/apps/pythinker-code/test/tui/commands/registry.test.ts +++ b/apps/pythinker-code/test/tui/commands/registry.test.ts @@ -1,11 +1,9 @@ import { BUILTIN_SLASH_COMMANDS, - colorsArgumentCompletions, - fastArgumentCompletions, findBuiltInSlashCommand, parseSlashInput, - pluginsArgumentCompletions, resolveSlashCommandAvailability, + addDirArgumentCompletions, sortSlashCommands, dynamicWorkflowArgumentCompletions, type PythinkerSlashCommand, @@ -15,9 +13,9 @@ import { describe, expect, it } from 'vitest'; describe('parseSlashInput', () => { it('parses command names and trimmed args', () => { expect(parseSlashInput('/help')).toEqual({ name: 'help', args: '' }); - expect(parseSlashInput('/model pythinker-k2 ')).toEqual({ + expect(parseSlashInput('/model kimi-k2 ')).toEqual({ name: 'model', - args: 'pythinker-k2', + args: 'kimi-k2', }); }); @@ -36,80 +34,14 @@ describe('built-in slash command registry', () => { expect(findBuiltInSlashCommand('quit')?.name).toBe('exit'); expect(findBuiltInSlashCommand('q')?.name).toBe('exit'); expect(findBuiltInSlashCommand('clear')?.name).toBe('new'); - expect(findBuiltInSlashCommand('reset')?.name).toBe('new'); - expect(findBuiltInSlashCommand('continue')?.name).toBe('sessions'); - expect(findBuiltInSlashCommand('bashes')?.name).toBe('tasks'); + expect(findBuiltInSlashCommand('bug')?.name).toBe('feedback'); expect(findBuiltInSlashCommand('btw')?.name).toBe('btw'); expect(findBuiltInSlashCommand('mcp')?.name).toBe('mcp'); - expect(findBuiltInSlashCommand('connect')?.name).toBe('login'); - expect(findBuiltInSlashCommand('allowed-tools')?.name).toBe('permissions'); - expect(findBuiltInSlashCommand('plugin')?.name).toBe('plugins'); - expect(findBuiltInSlashCommand('update')?.name).toBe('update'); - expect(findBuiltInSlashCommand('upgrade')?.name).toBe('update'); - expect(findBuiltInSlashCommand('reload-plugins')?.name).toBe('reload-plugins'); - expect(findBuiltInSlashCommand('release-notes')?.name).toBe('release-notes'); - expect(findBuiltInSlashCommand('review')?.name).toBe('review'); - expect(findBuiltInSlashCommand('security-review')?.name).toBe('security-review'); - expect(findBuiltInSlashCommand('pr-comments')?.name).toBe('pr-comments'); - expect(findBuiltInSlashCommand('commit')?.name).toBe('commit'); - expect(findBuiltInSlashCommand('commit-push-pr')?.name).toBe('commit-push-pr'); - expect(findBuiltInSlashCommand('privacy-settings')?.name).toBe('privacy-settings'); - expect(findBuiltInSlashCommand('terminal-setup')?.name).toBe('terminal-setup'); - expect(findBuiltInSlashCommand('init-verifiers')?.name).toBe('init-verifiers'); - expect((findBuiltInSlashCommand('heapdump') as PythinkerSlashCommand | undefined)?.hidden).toBe( - true, - ); - expect(findBuiltInSlashCommand('bug')?.name).toBe('feedback'); expect(findBuiltInSlashCommand('status')?.name).toBe('status'); - expect(findBuiltInSlashCommand('cost')?.name).toBe('cost'); expect(findBuiltInSlashCommand('usage')?.aliases).not.toContain('status'); - const colors = findBuiltInSlashCommand('colors'); - expect(colors?.name).toBe('colors'); - expect(colors?.aliases).toEqual([]); - expect(resolveSlashCommandAvailability(colors!, '')).toBe('always'); - expect(findBuiltInSlashCommand('dance')).toBeUndefined(); expect(findBuiltInSlashCommand('unknown')).toBeUndefined(); }); - it('offers colors mode argument completions', () => { - const values = (prefix: string): string[] | null => { - const items = colorsArgumentCompletions(prefix); - return items === null ? null : items.map((item) => item.value); - }; - - expect(values('')).toEqual(['on', 'off']); - expect(values('O')).toEqual(['on', 'off']); - expect(colorsArgumentCompletions('of')).toEqual([ - { value: 'off', label: 'off', description: 'Turn rainbow colors off' }, - ]); - expect(values('on')).toBeNull(); - expect(values('off')).toBeNull(); - expect(values('unknown')).toBeNull(); - }); - - it('offers Fast mode completions and keeps status available while busy', () => { - const fast = findBuiltInSlashCommand('fast'); - expect(fast).toBeDefined(); - expect(resolveSlashCommandAvailability(fast!, '')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(fast!, 'on')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(fast!, 'off')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(fast!, 'status')).toBe('always'); - expect(fastArgumentCompletions('')).toEqual([ - { value: 'on', label: 'on', description: 'Turn Fast mode on' }, - { value: 'off', label: 'off', description: 'Turn Fast mode off' }, - { value: 'status', label: 'status', description: 'Show Fast mode status' }, - ]); - }); - it('keeps advisor status and the omitted verb available while busy', () => { - const advisor = findBuiltInSlashCommand('advisor'); - expect(advisor).toBeDefined(); - expect(resolveSlashCommandAvailability(advisor!, '')).toBe('always'); - expect(resolveSlashCommandAvailability(advisor!, 'status')).toBe('always'); - expect(resolveSlashCommandAvailability(advisor!, 'on')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(advisor!, 'off')).toBe('idle-only'); - }); - - it('marks plan clear as idle-only while normal plan toggles are always available', () => { const plan = findBuiltInSlashCommand('plan'); expect(plan).toBeDefined(); @@ -118,54 +50,50 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(plan!, 'clear')).toBe('idle-only'); }); - it('keeps Dynamic Workflow mode changes and tasks idle-only', () => { - const workflow = findBuiltInSlashCommand('workflow'); - expect(workflow).toBeDefined(); - expect((workflow as PythinkerSlashCommand).experimentalFlag).toBeUndefined(); - expect(resolveSlashCommandAvailability(workflow!, 'on')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(workflow!, 'off')).toBe('idle-only'); - expect(resolveSlashCommandAvailability(workflow!, 'Ship feature X')).toBe('idle-only'); + it('keeps dynamic_workflow mode changes and dynamic_workflow tasks idle-only', () => { + const dynamic_workflow = findBuiltInSlashCommand('dynamic_workflow'); + expect(dynamic_workflow).toBeDefined(); + expect((dynamic_workflow as PythinkerSlashCommand).experimentalFlag).toBeUndefined(); + expect(resolveSlashCommandAvailability(dynamic_workflow!, 'on')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(dynamic_workflow!, 'off')).toBe('idle-only'); + expect(resolveSlashCommandAvailability(dynamic_workflow!, 'Ship feature X')).toBe('idle-only'); }); - it('offers Dynamic Workflow subcommand argument completions', () => { + it('offers dynamic_workflow subcommand argument completions', () => { const values = (prefix: string): string[] | null => { const items = dynamicWorkflowArgumentCompletions(prefix); return items === null ? null : items.map((item) => item.value); }; - expect(values('')).toEqual(['on', 'off', 'model', 'save']); + expect(values('')).toEqual(['on', 'off']); expect(values('O')).toEqual(['on', 'off']); - expect(values('mod')).toEqual(['model']); - expect(values('sa')).toEqual(['save']); expect(dynamicWorkflowArgumentCompletions('of')).toEqual([ - { value: 'off', label: 'off', description: 'Turn Dynamic Workflow mode off' }, + { value: 'off', label: 'off', description: 'Turn dynamic_workflow mode off' }, ]); expect(values('on')).toBeNull(); expect(values('off')).toBeNull(); expect(values('Ship feature X')).toBeNull(); }); - it('offers plugin subcommand argument completions', () => { + it('offers add-dir list and directory argument completions', () => { const values = (prefix: string): string[] | null => { - const items = pluginsArgumentCompletions(prefix); + const items = addDirArgumentCompletions(prefix); return items === null ? null : items.map((item) => item.value); }; - expect(values('')).toEqual([ - 'list', - 'install', - 'marketplace', - 'info', - 'enable', - 'disable', - 'remove', - 'reload', - 'mcp', - ]); - expect(values('ma')).toEqual(['marketplace']); - expect(values('mcp e')).toEqual(['mcp enable']); - expect(values('mcp d')).toEqual(['mcp disable']); - expect(values('reload')).toBeNull(); + expect(values('')).toEqual(['list']); + expect(values('L')).toEqual(['list']); + expect(values('list')).toBeNull(); + const directoryCompletions = values('/') ?? []; + expect(directoryCompletions.length).toBeGreaterThan(0); + expect(directoryCompletions.every((value) => value.startsWith('/') && value.endsWith('/'))).toBe(true); + expect(directoryCompletions.some((value) => value.startsWith('/.'))).toBe(false); + expect(values('/.')).toBeNull(); + const homeCompletions = values('~/') ?? []; + expect(homeCompletions.length).toBeGreaterThan(0); + expect(homeCompletions.every((value) => value.startsWith('~/') && value.endsWith('/'))).toBe(true); + expect(homeCompletions.some((value) => value.startsWith('~/.'))).toBe(false); + expect(homeCompletions.some((value) => value.startsWith('~/sers/'))).toBe(false); }); it('defaults commands without explicit availability to idle-only', () => { @@ -221,56 +149,33 @@ describe('built-in slash command registry', () => { expect(new Set(names).size).toBe(names.length); expect(names).toEqual( expect.arrayContaining([ - 'agents', - 'colors', + 'add-dir', 'compact', - 'commit', - 'commit-push-pr', - 'copy', - 'cost', 'btw', - 'debug', - 'doctor', 'editor', 'exit', 'export-debug-zip', - 'fast', 'fork', 'help', - 'hooks', - 'heapdump', 'init', - 'init-verifiers', - 'keybindings', 'login', 'logout', 'mcp', 'model', 'new', - 'output-style', 'permission', - 'permissions', 'plan', - 'pr-comments', - 'privacy-settings', 'reload', - 'reload-plugins', 'reload-tui', - 'release-notes', - 'review', - 'security-review', + 'secondary-model', 'sessions', 'settings', - 'skills', 'status', - 'tag', 'theme', - 'terminal-setup', 'title', 'undo', 'usage', 'version', - 'vim', 'yolo', ]), ); @@ -285,4 +190,11 @@ describe('built-in slash command registry', () => { expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only'); expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always'); }); + + it('gates secondary-model behind the secondary-model experiment, always available', () => { + const command = findBuiltInSlashCommand('secondary-model'); + expect(command).toBeDefined(); + expect((command as PythinkerSlashCommand).experimentalFlag).toBe('secondary-model'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/reload.test.ts b/apps/pythinker-code/test/tui/commands/reload.test.ts index 1fa853b9..d2f40c69 100644 --- a/apps/pythinker-code/test/tui/commands/reload.test.ts +++ b/apps/pythinker-code/test/tui/commands/reload.test.ts @@ -8,13 +8,16 @@ import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { isExperimentalFlagEnabled, setExperimentalFeatures, } from '#/tui/commands/experimental-flags'; +import { + createMarkdownOptions, + setMarkdownRenderLatex, +} from '#/tui/utils/markdown-options'; const tempDirs: string[] = []; const originalPythinkerCodeHome = process.env['PYTHINKER_CODE_HOME']; @@ -35,6 +38,7 @@ describe('reload slash commands', () => { it('reloads tui.toml without touching Core session state', async () => { await writeTuiConfig(` theme = "light" +cache_expiry_hint = false [editor] command = "vim" @@ -45,12 +49,6 @@ notification_condition = "always" [upgrade] auto_install = false - -[status_line] -show_model = false -show_git = false -show_modes = false -show_background_tasks = false `); const session = { reloadSession: vi.fn() }; const host = makeHost({ session }); @@ -60,19 +58,12 @@ show_background_tasks = false expect(host.harness.getConfig).not.toHaveBeenCalled(); expect(host.harness.getExperimentalFeatures).not.toHaveBeenCalled(); expect(session.reloadSession).not.toHaveBeenCalled(); - expect(host.reloadKeybindings).toHaveBeenCalledOnce(); expect(host.state.appState).toMatchObject({ theme: 'light', editorCommand: 'vim', + cacheExpiryHint: false, notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, - statusLine: { - ...DEFAULT_STATUS_LINE_CONFIG, - showModel: false, - showGit: false, - showModes: false, - showBackgroundTasks: false, - }, }); expect(host.showStatus).toHaveBeenCalledWith( 'TUI config reloaded.', @@ -81,32 +72,24 @@ show_background_tasks = false }); it('reloads the active session, refreshes runtime config, and applies tui.toml', async () => { - await writeTuiConfig(` -theme = "light" - -[status_line] -show_elapsed = false -`); + await writeTuiConfig('theme = "light"\n'); const session = { id: 'ses-1', reloadSession: vi.fn(async () => ({})) }; const host = makeHost({ session }); await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(session.reloadSession).toHaveBeenCalledWith({ + forcePluginSessionStartReminder: true, + }); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( session, 'Session reloaded.', ); expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); - expect(host.refreshSkillCommands).toHaveBeenCalledOnce(); - expect(host.reloadKeybindings).toHaveBeenCalledOnce(); + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(true); expect(host.state.appState.theme).toBe('light'); - expect(host.state.appState.statusLine).toEqual({ - ...DEFAULT_STATUS_LINE_CONFIG, - showElapsed: false, - }); expect(host.state.appState.availableModels).toEqual({ fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, }); @@ -136,6 +119,55 @@ show_elapsed = false expect(themeWhenTracked).toBe('auto'); }); + + it('applies the render_latex toggle before theme application rebuilds Markdown', async () => { + await writeTuiConfig('render_latex = false\n'); + const host = makeHost(); + + // applyTheme invalidates transcript components, which rebuild their + // Markdown children by copying the shared options — the reloaded value + // must already be live at that point. + let latexWhenThemeApplied: boolean | undefined; + const mutable = host as unknown as { applyTheme: unknown }; + mutable.applyTheme = vi.fn(() => { + latexWhenThemeApplied = createMarkdownOptions().renderLatex; + }); + + try { + await handleReloadTuiCommand(host); + expect(latexWhenThemeApplied).toBe(false); + } finally { + setMarkdownRenderLatex(true); + } + }); + + it('refreshes workspace commands and lazy defaults on a session-less v2 reload', async () => { + await writeTuiConfig('theme = "dark"\n'); + const host = makeHost(); + const refreshSkillCommands = vi.fn(async () => {}); + const refreshPluginCommands = vi.fn(async () => {}); + const hydrateLazyConfigDefaults = vi.fn(async () => {}); + Object.assign(host, { + engineV2: true, + refreshSkillCommands, + refreshPluginCommands, + hydrateLazyConfigDefaults, + }); + + await handleReloadCommand(host); + + expect(refreshSkillCommands).toHaveBeenCalledOnce(); + expect(refreshPluginCommands).toHaveBeenCalledOnce(); + expect(hydrateLazyConfigDefaults).toHaveBeenCalledOnce(); + // Autocomplete must rebuild after the command maps are refreshed. + expect(refreshSkillCommands.mock.invocationCallOrder[0]).toBeLessThan( + host.refreshSlashCommandAutocomplete.mock.invocationCallOrder[0]!, + ); + expect(host.showStatus).toHaveBeenCalledWith( + 'Runtime and TUI config reloaded; no active session.', + 'success', + ); + }); }); async function writeTuiConfig(text: string): Promise<void> { @@ -157,10 +189,12 @@ function makeHost({ editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, availableProviders: {}, }, + editor: { + setDisablePasteBurst: vi.fn(), + }, theme: { palette: { success: '#00ff00', @@ -188,8 +222,7 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), - refreshSkillCommands: vi.fn(async () => {}), - reloadKeybindings: vi.fn(() => []), + refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), } as unknown as SlashCommandHost & { @@ -197,8 +230,7 @@ function makeHost({ readonly getConfig: ReturnType<typeof vi.fn>; readonly getExperimentalFeatures: ReturnType<typeof vi.fn>; }; - readonly refreshSkillCommands: ReturnType<typeof vi.fn>; - readonly reloadKeybindings: ReturnType<typeof vi.fn>; + readonly refreshSlashCommandAutocomplete: ReturnType<typeof vi.fn>; readonly reloadCurrentSessionView: ReturnType<typeof vi.fn>; readonly showStatus: ReturnType<typeof vi.fn>; }; diff --git a/apps/pythinker-code/test/tui/commands/resolve.test.ts b/apps/pythinker-code/test/tui/commands/resolve.test.ts index 63d6f546..45d00a77 100644 --- a/apps/pythinker-code/test/tui/commands/resolve.test.ts +++ b/apps/pythinker-code/test/tui/commands/resolve.test.ts @@ -14,6 +14,7 @@ function resolve( return resolveSlashCommandInput({ input, skillCommandMap: new Map<string, string>(), + pluginCommandMap: new Map<string, string>(), isStreaming: false, isCompacting: false, ...overrides, @@ -34,17 +35,15 @@ describe('resolveSlashCommandInput', () => { expect(resolve('/q')).toMatchObject({ kind: 'builtin', name: 'exit', args: '' }); expect(resolve('/clear')).toMatchObject({ kind: 'builtin', name: 'new', args: '' }); expect(resolve('/fork')).toMatchObject({ kind: 'builtin', name: 'fork', args: '' }); - expect(resolve('/branch')).toMatchObject({ kind: 'builtin', name: 'fork', args: '' }); - expect(resolve('/rewind')).toMatchObject({ kind: 'builtin', name: 'undo', args: '' }); expect(resolve('/title New title')).toMatchObject({ kind: 'builtin', name: 'title', args: 'New title', }); - expect(resolve('/rename New title')).toMatchObject({ + expect(resolve('/add-dir list')).toMatchObject({ kind: 'builtin', - name: 'title', - args: 'New title', + name: 'add-dir', + args: 'list', }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); expect(resolve('/btw')).toMatchObject({ @@ -62,25 +61,6 @@ describe('resolveSlashCommandInput', () => { name: 'experiments', args: '', }); - expect(resolve('/workflow')).toMatchObject({ kind: 'builtin', name: 'workflow' }); - expect(resolve('/swarm')).toEqual({ kind: 'message', input: '/swarm' }); - }); - - it('routes /colors as an always-available built-in and leaves /dance as a message', () => { - expect(resolve('/colors on', { isStreaming: true })).toMatchObject({ - kind: 'builtin', - name: 'colors', - args: 'on', - }); - expect(resolve('/colors off', { isCompacting: true })).toMatchObject({ - kind: 'builtin', - name: 'colors', - args: 'off', - }); - expect(resolve('/dance')).toEqual({ - kind: 'message', - input: '/dance', - }); }); it('blocks idle-only built-ins while streaming', () => { @@ -114,19 +94,24 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'streaming', }); + expect(resolve('/add-dir ../shared', { isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'streaming', + }); expect(resolve('/experiments', { isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'experiments', reason: 'streaming', }); - expect(resolve('/workflow on', { isStreaming: true })).toEqual({ + expect(resolve('/dynamic_workflow on', { isStreaming: true })).toEqual({ kind: 'blocked', - commandName: 'workflow', + commandName: 'dynamic_workflow', reason: 'streaming', }); - expect(resolve('/workflow off', { isStreaming: true })).toEqual({ + expect(resolve('/dynamic_workflow off', { isStreaming: true })).toEqual({ kind: 'blocked', - commandName: 'workflow', + commandName: 'dynamic_workflow', reason: 'streaming', }); }); @@ -147,19 +132,24 @@ describe('resolveSlashCommandInput', () => { commandName: 'reload', reason: 'compacting', }); + expect(resolve('/add-dir ../shared', { isCompacting: true })).toEqual({ + kind: 'blocked', + commandName: 'add-dir', + reason: 'compacting', + }); expect(resolve('/experiments', { isCompacting: true })).toEqual({ kind: 'blocked', commandName: 'experiments', reason: 'compacting', }); - expect(resolve('/workflow on', { isCompacting: true })).toEqual({ + expect(resolve('/dynamic_workflow on', { isCompacting: true })).toEqual({ kind: 'blocked', - commandName: 'workflow', + commandName: 'dynamic_workflow', reason: 'compacting', }); - expect(resolve('/workflow off', { isCompacting: true })).toEqual({ + expect(resolve('/dynamic_workflow off', { isCompacting: true })).toEqual({ kind: 'blocked', - commandName: 'workflow', + commandName: 'dynamic_workflow', reason: 'compacting', }); }); @@ -205,7 +195,7 @@ describe('resolveSlashCommandInput', () => { }); }); - it('resolves skill commands and blocks them while busy', () => { + it('resolves skill commands and keeps them resolvable while busy (queued downstream)', () => { const skillCommandMap = new Map([['skill:review', 'review']]); expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({ @@ -215,13 +205,14 @@ describe('resolveSlashCommandInput', () => { args: 'src/app.ts', }); expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({ - kind: 'blocked', + kind: 'skill', commandName: 'skill:review', - reason: 'streaming', + skillName: 'review', + args: 'src/app.ts', }); }); - it('resolves unprefixed built-in skill commands and blocks them while busy', () => { + it('resolves unprefixed built-in skill commands and keeps them resolvable while busy', () => { const skillCommandMap = new Map([['mcp-config', 'mcp-config']]); expect(resolve('/mcp-config', { skillCommandMap })).toEqual({ @@ -231,9 +222,10 @@ describe('resolveSlashCommandInput', () => { args: '', }); expect(resolve('/mcp-config', { skillCommandMap, isCompacting: true })).toEqual({ - kind: 'blocked', + kind: 'skill', commandName: 'mcp-config', - reason: 'compacting', + skillName: 'mcp-config', + args: '', }); }); @@ -255,10 +247,11 @@ describe('resolveSlashCommandInput', () => { }); }); - it('routes /swarm as ordinary message text', () => { - expect(resolve('/swarm Ship feature X')).toEqual({ - kind: 'message', - input: '/swarm Ship feature X', + it('resolves /dynamic_workflow without an experimental flag', () => { + expect(resolve('/dynamic_workflow Ship feature X')).toMatchObject({ + kind: 'builtin', + name: 'dynamic_workflow', + args: 'Ship feature X', }); }); @@ -317,4 +310,33 @@ describe('slash command busy helpers', () => { expect(slashBusyMessage('new', 'streaming')).toContain('Cannot /new while streaming'); expect(slashBusyMessage('new', 'compacting')).toContain('Cannot /new while compacting'); }); + + it('resolves a namespaced plugin command to a plugin-command intent', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy $ARGUMENTS']]); + expect(resolve('/my-plugin:deploy prod', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'deploy', + pluginId: 'my-plugin', + args: 'prod', + }); + }); + + it('resolves a nested plugin command whose name contains a slash', () => { + const pluginCommandMap = new Map([['my-plugin:frontend/component', 'body']]); + expect(resolve('/my-plugin:frontend/component spin', { pluginCommandMap })).toEqual({ + kind: 'plugin-command', + commandName: 'frontend/component', + pluginId: 'my-plugin', + args: 'spin', + }); + }); + + it('blocks a plugin command while streaming', () => { + const pluginCommandMap = new Map([['my-plugin:deploy', 'Deploy']]); + expect(resolve('/my-plugin:deploy', { pluginCommandMap, isStreaming: true })).toEqual({ + kind: 'blocked', + commandName: 'my-plugin:deploy', + reason: 'streaming', + }); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/secondary-model.test.ts b/apps/pythinker-code/test/tui/commands/secondary-model.test.ts new file mode 100644 index 00000000..b68441d2 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/secondary-model.test.ts @@ -0,0 +1,234 @@ +/** + * Scenario: /secondary-model command behavior in the interactive TUI. + * Responsibilities: picker filtering, persistence of `[secondary_model] default_model` + * (keeping existing pool descriptions), and error paths. + * Wiring: real command and selector with the SDK/session boundaries stubbed by a small host rig. + * Run: pnpm -C apps/pythinker-code exec vitest run test/tui/commands/secondary-model.test.ts + */ +import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands'; +import { handleSecondaryModelCommand } from '#/tui/commands/config'; +import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; + +interface PickerOptions { + readonly models: Record<string, ModelAlias>; + readonly currentValue: string; + readonly selectedValue?: string; + readonly title?: string; + readonly thinkingControl?: boolean; + readonly onSelect: (selection: { alias: string }) => void; +} + +function model(name: string): ModelAlias { + return { + provider: 'test', + model: name, + maxContextSize: 200_000, + displayName: name, + } as unknown as ModelAlias; +} + +function makeHost(options?: { + readonly secondaryModel?: { defaultModel?: string; models?: Record<string, string> }; +}) { + const appState = { + availableModels: { + k2: model('k2'), + cheap: model('cheap'), + // The v1 derived entry must never be selectable. + '__secondary__': model('cheap'), + // The pool's reserved symbolic choice must never be selectable either. + 'primary': model('primary'), + } as Record<string, ModelAlias>, + availableProviders: {}, + transcriptEntries: [], + }; + const host = { + state: { + appState, + transcriptEntries: [], + }, + authFlow: { + refreshOAuthProviderModels: vi.fn(async () => undefined), + }, + harness: { + getConfig: vi.fn(async () => ({ + providers: {}, + secondaryModel: options?.secondaryModel, + })), + setConfig: vi.fn(async () => ({})), + }, + setAppState: vi.fn((patch) => Object.assign(appState, patch)), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + showStatus: vi.fn(), + showError: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + } as unknown as SlashCommandHost & { + harness: { + getConfig: ReturnType<typeof vi.fn>; + setConfig: ReturnType<typeof vi.fn>; + }; + mountEditorReplacement: ReturnType<typeof vi.fn>; + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + showNotice: ReturnType<typeof vi.fn>; + }; + return { host }; +} + +function mountedPicker(host: { mountEditorReplacement: ReturnType<typeof vi.fn> }): PickerOptions { + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + const component = host.mountEditorReplacement.mock.calls[0]![0]; + expect(component).toBeInstanceOf(TabbedModelSelectorComponent); + return (component as unknown as { opts: PickerOptions }).opts; +} + +describe('handleSecondaryModelCommand', () => { + it('opens the picker filtered to user models, with the configured default as current', async () => { + const { host } = makeHost({ secondaryModel: { defaultModel: 'cheap' } }); + + await handleSecondaryModelCommand(host, ''); + + const opts = mountedPicker(host); + expect(Object.keys(opts.models)).toEqual(['k2', 'cheap']); + expect(opts.currentValue).toBe('cheap'); + expect(opts.title).toContain('secondary model'); + // Pool bindings carry no explicit thinking level — the picker hides the + // Thinking footer instead of offering a no-op choice. + expect(opts.thinkingControl).toBe(false); + }); + + it('persists only default_model when no pool exists (implicit single-entry pool)', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { defaultModel: 'k2' }, + }); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('adds the picked alias to an existing pool with an empty description', async () => { + const { host } = makeHost({ + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap' }, + }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', k2: '' }, + }, + }); + }); + + it('keeps existing pool descriptions and other pool entries on save', async () => { + const { host } = makeHost({ + secondaryModel: { + defaultModel: 'cheap', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, + }, + }); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showStatus).toHaveBeenCalled(); + }); + expect(host.harness.setConfig).toHaveBeenCalledWith({ + secondaryModel: { + defaultModel: 'k2', + models: { cheap: 'fast and cheap', k2: 'hard tasks' }, + }, + }); + }); + + it('pre-selects a valid alias argument instead of erroring', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'cheap'); + + const opts = mountedPicker(host); + expect(opts.selectedValue).toBe('cheap'); + }); + + it('rejects an unknown alias argument without opening the picker', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'nope'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: nope'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('rejects the synthesized derived alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, '__secondary__'); + + expect(host.showError).toHaveBeenCalledWith('Unknown model alias: __secondary__'); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('rejects the reserved primary alias as an argument', async () => { + const { host } = makeHost(); + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('reports the reserved error for primary even when it is the only configured model', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = { primary: model('primary') }; + + await handleSecondaryModelCommand(host, 'primary'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('reserved')); + expect(host.showNotice).not.toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('shows a notice when no models are configured', async () => { + const { host } = makeHost(); + host.state.appState.availableModels = {}; + + await handleSecondaryModelCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalled(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('reports a persistence failure without a status message', async () => { + const { host } = makeHost(); + host.harness.setConfig.mockRejectedValueOnce(new Error('disk full')); + + await handleSecondaryModelCommand(host, ''); + mountedPicker(host).onSelect({ alias: 'k2' }); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalled(); + }); + expect(host.showError.mock.calls[0]![0]).toContain('disk full'); + expect(host.showStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/skills.test.ts b/apps/pythinker-code/test/tui/commands/skills.test.ts index 9128d7ab..42b54c72 100644 --- a/apps/pythinker-code/test/tui/commands/skills.test.ts +++ b/apps/pythinker-code/test/tui/commands/skills.test.ts @@ -1,12 +1,6 @@ -import type { SlashCommandHost } from '#/tui/commands'; -import { - buildSkillSlashCommands, - handleAgentsCommand, - handleSkillsCommand, - isUserActivatableSkill, -} from '#/tui/commands/index'; +import { buildSkillSlashCommands, isUserActivatableSkill } from '#/tui/commands/index'; import type { SkillSummary } from '@pymodel/pythinker-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; function skill( name: string, @@ -38,7 +32,6 @@ describe('skill slash commands', () => { skill('review', 'prompt'), skill('nested-review', 'prompt', { description: 'Nested review skill', - argumentHint: '<target>', path: '/skills/parent/nested-review/SKILL.md', }), skill('agent-only', 'agent'), @@ -59,7 +52,6 @@ describe('skill slash commands', () => { name: 'skill:nested-review', aliases: [], description: 'Nested review skill', - argumentHint: '<target>', }); expect([...built.commandMap.entries()]).toEqual([ ['skill:commit', 'commit'], @@ -99,28 +91,6 @@ describe('skill slash commands', () => { expect(built.commandMap.get('mcp-config')).toBe('mcp-config'); }); - it('hides skills that disable user invocation', async () => { - const hidden = skill('model-only', 'prompt', { - userInvocable: false, - source: 'project', - }); - const built = buildSkillSlashCommands([hidden]); - const showNotice = vi.fn(); - const host = { - session: { listSkills: vi.fn(async () => [hidden]) }, - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleSkillsCommand(host, ''); - - expect(built.commands).toEqual([]); - expect(showNotice).toHaveBeenCalledWith( - 'No skills found', - 'Create skills in .pythinker-code/skills or ~/.pythinker-code/skills.', - ); - }); - it('keeps sub-skills slash-invocable', () => { const built = buildSkillSlashCommands([ skill('outer.inner', 'prompt', { @@ -132,88 +102,4 @@ describe('skill slash commands', () => { expect(built.commands.map((command) => command.name)).toEqual(['outer.inner']); expect(built.commandMap.get('outer.inner')).toBe('outer.inner'); }); - - it('uses a skill-provided command name for dynamic MCP prompts', () => { - const built = buildSkillSlashCommands([ - skill('mcp__github__review', 'prompt', { - source: 'extra', - commandName: 'mcp__github__review', - }), - ]); - - expect(built.commands.map((command) => command.name)).toEqual([ - 'mcp__github__review', - ]); - expect(built.commandMap.get('mcp__github__review')).toBe('mcp__github__review'); - }); - - it('renders the discovered user-activatable skills through the TUI', async () => { - const showNotice = vi.fn(); - const host = { - session: { - listSkills: vi.fn(async () => [ - skill('review', 'prompt', { source: 'project' }), - skill('agent-only', 'agent', { source: 'project' }), - skill('commit', 'flow', { source: 'user' }), - ]), - }, - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleSkillsCommand(host, ''); - - expect(showNotice).toHaveBeenCalledWith( - 'Skills (2)', - '/review · project · review skill\n/commit · user · commit skill', - ); - }); -}); - -describe('agent profile slash command', () => { - it('opens a searchable catalog of resolved profiles', async () => { - const mountEditorReplacement = vi.fn(); - const host = { - state: { appState: { workDir: '/workspace' } }, - harness: { - listAgentProfiles: vi.fn(async () => ({ - profiles: [ - { - name: 'coder', - description: 'Implement changes', - source: 'built-in', - tools: ['Read', 'Edit'], - background: false, - subagents: [], - }, - { - name: 'reviewer', - description: 'Review changes', - source: 'project', - tools: ['Read', 'Grep'], - background: true, - subagents: [], - }, - ], - warnings: [], - })), - }, - mountEditorReplacement, - restoreEditor: vi.fn(), - showNotice: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleAgentsCommand(host, ''); - - expect(mountEditorReplacement).toHaveBeenCalledOnce(); - const picker = mountEditorReplacement.mock.calls[0]?.[0] as { - render(width: number): string[]; - }; - const rendered = picker.render(120).join('\n'); - expect(rendered).toContain('Agent profiles'); - expect(rendered).toContain('coder'); - expect(rendered).toContain('reviewer'); - expect(rendered).toContain('project · background'); - }); }); diff --git a/apps/pythinker-code/test/tui/commands/tag.test.ts b/apps/pythinker-code/test/tui/commands/tag.test.ts deleted file mode 100644 index c571f23e..00000000 --- a/apps/pythinker-code/test/tui/commands/tag.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; -import { handleTagCommand, type SlashCommandHost } from '#/tui/commands/index'; -import { describe, expect, it, vi } from 'vitest'; - -describe('/tag', () => { - it('persists a tag while preserving other custom metadata', async () => { - const { host, session } = makeHost({ source: 'test' }); - - await handleTagCommand(host, ' review '); - - expect(session.updateSessionMetadata).toHaveBeenCalledWith({ - custom: { source: 'test', tag: 'review' }, - }); - expect(host.showStatus).toHaveBeenCalledWith('Tagged session with #review.', 'success'); - }); - - it('confirms before removing the current tag', async () => { - const { host, session } = makeHost({ source: 'test', tag: 'review' }); - - await handleTagCommand(host, 'review'); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0]; - expect(picker).toBeInstanceOf(ChoicePickerComponent); - - (picker as ChoicePickerComponent).handleInput('\r'); - await vi.waitFor(() => { - expect(session.updateSessionMetadata).toHaveBeenCalledWith({ - custom: { source: 'test' }, - }); - }); - expect(host.restoreEditor).toHaveBeenCalledOnce(); - expect(host.showStatus).toHaveBeenCalledWith('Removed tag #review.', 'success'); - }); -}); - -function makeHost(custom: Record<string, unknown>) { - const session = { - getSessionMetadata: vi.fn(async () => ({ custom })), - updateSessionMetadata: vi.fn(async () => {}), - }; - const host = { - requireSession: vi.fn(() => session), - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - showStatus: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost & { - readonly mountEditorReplacement: ReturnType<typeof vi.fn>; - readonly restoreEditor: ReturnType<typeof vi.fn>; - readonly showStatus: ReturnType<typeof vi.fn>; - }; - return { host, session }; -} diff --git a/apps/pythinker-code/test/tui/commands/undo.test.ts b/apps/pythinker-code/test/tui/commands/undo.test.ts new file mode 100644 index 00000000..df219bb8 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/undo.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleUndoCommand } from '#/tui/commands/undo'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import type { TranscriptEntry } from '#/tui/types'; + +function entry(partial: Partial<TranscriptEntry> & Pick<TranscriptEntry, 'kind' | 'content'>): TranscriptEntry { + return { + id: `t-${Math.random().toString(36).slice(2, 10)}`, + turnId: undefined, + renderMode: 'plain', + ...partial, + }; +} + +function hostWith(entries: TranscriptEntry[]): SlashCommandHost { + return { + session: { undoHistory: vi.fn(async () => {}) }, + state: { + transcriptEntries: entries, + transcriptContainer: { children: [], addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + appState: { streamingPhase: 'idle' }, + }, + showError: vi.fn(), + } as unknown as SlashCommandHost; +} + +describe('/undo with bundled prompts', () => { + it('removes the bundle cards with their prompt, keeping a standalone skill card before them', async () => { + const entries: TranscriptEntry[] = [ + entry({ kind: 'user', content: 'earlier question' }), + entry({ + kind: 'skill_activation', + content: 'Activated skill: review', + skillTrigger: 'user-slash', + }), + entry({ kind: 'user', content: 'prompt one' }), + entry({ kind: 'assistant', content: 'answer one' }), + entry({ + kind: 'skill_activation', + content: 'Activated skill: security', + skillTrigger: 'user-slash', + bundledWithPrompt: true, + }), + entry({ kind: 'user', content: 'prompt two' }), + entry({ kind: 'assistant', content: 'answer two' }), + ]; + const host = hostWith(entries); + + await handleUndoCommand(host, '1'); + + expect(host.session?.undoHistory).toHaveBeenCalledWith(1); + expect(entries.map((item) => item.content)).toEqual([ + 'earlier question', + 'Activated skill: review', + 'prompt one', + 'answer one', + ]); + }); + + it('removes bundle cards around an interleaved hook result and keeps the hook result', async () => { + const entries: TranscriptEntry[] = [ + entry({ + kind: 'skill_activation', + content: 'Activated skill: review', + skillTrigger: 'user-slash', + bundledWithPrompt: true, + }), + entry({ kind: 'assistant', content: 'hook note', hookResult: true }), + entry({ kind: 'user', content: 'bundled prompt' }), + entry({ kind: 'assistant', content: 'bundled answer' }), + ]; + const host = hostWith(entries); + + await handleUndoCommand(host, '1'); + + expect(host.session?.undoHistory).toHaveBeenCalledWith(1); + expect(entries.map((item) => item.content)).toEqual(['hook note']); + }); + + it('does not count bundle cards as undo anchors of their own', async () => { + const entries: TranscriptEntry[] = [ + entry({ kind: 'user', content: 'prompt one' }), + entry({ kind: 'assistant', content: 'answer one' }), + entry({ + kind: 'skill_activation', + content: 'Activated skill: review', + skillTrigger: 'user-slash', + bundledWithPrompt: true, + }), + entry({ kind: 'user', content: 'prompt two' }), + entry({ kind: 'assistant', content: 'answer two' }), + ]; + const host = hostWith(entries); + + await handleUndoCommand(host, '2'); + + expect(host.session?.undoHistory).toHaveBeenCalledWith(2); + expect(entries).toHaveLength(0); + }); +}); diff --git a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts index 15762fc3..8e79bfe9 100644 --- a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts +++ b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts @@ -1,26 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; -import type { SlashCommandHost } from '#/tui/commands'; -import { - applyCopyPreferenceChoice, - applyPrivacyPreferenceChoice, - applyUpdatePreferenceChoice, - handleOutputStyleCommand, - handlePermissionsCommand, -} from '#/tui/commands/config'; -import { handleUpdateCommand } from '#/tui/commands/info'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { applyUpdatePreferenceChoice } from '#/tui/commands/config'; import { darkColors } from '#/tui/theme/colors'; const mocks = vi.hoisted(() => ({ - disableTelemetry: vi.fn(), saveTuiConfig: vi.fn(), - startManualUpdate: vi.fn(), -})); - -vi.mock('@pymodel/pythinker-telemetry', async (importOriginal) => ({ - ...(await importOriginal<typeof import('@pymodel/pythinker-telemetry')>()), - disableTelemetry: mocks.disableTelemetry, })); vi.mock('../../../src/tui/config', async () => { @@ -33,51 +17,18 @@ vi.mock('../../../src/tui/config', async () => { }; }); -vi.mock('../../../src/cli/update/preflight', async (importOriginal) => { - const actual = await vi.importActual<typeof import('../../../src/cli/update/preflight.js')>( - '../../../src/cli/update/preflight.js', - ); - return { - ...actual, - startManualUpdate: mocks.startManualUpdate, - }; -}); - describe('update preference commands', () => { - it('persists telemetry opt-out and stops collection immediately', async () => { - const host = { - harness: { - setConfig: vi.fn(async () => ({ providers: {}, telemetry: false })), - }, - showError: vi.fn(), - showNotice: vi.fn(), - track: vi.fn(), - } as unknown as SlashCommandHost; - - await applyPrivacyPreferenceChoice(host, false); - - expect(host.harness.setConfig).toHaveBeenCalledWith({ telemetry: false }); - expect(mocks.disableTelemetry).toHaveBeenCalledOnce(); - expect(host.showNotice).toHaveBeenCalledWith( - 'Telemetry disabled', - 'Applied immediately and saved for future launches.', - ); - }); - it('saves automatic update preference changes to tui.toml', async () => { const setAppState = vi.fn(); const showStatus = vi.fn(); const track = vi.fn(); const host = { state: { - copyFullResponse: false, - layout: 'inline' as const, appState: { theme: 'auto' as const, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, }, theme: { palette: darkColors }, }, @@ -90,443 +41,41 @@ describe('update preference commands', () => { expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ theme: 'auto', - layout: 'inline', editorCommand: null, + disablePasteBurst: false, + renderLatex: true, + cacheExpiryHint: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - copyFullResponse: false, + statusLine: { items: null, command: null }, }); expect(setAppState).toHaveBeenCalledWith({ upgrade: { autoInstall: false } }); expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); expect(showStatus).toHaveBeenCalledWith('Automatic updates disabled.'); }); - it('saves the full-response copy preference to tui.toml', async () => { - const showStatus = vi.fn(); + it('preserves a render_latex opt-out when saving an unrelated preference', async () => { + mocks.saveTuiConfig.mockClear(); const host = { state: { - copyFullResponse: false, - layout: 'inline' as const, appState: { theme: 'auto' as const, editorCommand: null, + renderLatex: false, notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - }, - }, - showStatus, - }; - - await applyCopyPreferenceChoice(host, true); - - expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ - theme: 'auto', - layout: 'inline', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - copyFullResponse: true, - }); - expect(host.state.copyFullResponse).toBe(true); - expect(showStatus).toHaveBeenCalledWith('Full-response copying enabled.'); - }); -}); - -describe('output style commands', () => { - function makeHost() { - const catalog = { - active: 'Explanatory', - styles: [ - { - name: 'default', - description: 'Use the standard Pythinker response style.', - source: 'built-in' as const, - active: false, - }, - { - name: 'Explanatory', - description: 'Explain implementation choices and codebase patterns.', - source: 'built-in' as const, - active: true, }, - ], - }; - const host = { - state: { appState: { workDir: '/workspace' } }, - harness: { - listOutputStyles: vi.fn(async () => catalog), - setConfig: vi.fn(async () => ({ providers: {} })), + theme: { palette: darkColors }, }, - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - showError: vi.fn(), - showNotice: vi.fn(), - } as unknown as SlashCommandHost & { - harness: { - listOutputStyles: ReturnType<typeof vi.fn>; - setConfig: ReturnType<typeof vi.fn>; - }; - mountEditorReplacement: ReturnType<typeof vi.fn>; - restoreEditor: ReturnType<typeof vi.fn>; - showError: ReturnType<typeof vi.fn>; - showNotice: ReturnType<typeof vi.fn>; - }; - return { catalog, host }; - } - - it('persists a named style for new sessions', async () => { - const { host } = makeHost(); - - await handleOutputStyleCommand(host, 'default'); - - expect(host.harness.listOutputStyles).toHaveBeenCalledWith('/workspace'); - expect(host.harness.setConfig).toHaveBeenCalledWith({ outputStyle: 'default' }); - expect(host.showNotice).toHaveBeenCalledWith( - 'Output style saved: default', - 'Applies to new sessions.', - ); - }); - - it('rejects unknown styles without writing config', async () => { - const { host } = makeHost(); - - await handleOutputStyleCommand(host, 'missing'); - - expect(host.harness.setConfig).not.toHaveBeenCalled(); - expect(host.showError).toHaveBeenCalledWith('Unknown output style: missing'); - }); - - it('opens the shared searchable picker when no name is provided', async () => { - const { host } = makeHost(); - - await handleOutputStyleCommand(host, ''); - - expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - render(width: number): string[]; - }; - expect(picker.render(120).join('\n')).toContain('Select output style'); - expect(picker.render(120).join('\n')).toContain('Explanatory'); - }); - - it('reports when a plugin-forced style still takes precedence', async () => { - const { host } = makeHost(); - host.harness.listOutputStyles.mockResolvedValueOnce({ - active: 'example:Strict', - styles: [ - { - name: 'default', - description: 'Use the standard Pythinker response style.', - source: 'built-in', - active: false, - }, - { - name: 'example:Strict', - description: 'Use the plugin response contract.', - source: 'plugin', - active: true, - forced: true, - }, - ], - }); - - await handleOutputStyleCommand(host, 'default'); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Output style saved: default', - 'example:Strict remains active while its plugin forces that style.', - ); - }); -}); - -describe('update command', () => { - function makeHost() { - const host = { - state: { appState: { version: '0.9.0' } }, + setAppState: vi.fn(), showStatus: vi.fn(), - showNotice: vi.fn(), - showError: vi.fn(), - } as unknown as SlashCommandHost & { - showStatus: ReturnType<typeof vi.fn>; - showNotice: ReturnType<typeof vi.fn>; - showError: ReturnType<typeof vi.fn>; - }; - return host; - } - - it('keeps the existing wording for an in-progress update of the same version', async () => { - const host = makeHost(); - mocks.startManualUpdate.mockResolvedValue({ - status: 'in-progress', - installingVersion: '0.10.0', - installOnRestart: false, - readyToInstall: false, - }); - - await handleUpdateCommand(host, ''); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Update to v0.10.0 already in progress', - 'Close this terminal and open a new one once it completes.', - ); - }); - - it('keeps the homebrew ready-to-install wording for a same-version in-progress update', async () => { - const host = makeHost(); - mocks.startManualUpdate.mockResolvedValue({ - status: 'in-progress', - installingVersion: '0.10.0', - installOnRestart: true, - readyToInstall: true, - }); - - await handleUpdateCommand(host, ''); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Update to v0.10.0 already in progress', - 'Close this terminal and open a new one to install it.', - ); - }); - - it('announces the newer target when the running install is for an older version', async () => { - const host = makeHost(); - mocks.startManualUpdate.mockResolvedValue({ - status: 'in-progress', - installingVersion: '0.10.0', - targetVersion: '0.11.0', - installOnRestart: false, - readyToInstall: false, - }); - - await handleUpdateCommand(host, ''); - - expect(host.showNotice).toHaveBeenCalledWith( - 'Installing v0.10.0 — v0.11.0 will follow', - 'The running install of v0.10.0 finishes first; v0.11.0 installs after the next start.', - ); - }); - - it('reports a parked version as failed with the recorded reason', async () => { - const host = makeHost(); - mocks.startManualUpdate.mockResolvedValue({ - status: 'failed', - version: '0.10.0', - attempts: 2, - failedAt: '2026-08-05T08:00:00.000Z', - message: 'npm exited with code 1', - command: 'npm install -g @pymodel/pythinker-code@0.10.0', - }); - - await handleUpdateCommand(host, ''); - - expect(host.showError).toHaveBeenCalledWith( - 'Update to v0.10.0 failed after 2 attempts.\n' + - 'Reason: npm exited with code 1\n' + - 'To update manually, run: npm install -g @pymodel/pythinker-code@0.10.0', - ); - }); - - it('reports a parked version as failed without a reason line', async () => { - const host = makeHost(); - mocks.startManualUpdate.mockResolvedValue({ - status: 'failed', - version: '0.10.0', - attempts: 2, - failedAt: '2026-08-05T08:00:00.000Z', - command: 'npm install -g @pymodel/pythinker-code@0.10.0', - }); - - await handleUpdateCommand(host, ''); - - expect(host.showError).toHaveBeenCalledWith( - 'Update to v0.10.0 failed after 2 attempts.\n' + - 'To update manually, run: npm install -g @pymodel/pythinker-code@0.10.0', - ); - }); - - it('truncates a very long recorded reason to one line and marks it truncated', async () => { - const host = makeHost(); - const longReason = `npm failed: ${'x'.repeat(5000)}`; - mocks.startManualUpdate.mockResolvedValue({ - status: 'failed', - version: '0.10.0', - attempts: 3, - failedAt: '2026-08-05T08:00:00.000Z', - message: longReason, - command: 'npm install -g @pymodel/pythinker-code@0.10.0', - }); - - await handleUpdateCommand(host, ''); - - expect(host.showError).toHaveBeenCalledWith( - 'Update to v0.10.0 failed after 3 attempts.\n' + - `Reason: ${longReason.slice(0, 160)}… (truncated)\n` + - 'To update manually, run: npm install -g @pymodel/pythinker-code@0.10.0', - ); - }); -}); - -describe('permission rule commands', () => { - function makeHost() { - const session = { - reloadSession: vi.fn(async () => ({})), - listWorkspaceDirectories: vi.fn(async () => [ - { path: '/tmp/extra', source: 'user' as const }, - ]), - removeWorkspaceDirectory: vi.fn(async () => {}), - }; - const harness = { - getConfig: vi.fn(async () => ({ - providers: {}, - permission: { - rules: [ - { - decision: 'deny' as const, - scope: 'user' as const, - pattern: 'Bash(rm *)', - }, - ], - }, - additionalDirs: ['/tmp/extra'], - })), - setConfig: vi.fn(async (patch) => ({ providers: {}, ...patch })), - }; - const host = { - state: { appState: {} }, - session, - harness, - mountEditorReplacement: vi.fn(), - restoreEditor: vi.fn(), - reloadCurrentSessionView: vi.fn(async () => {}), - showError: vi.fn(), - showNotice: vi.fn(), - } as unknown as SlashCommandHost & { - session: typeof session; - harness: typeof harness; - mountEditorReplacement: ReturnType<typeof vi.fn>; - restoreEditor: ReturnType<typeof vi.fn>; - reloadCurrentSessionView: ReturnType<typeof vi.fn>; - showError: ReturnType<typeof vi.fn>; - showNotice: ReturnType<typeof vi.fn>; - }; - return host; - } - - it('opens the native rule manager with add actions and current rules', async () => { - const host = makeHost(); - - await handlePermissionsCommand(host, ''); - - expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - render(width: number): string[]; - }; - const rendered = picker.render(120).join('\n'); - expect(rendered).toContain('Manage permission rules'); - expect(rendered).toContain('Add allow rule'); - expect(rendered).toContain('deny · Bash(rm *)'); - expect(rendered).toContain('Add working directory'); - expect(rendered).toContain('/tmp/extra'); - }); - - it('adds a validated user rule and reloads the active session', async () => { - const host = makeHost(); - - await handlePermissionsCommand(host, ''); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('\r'); - - const input = host.mountEditorReplacement.mock.calls[1]?.[0] as { - handleInput(data: string): void; - }; - for (const character of 'Bash(git *)') input.handleInput(character); - input.handleInput('\r'); - await vi.waitFor(() => { - expect(host.harness.setConfig).toHaveBeenCalledOnce(); - }); - - expect(host.harness.setConfig).toHaveBeenCalledWith({ - permission: { - rules: [ - { decision: 'deny', scope: 'user', pattern: 'Bash(rm *)' }, - { decision: 'allow', scope: 'user', pattern: 'Bash(git *)' }, - ], - }, - }); - expect(host.session.reloadSession).toHaveBeenCalledOnce(); - expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( - host.session, - 'Added allow rule Bash(git *).', - ); - }); - - it('deletes a selected rule after confirmation', async () => { - const host = makeHost(); - - await handlePermissionsCommand(host, ''); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; - }; - picker.handleInput('\u001B[B'); - picker.handleInput('\u001B[B'); - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); - - const confirmation = host.mountEditorReplacement.mock.calls[1]?.[0] as { - handleInput(data: string): void; - }; - confirmation.handleInput('\u001B[A'); - confirmation.handleInput('\r'); - await vi.waitFor(() => { - expect(host.harness.setConfig).toHaveBeenCalledOnce(); - }); - - expect(host.harness.setConfig).toHaveBeenCalledWith({ - permission: { rules: [] }, - }); - expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( - host.session, - 'Deleted deny rule Bash(rm *).', - ); - }); - - it('removes a saved working directory from the active session and config', async () => { - const host = makeHost(); - host.state.appState.workDir = '/workspace'; - - await handlePermissionsCommand(host, ''); - const picker = host.mountEditorReplacement.mock.calls[0]?.[0] as { - handleInput(data: string): void; + track: vi.fn(), }; - for (let index = 0; index < 5; index++) picker.handleInput('\u001B[B'); - picker.handleInput('\r'); - const confirmation = host.mountEditorReplacement.mock.calls[1]?.[0] as { - handleInput(data: string): void; - }; - confirmation.handleInput('\u001B[A'); - confirmation.handleInput('\r'); - await vi.waitFor(() => { - expect(host.session.removeWorkspaceDirectory).toHaveBeenCalledWith('/tmp/extra'); - expect(host.harness.setConfig).toHaveBeenCalledWith({ additionalDirs: [] }); - }); + await applyUpdatePreferenceChoice(host, false); - expect(host.showNotice).toHaveBeenCalledWith( - 'Removed working directory /tmp/extra from user settings.', + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ renderLatex: false }), ); }); - - it('rejects command arguments without opening the manager', async () => { - const host = makeHost(); - - await handlePermissionsCommand(host, 'allow Bash'); - - expect(host.showError).toHaveBeenCalledWith('Usage: /permissions'); - expect(host.mountEditorReplacement).not.toHaveBeenCalled(); - }); }); diff --git a/apps/pythinker-code/test/tui/commands/web.test.ts b/apps/pythinker-code/test/tui/commands/web.test.ts index 5692d1d7..0c79615c 100644 --- a/apps/pythinker-code/test/tui/commands/web.test.ts +++ b/apps/pythinker-code/test/tui/commands/web.test.ts @@ -1,7 +1,60 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; -import { webSessionUrl } from '#/tui/commands/web'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { handleWebCommand, webSessionUrl } from '#/tui/commands/web'; + +const mocks = vi.hoisted(() => ({ + startServerForeground: vi.fn(), + tryResolveServerToken: vi.fn(), + getDataDir: vi.fn(() => '/tmp/pythinker-home'), + openUrl: vi.fn(), +})); + +vi.mock('#/cli/sub/web/run', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/web/run')>(); + return { ...actual, startServerForeground: mocks.startServerForeground }; +}); + +vi.mock('#/cli/sub/web/shared', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/cli/sub/web/shared')>(); + return { + ...actual, + tryResolveServerToken: mocks.tryResolveServerToken, + }; +}); + +vi.mock('#/utils/open-url', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/open-url')>(); + return { ...actual, openUrl: mocks.openUrl }; +}); + +vi.mock('#/utils/paths', async (importOriginal) => { + const actual = await importOriginal<typeof import('#/utils/paths')>(); + return { ...actual, getDataDir: mocks.getDataDir }; +}); + +function makeHost() { + const host = { + session: { id: 'ses-1' }, + showStatus: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + setExitOpenUrl: vi.fn(), + setExitForegroundTask: vi.fn(), + stop: vi.fn(async () => {}), + } as unknown as SlashCommandHost & { + showStatus: ReturnType<typeof vi.fn>; + showError: ReturnType<typeof vi.fn>; + mountEditorReplacement: ReturnType<typeof vi.fn>; + restoreEditor: ReturnType<typeof vi.fn>; + setExitOpenUrl: ReturnType<typeof vi.fn>; + setExitForegroundTask: ReturnType<typeof vi.fn>; + stop: ReturnType<typeof vi.fn>; + }; + return host; +} describe('web slash command', () => { it('is registered as an always-available built-in', () => { @@ -11,6 +64,62 @@ describe('web slash command', () => { }); }); +describe('handleWebCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDataDir.mockReturnValue('/tmp/pythinker-home'); + }); + + it('shows an error and does nothing when there is no active session', async () => { + const host = makeHost(); + host.session = undefined; + + await handleWebCommand(host); + + expect(host.showError).toHaveBeenCalledOnce(); + expect(host.setExitForegroundTask).not.toHaveBeenCalled(); + expect(host.stop).not.toHaveBeenCalled(); + }); + + it('registers a foreground takeover and stops the TUI without opening a URL yet', async () => { + const host = makeHost(); + + await handleWebCommand(host); + + expect(host.setExitForegroundTask).toHaveBeenCalledOnce(); + expect(host.stop).toHaveBeenCalledOnce(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(mocks.openUrl).not.toHaveBeenCalled(); + }); + + it('starts the new server on takeover, printing the banner and opening the deep link', async () => { + mocks.tryResolveServerToken.mockReturnValue('tok-1'); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + mocks.startServerForeground.mockImplementation( + async (_options: unknown, hooks: { onReady?: (origin: string) => void }) => { + hooks.onReady?.('http://127.0.0.1:58627'); + }, + ); + const host = makeHost(); + + await handleWebCommand(host); + const task = host.setExitForegroundTask.mock.calls[0]![0] as ( + exitCode: number, + ) => Promise<void>; + await task(0); + + expect(mocks.startServerForeground).toHaveBeenCalledOnce(); + expect(mocks.openUrl).toHaveBeenCalledWith( + 'http://127.0.0.1:58627/sessions/ses-1#token=tok-1', + ); + const written = writeSpy.mock.calls.map((call) => String(call[0])).join(''); + expect(written).toContain('Pythinker server ready'); + expect(written).toContain('Ctrl+C'); + expect(written).toContain('/sessions/ses-1'); + writeSpy.mockRestore(); + }); +}); + describe('webSessionUrl', () => { it('deep-links to the session under the origin', () => { expect(webSessionUrl('http://127.0.0.1:58627', 'abc123')).toBe( @@ -29,4 +138,16 @@ describe('webSessionUrl', () => { 'http://127.0.0.1:58627/sessions/a%2Fb%20c', ); }); + + it('carries the bearer token in the fragment so the browser authenticates on load', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', 'tok-1')).toBe( + 'http://127.0.0.1:58627/sessions/abc123#token=tok-1', + ); + }); + + it('omits the fragment when no token is available', () => { + expect(webSessionUrl('http://127.0.0.1:58627', 'abc123', undefined)).toBe( + 'http://127.0.0.1:58627/sessions/abc123', + ); + }); }); diff --git a/apps/pythinker-code/test/tui/components/chrome/banner.test.ts b/apps/pythinker-code/test/tui/components/chrome/banner.test.ts index 8f5724e5..7cff4b4c 100644 --- a/apps/pythinker-code/test/tui/components/chrome/banner.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/banner.test.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { currentTheme } from '#/tui/theme'; @@ -182,7 +182,7 @@ describe('BannerComponent', () => { }); it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { - const width = 20; + const width = 24; const lines = new BannerComponent( makeBannerState({ tag: 'New:', @@ -196,9 +196,47 @@ describe('BannerComponent', () => { expect(lines[0]).toContain('✦ New:'); const firstLine = lines[0]!; const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); - const continuationLine = lines.find((line) => line.includes('lot of'))!; - expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const continuationLine = lines.find((line) => line.includes('of content'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('of content')))).toBe(mainTextStart); const subLine = lines.find((line) => line.includes('Sub text'))!; expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); }); + + it('moves a long tag onto its own line so the main text keeps a usable width', () => { + // Regression: remote banner configs can set a full-sentence tag. Inline it + // would leave the main text only a few columns, which hard-breaks words. + const width = 50; + const lines = new BannerComponent( + makeBannerState({ + tag: 'Use Kimi K3 with High thinking effort', + mainText: '- for the best balance between token spend and capability', + subText: 'Run /model to switch to K3 and set thinking effort to High', + }), + ).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + // The tag occupies the first line alone; no main text is squeezed next to it. + expect(lines[0]).toContain('✦ Use Kimi K3 with High thinking effort'); + expect(lines[0]).not.toContain('- for'); + // Words stay intact (no mid-word hard breaks like "balan"/"ce"). + const joined = lines.join('\n'); + for (const word of ['balance', 'between', 'capability', 'thinking', 'effort']) { + expect(joined).toContain(word); + } + // Main text and subtext align with the tag text (right after "✦ "). + const mainLine = lines.find((line) => line.includes('- for'))!; + expect(visibleWidth(mainLine.slice(0, mainLine.indexOf('- for')))).toBe(visibleWidth('✦ ')); + const subLine = lines.find((line) => line.includes('Run /model'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Run /model')))).toBe(visibleWidth('✦ ')); + }); + + it('keeps a short tag inline when the remaining width is enough', () => { + const width = 40; + const lines = new BannerComponent( + makeBannerState({ tag: 'Tip:', mainText: 'Use /help to list commands.' }), + ).render(width); + expect(lines[0]).toContain('✦ Tip:'); + expect(lines[0]).toContain('Use /help'); + }); }); diff --git a/apps/pythinker-code/test/tui/components/chrome/device-code-box.test.ts b/apps/pythinker-code/test/tui/components/chrome/device-code-box.test.ts index 04ef2f67..f2dd9da0 100644 --- a/apps/pythinker-code/test/tui/components/chrome/device-code-box.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/device-code-box.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { DeviceCodeBoxComponent } from '#/tui/components/chrome/device-code-box'; @@ -8,9 +8,9 @@ function strip(text: string): string { return text.replaceAll(/\[[0-9;]*m/g, ''); } -const url = 'https://pythinker.com/code/authorize_device?user_code=N32D-W3YD'; +const url = 'https://www.kimi.com/code/authorize_device?user_code=N32D-W3YD'; const code = 'N32D-W3YD'; -const title = 'Sign in to Kimi'; +const title = 'Sign in to Pythinker Code'; const hint = 'Press Ctrl-C to cancel'; describe('DeviceCodeBoxComponent', () => { diff --git a/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts new file mode 100644 index 00000000..1c76901b --- /dev/null +++ b/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts @@ -0,0 +1,263 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { + runStatusLineCommand, + STATUS_LINE_MAX_CAPTURE_BYTES, + StatusLineCommandRunner, + type StatusLinePayload, +} from '#/tui/utils/status-line-command'; +import type { AppState } from '#/tui/types'; + +const baseState: AppState = { + version: '1.2.3', + workDir: '/tmp/project', + additionalDirs: [], + sessionId: 'ses-1', + sessionTitle: null, + model: 'kimi-k2', + permissionMode: 'manual', + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, + planMode: false, + inputMode: 'prompt', + dynamicWorkflowMode: false, + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + mcpServersSummary: null, +}; + +const payload: StatusLinePayload = { + model: 'kimi-k2', + cwd: '/tmp/project', + gitBranch: 'main', + permissionMode: 'manual', + planMode: false, + contextUsage: 12, + contextTokens: 1024, + maxContextTokens: 8192, + sessionId: 'ses-1', + version: '1.2.3', +}; + +function plain(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('FooterComponent status_line items', () => { + it('renders only the chosen slots in the given order', () => { + const state: AppState = { + ...baseState, + statusLine: { items: ['cwd', 'model'], command: null }, + }; + const footer = new FooterComponent(state); + + const line1 = plain(footer.render(120)[0]!); + const cwdAt = line1.indexOf('/tmp/project'); + const modelAt = line1.indexOf('kimi-k2'); + expect(cwdAt).toBeGreaterThanOrEqual(0); + expect(modelAt).toBeGreaterThan(cwdAt); + expect(line1).not.toContain('goal'); + }); + + it('keeps the default layout when statusLine is unset', () => { + const footer = new FooterComponent({ ...baseState }); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('kimi-k2'); + expect(line1).toContain('/tmp/project'); + }); + + it('drops the rotating tips when tips is not in items', () => { + const withTips = plain(new FooterComponent(baseState).render(200)[0]!); + const state: AppState = { + ...baseState, + statusLine: { items: ['model', 'cwd'], command: null }, + }; + const withoutTips = plain(new FooterComponent(state).render(200)[0]!); + + expect(withoutTips.length).toBeLessThan(withTips.length); + expect(withoutTips.trimEnd()).toMatch(/kimi-k2 {2}\/tmp\/project$/); + }); + + it('honors the configured position of the tips slot', () => { + // The tip content itself rotates; locate it via a tips-only render. + const tipsOnly = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips'], command: null }, + }).render(200)[0]!, + ).trim(); + + const tipsFirst = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['tips', 'model'], command: null }, + }).render(200)[0]!, + ); + const tipsLast = plain( + new FooterComponent({ + ...baseState, + statusLine: { items: ['model', 'tips'], command: null }, + }).render(200)[0]!, + ); + + expect(tipsOnly.length).toBeGreaterThan(0); + expect(tipsFirst.indexOf(tipsOnly)).toBeLessThan(tipsFirst.indexOf('kimi-k2')); + expect(tipsLast.indexOf('kimi-k2')).toBeLessThan(tipsLast.indexOf(tipsOnly)); + }); + + it('renders nothing on line 1 for an empty items list', () => { + const state: AppState = { + ...baseState, + statusLine: { items: [], command: null }, + }; + const footer = new FooterComponent(state); + + expect(plain(footer.render(120)[0]!).trim()).toBe(''); + }); +}); + +describe('runStatusLineCommand', () => { + it('passes the payload as JSON on stdin and returns the first stdout line', async () => { + const line = await runStatusLineCommand('cat', payload); + + expect(line).not.toBeNull(); + const parsed = JSON.parse(line!); + expect(parsed.model).toBe('kimi-k2'); + expect(parsed.gitBranch).toBe('main'); + expect(parsed.cwd).toBe('/tmp/project'); + }); + + it('returns null on a nonzero exit', async () => { + expect(await runStatusLineCommand('exit 3', payload)).toBeNull(); + }); + + it('returns null on empty output', async () => { + expect(await runStatusLineCommand('true', payload)).toBeNull(); + }); + + it('returns null when the command overruns the timeout', async () => { + expect(await runStatusLineCommand('sleep 2', payload, 100)).toBeNull(); + }); + + it('trims the line and ignores later lines', async () => { + const line = await runStatusLineCommand('printf "first\\nsecond\\n"', payload); + + expect(line).toBe('first'); + }); + + it('caps the captured output instead of accumulating an unending stream', async () => { + // 200 KB on a single line, then exit: only the capped prefix is kept. + const line = await runStatusLineCommand( + 'head -c 200000 /dev/zero | tr "\\0" "a"', + payload, + ); + + expect(line).not.toBeNull(); + expect(line!.length).toBeLessThanOrEqual(STATUS_LINE_MAX_CAPTURE_BYTES); + }); +}); + +describe('FooterComponent status_line command', () => { + it('swaps line 1 to the command output once it lands', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "my-custom-status"' }, + }; + const footer = new FooterComponent(state); + + // Before the first run completes the built-in layout is still shown. + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('my-custom-status'); + }); + + it('keeps the built-in layout when the command fails', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'exit 1' }, + }; + const footer = new FooterComponent(state); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(plain(footer.render(120)[0]!)).toContain('kimi-k2'); + }); +}); + +describe('StatusLineCommandRunner', () => { + it('caches the last good line and coalesces refreshes in the same interval', async () => { + const runner = new StatusLineCommandRunner('printf "x"', () => {}); + + runner.maybeRefresh(payload); + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(runner.current()).toBe('x'); + }); + + it('runs a deferred refresh after the throttle interval instead of dropping it', async () => { + const dir = join(tmpdir(), `sl-trailing-${process.pid}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + try { + const counterFile = join(dir, 'count'); + const scriptFile = join(dir, 'count.sh'); + writeFileSync(counterFile, '0'); + writeFileSync( + scriptFile, + '#!/bin/sh\nn=$(cat "$1")\necho $((n+1)) > "$1"\nprintf "run-%s" "$n"\n', + ); + const runner = new StatusLineCommandRunner(`sh ${scriptFile} ${counterFile}`, () => {}); + + runner.maybeRefresh(payload); + await new Promise((resolve) => setTimeout(resolve, 250)); + runner.maybeRefresh(payload); // throttled: must defer, not drop + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('1'); + + await new Promise((resolve) => setTimeout(resolve, 800)); + expect(readFileSync(counterFile, 'utf-8').trim()).toBe('2'); + runner.dispose(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('recreates the runner when the command changes', async () => { + const state: AppState = { + ...baseState, + statusLine: { items: null, command: 'printf "aaa"' }, + }; + const footer = new FooterComponent(state); + footer.render(120); // kicks the first run + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(plain(footer.render(120)[0]!)).toContain('aaa'); + + footer.setState({ ...state, statusLine: { items: null, command: 'printf "bbb"' } }); + footer.render(120); // kicks the replacement run + await new Promise((resolve) => setTimeout(resolve, 450)); + + const line1 = plain(footer.render(120)[0]!); + expect(line1).toContain('bbb'); + expect(line1).not.toContain('aaa'); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index 96efa96a..f79b19ac 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -1,382 +1,225 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { FooterComponent, footerStatusFromAppState } from '#/tui/components/chrome/footer'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - foldFooterEvents, - selectFooterViewModel, -} from '#/tui/runtime/footer/footer-model'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { FooterComponent } from '#/tui/components/chrome/footer'; +import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; +import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; -import type { GitStatusCache } from '#/utils/git/git-status'; - -const gitMocks = vi.hoisted(() => { - const onChangeCallbacks: Array<() => void> = []; - const createGitStatusCache = vi.fn( - ( - _workDir: string, - options: { readonly onChange?: () => void } = {}, - ): GitStatusCache => { - if (options.onChange !== undefined) { - onChangeCallbacks.push(options.onChange); - } - return { getStatus: () => null }; - }, - ); - return { createGitStatusCache, onChangeCallbacks }; -}); -vi.mock('../../../../src/utils/git/git-status', async () => { - const actual = await vi.importActual< - typeof import('../../../../src/utils/git/git-status.js') - >('../../../../src/utils/git/git-status.js'); - return { - ...actual, - createGitStatusCache: gitMocks.createGitStatusCache, - }; -}); +const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +function truecolorCodes(text: string): Set<string> { + const codes = new Set<string>(); + for (const match of text.matchAll(TRUECOLOR_PATTERN)) { + codes.add(`${match[1]},${match[2]},${match[3]}`); + } + return codes; +} + +// Dark dance colors the footer never uses outside of /dance. +const RAINBOW_CYAN = '91,192,190'; +const RAINBOW_GREEN = '78,200,126'; + +function setDanceView(colored: boolean, phase: number): void { + const dance: RainbowDanceController = { + colored, + phase, + start: () => {}, + stop: () => {}, + dispose: () => {}, + }; + setRainbowDance(dance); } const appState: AppState = { version: '1.2.3', - workDir: '/Users/example/work/pythinker-code', + workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, - model: 'DeepSeek V4 Flash', + model: 'kimi-k2', permissionMode: 'manual', - thinkingLevel: 'max', - contextUsage: 0.05, + thinkingEffort: 'off', + contextUsage: 0, contextTokens: 0, maxContextTokens: 0, isCompacting: false, isReplaying: false, - streamingPhase: 'composing', - streamingStartTime: Date.parse('2026-08-02T00:00:00.000Z'), + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, planMode: false, - dynamicWorkflowMode: true, + inputMode: 'prompt', + dynamicWorkflowMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, }; -function statusLine( - overrides: Partial<AppState['statusLine']> = {}, -): AppState['statusLine'] { - return { ...DEFAULT_STATUS_LINE_CONFIG, ...overrides }; -} - -const activeGoal: NonNullable<AppState['goal']> = { - goalId: 'goal-1', - objective: 'Ship it', - status: 'active', - turnsUsed: 1, - tokensUsed: 0, - wallClockMs: 0, - budget: { - turnBudget: null, - tokenBudget: null, - wallClockBudgetMs: null, - remainingTokens: null, - remainingTurns: null, - remainingWallClockMs: null, - tokenBudgetReached: false, - turnBudgetReached: false, - wallClockBudgetReached: false, - overBudget: false, - }, -}; - describe('FooterComponent', () => { const previousChalkLevel = chalk.level; beforeEach(() => { chalk.level = 3; - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-08-02T00:04:12.000Z')); - gitMocks.createGitStatusCache.mockClear(); - gitMocks.onChangeCallbacks.length = 0; }); afterEach(() => { chalk.level = previousChalkLevel; - vi.useRealTimers(); + setRainbowDance(undefined); }); - it('does not render shared status rows', () => { + it('paints the model name in rainbow while colored', () => { + setDanceView(true, 0); const footer = new FooterComponent(appState); - expect(footer.render(160)).toEqual([]); + const codes = truecolorCodes(footer.render(120).join('\n')); + + // "kimi-k2" spreads across the palette, pulling in colors the footer + // never renders on its own. + expect(codes.has(RAINBOW_CYAN)).toBe(true); + expect(codes.has(RAINBOW_GREEN)).toBe(true); }); - it('renders validation rows but suppresses activity and shared status rows', () => { + it('renders the model name in its normal color when not dancing', () => { const footer = new FooterComponent(appState); - const activity = selectFooterViewModel( - foldFooterEvents(createFooterState(), [ - { - type: 'activity.updated', - activity: { - phase: 'waiting', - label: 'Waiting for response', - spinnerActive: true, - spinnerFrame: '⠋', - }, - }, - ]), - Date.now(), - DEFAULT_STATUS_LINE_CONFIG, - ); - footer.setViewModel(activity); - - expect(footer.render(120).map(stripAnsi)).toEqual([]); - - const validation = selectFooterViewModel( - foldFooterEvents(createFooterState(), [ - { - type: 'validation.updated', - validation: { level: 'error', message: 'Fix the request' }, - }, - ]), - Date.now(), - DEFAULT_STATUS_LINE_CONFIG, - ); - footer.setViewModel(validation); - expect(footer.render(120).map(stripAnsi)).toEqual(['error: Fix the request']); - }); + const codes = truecolorCodes(footer.render(120).join('\n')); - it('omits elapsed for an idle workflow after its completed turn', () => { - const completedWorkflow = footerStatusFromAppState( - { ...appState, streamingPhase: 'idle' }, - null, - ); - const missingStart = footerStatusFromAppState( - { ...appState, streamingPhase: 'waiting', streamingStartTime: 0 }, - null, - ); - - expect(completedWorkflow.elapsedMs).toBeNull(); - expect(missingStart.elapsedMs).toBeNull(); + expect(codes.has(RAINBOW_CYAN)).toBe(false); + expect(codes.has(RAINBOW_GREEN)).toBe(false); }); - it('refreshes active elapsed with the existing footer interval and stops when idle', () => { - const onRefresh = vi.fn(); - const footer = new FooterComponent( - { ...appState, streamingPhase: 'waiting' }, - onRefresh, - ); - - vi.advanceTimersByTime(3_000); - expect(onRefresh).toHaveBeenCalledTimes(3); - - footer.syncAppState({ ...appState, streamingPhase: 'idle' }); - vi.advanceTimersByTime(2_000); - expect(onRefresh).toHaveBeenCalledTimes(3); - - footer.dispose(); + it('repaints from the active palette on the next render (no setColors needed)', () => { + const footer = new FooterComponent(appState); + const before = footer.render(120).join('\n'); + + currentTheme.setPalette(lightColors); + try { + const after = footer.render(120).join('\n'); + // Reads currentTheme live, so a palette swap changes the emitted colours. + expect(after).not.toBe(before); + } finally { + currentTheme.setPalette(darkColors); + } }); - it('uses one footer freshness interval when goal and elapsed are active', () => { - const onRefresh = vi.fn(); - const footer = new FooterComponent( - { ...appState, goal: activeGoal, streamingPhase: 'waiting' }, - onRefresh, - ); - - vi.advanceTimersByTime(10_000); - expect(onRefresh).toHaveBeenCalledTimes(10); + it('shows the effort for an effort-capable model', () => { + const effortModel: ModelAlias = { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }; + const state: AppState = { + ...appState, + thinkingEffort: 'max', + availableModels: { 'kimi-k2': effortModel }, + }; + const footer = new FooterComponent(state); - footer.dispose(); - vi.advanceTimersByTime(1_000); - expect(onRefresh).toHaveBeenCalledTimes(10); + expect(footer.render(120).join('\n')).toContain('thinking: max'); }); - it('keeps the shared timer only while a visible goal or elapsed display needs it', () => { - const onRefresh = vi.fn(); - const activeState = { + it('does not show the effort for a legacy boolean model', () => { + const plainModel: ModelAlias = { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + capabilities: ['thinking'], + }; + const state: AppState = { ...appState, - goal: activeGoal, - streamingPhase: 'waiting' as const, + thinkingEffort: 'high', + availableModels: { 'kimi-k2': plainModel }, }; - const footer = new FooterComponent(activeState, onRefresh); - - vi.advanceTimersByTime(1_000); - expect(onRefresh).toHaveBeenCalledTimes(1); - - footer.syncAppState({ - ...activeState, - statusLine: statusLine({ showGoal: false }), - }); - vi.advanceTimersByTime(1_000); - expect(onRefresh).toHaveBeenCalledTimes(2); - - footer.syncAppState({ - ...activeState, - statusLine: statusLine({ showElapsed: false }), - }); - vi.advanceTimersByTime(1_000); - expect(onRefresh).toHaveBeenCalledTimes(3); - - footer.syncAppState({ - ...activeState, - statusLine: statusLine({ showGoal: false, showElapsed: false }), - }); - vi.advanceTimersByTime(2_000); - expect(onRefresh).toHaveBeenCalledTimes(3); - - footer.syncAppState({ - ...activeState, - statusLine: statusLine({ showGoal: false }), - }); - vi.advanceTimersByTime(1_000); - expect(onRefresh).toHaveBeenCalledTimes(4); - - footer.dispose(); - }); - - it('keeps hidden goal and background badges out of footer actions and selection', () => { - const footer = new FooterComponent({ ...appState, goal: activeGoal }); - footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - - expect(footer.actionItems().map((item) => item.id)).toEqual([ - 'goal', - 'shell-tasks', - 'agents', - ]); - footer.selectFirst(); - expect(footer.selectedActionId()).toBe('goal'); + const footer = new FooterComponent(state); + const rendered = footer.render(120).join('\n'); - footer.syncAppState({ - ...appState, - goal: activeGoal, - statusLine: statusLine({ showGoal: false }), - }); - expect(footer.selectedActionId()).toBeNull(); - expect(footer.actionItems().map((item) => item.id)).toEqual([ - 'shell-tasks', - 'agents', - ]); - - footer.selectFirst(); - expect(footer.selectedActionId()).toBe('shell-tasks'); - footer.syncAppState({ - ...appState, - goal: activeGoal, - statusLine: statusLine({ - showGoal: false, - showBackgroundTasks: false, - }), - }); - expect(footer.selectedActionId()).toBeNull(); - expect(footer.actionItems()).toEqual([]); - - footer.dispose(); + expect(rendered).toContain('thinking'); + expect(rendered).not.toContain('thinking:high'); }); +}); - it('keeps fallback transient hints visible when every status item is hidden', () => { - const footer = new FooterComponent({ +describe('FooterComponent overrides', () => { + it('shows the overridden effort list', () => { + const effortModelWithOverride: ModelAlias = { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + overrides: { supportEfforts: ['low', 'high'], defaultEffort: 'high' }, + }; + const state: AppState = { ...appState, - statusLine: { - showModel: false, - showEffort: false, - showTokenSpeed: false, - showContextBar: false, - showGit: false, - showModes: false, - showElapsed: false, - showGoal: false, - showBackgroundTasks: false, - }, - }); - footer.setTransientHint('Press Ctrl-C again to exit'); - - const rows = footer.render(40).map(stripAnsi); - expect(rows).toEqual(['Press Ctrl-C again to exit']); - expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true); + thinkingEffort: 'high', + availableModels: { 'kimi-k2': effortModelWithOverride }, + }; + const footer = new FooterComponent(state); - footer.dispose(); + expect(footer.render(120).join('\n')).toContain('thinking: high'); }); +}); - it('does not query a retained Git cache after visibility turns off', () => { - const getStatus = vi.fn(() => null); - gitMocks.createGitStatusCache.mockImplementationOnce(() => ({ getStatus })); +describe('FooterComponent displayName override', () => { + it('renders the overridden display name', () => { const state: AppState = { ...appState, - statusLine: statusLine({ showGit: true }), + model: 'kimi-k2', + availableModels: { + 'kimi-k2': { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 262144, + displayName: 'Remote Name', + overrides: { displayName: 'Custom Name' }, + }, + }, }; const footer = new FooterComponent(state); - expect(footer.getGitStatus()).toBeNull(); - const visibleQueryCount = getStatus.mock.calls.length; - expect(visibleQueryCount).toBeGreaterThan(0); + expect(footer.render(120).join('\n')).toContain('Custom Name'); + expect(footer.render(120).join('\n')).not.toContain('Remote Name'); + }); +}); + +describe('FooterComponent line-2 hints', () => { + function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + + it('shows the warning hint on line 2', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); - state.statusLine.showGit = false; - expect(footer.getGitStatus()).toBeNull(); - expect(getStatus).toHaveBeenCalledTimes(visibleQueryCount); + const line2 = stripAnsi(footer.render(120)[1] ?? ''); - footer.dispose(); + expect(line2).toContain('Goal objective is too long'); }); - it('avoids Git cache work while hidden and recreates usable status when re-enabled', () => { - const onRefresh = vi.fn(); - gitMocks.createGitStatusCache.mockImplementation( - (workDir, options: { readonly onChange?: () => void } = {}) => { - if (options.onChange !== undefined) { - gitMocks.onChangeCallbacks.push(options.onChange); - } - return { - getStatus: () => ({ - branch: workDir.endsWith('second') ? 'second' : 'main', - dirty: false, - ahead: 0, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - pullRequest: null, - }), - }; - }, - ); - const hiddenState = { - ...appState, - statusLine: statusLine({ showGit: false }), - }; - const footer = new FooterComponent(hiddenState, onRefresh); - - expect(gitMocks.createGitStatusCache).not.toHaveBeenCalled(); - expect(footer.getGitStatus()).toBeNull(); - - footer.syncAppState({ - ...hiddenState, - statusLine: statusLine({ showGit: true }), - }); - expect(gitMocks.createGitStatusCache).toHaveBeenCalledTimes(1); - expect(footer.getGitStatus()?.branch).toBe('main'); - - footer.syncAppState({ - ...hiddenState, - workDir: '/tmp/second', - statusLine: statusLine({ showGit: true }), - }); - expect(gitMocks.createGitStatusCache).toHaveBeenCalledTimes(2); - expect(footer.getGitStatus()?.branch).toBe('second'); - - const staleOnChange = gitMocks.onChangeCallbacks.at(-1); - footer.syncAppState(hiddenState); - onRefresh.mockClear(); - staleOnChange?.(); - expect(onRefresh).not.toHaveBeenCalled(); - expect(footer.getGitStatus()).toBeNull(); - - footer.dispose(); + it('gives the transient hint precedence, then restores the warning hint', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); + + footer.setTransientHint('Press Ctrl+C again to exit'); + expect(stripAnsi(footer.render(120)[1] ?? '')).toContain('Press Ctrl+C again to exit'); + expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); + + footer.setTransientHint(null); + expect(stripAnsi(footer.render(120)[1] ?? '')).toContain('Goal objective is too long'); + }); + + it('clears the warning hint with null', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); + footer.setWarningHint(null); + + expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); }); }); diff --git a/apps/pythinker-code/test/tui/components/chrome/gutter-container.test.ts b/apps/pythinker-code/test/tui/components/chrome/gutter-container.test.ts index c26e97a1..fd6b5ef1 100644 --- a/apps/pythinker-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/gutter-container.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { GutterContainer } from '#/tui/components/chrome/gutter-container'; @@ -54,4 +54,15 @@ describe('GutterContainer', () => { c.addChild(new FakeChild(() => [colored])); expect(c.render(20)).toEqual([` ${colored}`]); }); + + it('keeps a leading OSC 133 zone marker at byte 0, before the gutter', () => { + const c = new GutterContainer(2, 2); + const marked = `\x1b]133;A\x07content`; + const doubleMarked = `\x1b]133;B\x07\x1b]133;C\x07last`; + c.addChild(new FakeChild(() => [marked, doubleMarked])); + expect(c.render(20)).toEqual([ + `\x1b]133;A\x07 content`, + `\x1b]133;B\x07\x1b]133;C\x07 last`, + ]); + }); }); diff --git a/apps/pythinker-code/test/tui/components/chrome/moon-loader.test.ts b/apps/pythinker-code/test/tui/components/chrome/moon-loader.test.ts new file mode 100644 index 00000000..0159d1d4 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/chrome/moon-loader.test.ts @@ -0,0 +1,60 @@ +import type { TUI } from '@pymodel/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { + BRAILLE_SPINNER_FRAMES, + BRAILLE_SPINNER_INTERVAL_MS, +} from '#/tui/constant/rendering'; + +// MoonLoader starts a real setInterval in its constructor, so every loader +// created in these tests must be stopped to avoid leaving live timers behind. +const loaders: MoonLoader[] = []; + +function createLoader(): MoonLoader { + const ui = { requestRender() {} } as unknown as TUI; + const loader = new MoonLoader(ui, 'moon'); + loaders.push(loader); + return loader; +} + +afterEach(() => { + for (const loader of loaders) loader.stop(); + loaders.length = 0; + vi.useRealTimers(); +}); + +describe('MoonLoader', () => { + it('keeps the tip out of renderInline so it does not squeeze against the dynamic_workflow progress bar', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const inline = loader.renderInline(); + expect(inline).not.toContain('Tip'); + expect(inline).not.toContain('steer'); + expect(inline.trim().length).toBeGreaterThan(0); + }); + + it('still shows the tip on its own row when width allows', () => { + const loader = createLoader(); + loader.setTip(' · Tip: ctrl+s: steer mid-turn'); + loader.setAvailableWidth(80); + + const row = loader.render(80).join('\n'); + expect(row).toContain('Tip: ctrl+s: steer mid-turn'); + }); + + it('uses the shared Braille mark for the waiting state', () => { + expect(createLoader().renderInline()).toBe('⣷'); + }); + + it('advances through the shared Braille frames', () => { + vi.useFakeTimers(); + const loader = createLoader(); + + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); + + expect(loader.renderInline()).toBe(BRAILLE_SPINNER_FRAMES[1]); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts b/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts deleted file mode 100644 index 897a287c..00000000 --- a/apps/pythinker-code/test/tui/components/chrome/pythinker-logo.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - ANTENNA_SPINNER_FRAMES, - buildLogoHeaderRows, - LOGO_EYES_OPEN, - PYTHINKER_LOGO_LINES, - PYTHINKER_LOGO_WIDTH, - renderPythinkerLogo, - renderPythinkerLogoAntennaRow, - renderPythinkerLogoEyeRow, - renderPythinkerLogoLine, - renderPythinkerLogoWithEyes, -} from '#/tui/components/chrome/pythinker-logo'; - -const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; - -function truecolorCodes(text: string): Set<string> { - const codes = new Set<string>(); - for (const match of text.matchAll(TRUECOLOR_PATTERN)) { - codes.add(`${match[1]},${match[2]},${match[3]}`); - } - return codes; -} - -describe('pythinker logo', () => { - const previousChalkLevel = chalk.level; - - beforeEach(() => { - chalk.level = 3; - }); - - afterEach(() => { - chalk.level = previousChalkLevel; - }); - - it('renders five logo rows with stable plain widths', () => { - const lines = renderPythinkerLogo(); - expect(lines).toHaveLength(5); - for (let index = 0; index < PYTHINKER_LOGO_LINES.length; index++) { - const renderedLine = lines[index]; - const plainLine = PYTHINKER_LOGO_LINES[index]; - if (renderedLine === undefined || plainLine === undefined) { - throw new Error(`Missing logo row ${index}`); - } - expect(visibleWidth(renderedLine)).toBe(visibleWidth(plainLine)); - } - expect(PYTHINKER_LOGO_WIDTH).toBeGreaterThan(0); - }); - - it('uses multiple brand colors from the SVG palette', () => { - const codes = new Set<string>(); - for (let index = 0; index < PYTHINKER_LOGO_LINES.length; index++) { - for (const code of truecolorCodes(renderPythinkerLogoLine(index))) { - codes.add(code); - } - } - expect(codes.size).toBeGreaterThanOrEqual(3); - }); - - it('renders installer-style closed and glance eye phases', () => { - const closed = renderPythinkerLogoEyeRow({ left: 'closed', right: 'open' }); - expect(closed).toContain('─'); - expect(closed).toContain('◉'); - - const glance = renderPythinkerLogoEyeRow({ left: 'glance', right: 'open' }); - expect(glance).toContain('◉'); - expect(visibleWidth(glance)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[3])); - }); - - it('renders antenna spinner frames at stable width', () => { - for (let frame = 0; frame < ANTENNA_SPINNER_FRAMES.length; frame++) { - const row = renderPythinkerLogoAntennaRow(frame); - expect(row).toContain(ANTENNA_SPINNER_FRAMES[frame]); - expect(visibleWidth(row)).toBe(visibleWidth(PYTHINKER_LOGO_LINES[0])); - } - const lines = renderPythinkerLogoWithEyes(LOGO_EYES_OPEN, 2); - expect(lines).toHaveLength(5); - expect(lines[0]).toContain(ANTENNA_SPINNER_FRAMES[2]); - }); - - it('keeps logo width stable across eye blink states', () => { - const lines = renderPythinkerLogoWithEyes({ left: 'open-shine', right: 'closed' }); - expect(lines).toHaveLength(5); - for (let index = 0; index < PYTHINKER_LOGO_LINES.length; index++) { - const renderedLine = lines[index]; - const plainLine = PYTHINKER_LOGO_LINES[index]; - if (renderedLine === undefined || plainLine === undefined) { - throw new Error(`Missing logo row ${index}`); - } - expect(visibleWidth(renderedLine)).toBe(visibleWidth(plainLine)); - } - }); - - it('anchors welcome copy to the logo face rows', () => { - const plain = buildLogoHeaderRows( - 40, - { - eyebrow: '', - title: 'Welcome headline', - tagline: 'Review · Secure · Diagnose', - prompt: 'Type /help for commands.', - }, - (index) => renderPythinkerLogoLine(index), - ).map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '')); - - expect(plain[0]).not.toContain('Welcome headline'); - expect(plain[1]).not.toContain('Welcome headline'); - expect(plain[2]).toContain('Welcome headline'); - expect(plain[3]).toContain('Review · Secure · Diagnose'); - expect(plain[4]).toContain('/help'); - }); - - it('keeps eyebrow-led headers starting on the antenna row', () => { - const plain = buildLogoHeaderRows( - 40, - { - eyebrow: 'PYTHINKER CODE', - title: 'Server ready', - tagline: 'Local web UI is available.', - prompt: '', - }, - (index) => renderPythinkerLogoLine(index), - ).map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '')); - - expect(plain[0]).toContain('PYTHINKER CODE'); - expect(plain[1]).toContain('Server ready'); - expect(plain[2]).toContain('Local web UI is available.'); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/chrome/transcript-container.test.ts b/apps/pythinker-code/test/tui/components/chrome/transcript-container.test.ts deleted file mode 100644 index 3631ae65..00000000 --- a/apps/pythinker-code/test/tui/components/chrome/transcript-container.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { Component } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; - -import { - TranscriptContainer, - type TranscriptChildMetadata, -} from '#/tui/components/chrome/transcript-container'; - -class StubLines implements Component { - constructor(private readonly lines: readonly string[]) {} - - render(): string[] { - return [...this.lines]; - } - - invalidate(): void {} -} - -const durable: TranscriptChildMetadata = { - role: 'durable', - edgeBlankPolicy: 'trim-plain', -}; - -const ephemeral: TranscriptChildMetadata = { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', -}; - -describe('TranscriptContainer', () => { - it('trims opted-in edge blanks and inserts one durable separator', () => { - const container = new TranscriptContainer(2, 2); - const first = new StubLines(['', 'first', '']); - const second = new StubLines(['', 'second', '']); - - container.addTranscriptChild(first, durable); - container.addTranscriptChild(second, durable); - - expect(container.render(20)).toEqual([' first', ' ', ' second']); - expect(container.children).toEqual([first, second]); - expect(container.renderedRowsAfterChild(20, first)).toBe(2); - }); - - it('preserves ANSI blank rows and does not invent gaps around ephemeral children', () => { - const container = new TranscriptContainer(1, 1); - const first = new StubLines(['', '\u001B[48;5;1m \u001B[0m', 'first', '']); - const status = new StubLines(['status']); - const second = new StubLines(['', 'second']); - - container.addTranscriptChild(first, durable); - container.addTranscriptChild(status, ephemeral); - container.addTranscriptChild(second, durable); - expect(container.render(20)).toEqual([ - ' \u001B[48;5;1m \u001B[0m', - ' first', - ' status', - ' second', - ]); - expect(container.renderedRowsAfterChild(20, first)).toBe(2); - }); - - it('skips empty durable segments when placing separators', () => { - const container = new TranscriptContainer(1, 1); - const first = new StubLines(['first']); - const empty = new StubLines([]); - const second = new StubLines(['second']); - - container.addTranscriptChild(first, durable); - container.addTranscriptChild(empty, { - role: 'live-durable', - edgeBlankPolicy: 'trim-plain', - }); - container.addTranscriptChild(second, durable); - - expect(container.render(20)).toEqual([' first', ' ', ' second']); - }); - - it('matches rows after an invisible child to visible separator state', () => { - const container = new TranscriptContainer(1, 1); - const status = new StubLines(['status']); - const empty = new StubLines([]); - const second = new StubLines(['second']); - - container.addTranscriptChild(status, ephemeral); - container.addTranscriptChild(empty, durable); - container.addTranscriptChild(second, durable); - - expect(container.render(20)).toEqual([' status', ' second']); - expect(container.renderedRowsAfterChild(20, empty)).toBe(1); - }); - - it('keeps unregistered policy out of normalization by requiring explicit metadata', () => { - const container = new TranscriptContainer(0, 0); - const child = new StubLines(['', 'status', '']); - - expect(() => container.addChild(child)).toThrow(/addTranscriptChild/u); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/chrome/transcript-viewport.test.ts b/apps/pythinker-code/test/tui/components/chrome/transcript-viewport.test.ts deleted file mode 100644 index b6e58287..00000000 --- a/apps/pythinker-code/test/tui/components/chrome/transcript-viewport.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { type Component, Container, type Terminal } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; - -import { TranscriptViewport, stripAnsi } from '#/tui/components/chrome/transcript-viewport'; -import { ViewportLayoutRoot } from '#/tui/components/chrome/viewport-layout'; - -/** Minimal component emitting pre-baked lines (real transcript rows are - * not width-padded, unlike pi-tui's Text). */ -class StubLines implements Component { - constructor(private readonly lines: readonly string[]) {} - render(): string[] { - return [...this.lines]; - } - invalidate(): void {} -} - -function makeContainer(lineCount: number): Container { - const container = new Container(); - container.addChild( - new StubLines(Array.from({ length: lineCount }, (_, i) => `line-${String(i + 1)}`)), - ); - return container; -} - -function makeViewport(lineCount: number, height: number): TranscriptViewport { - const viewport = new TranscriptViewport(makeContainer(lineCount)); - viewport.setHeight(height); - return viewport; -} - -describe('TranscriptViewport', () => { - it('emits exactly `height` lines, padding short content at the bottom', () => { - const viewport = makeViewport(2, 5); - const lines = viewport.render(40); - expect(lines).toHaveLength(5); - expect(lines[0]).toContain('line-1'); - expect(lines[1]).toContain('line-2'); - expect(lines.slice(2)).toEqual(['', '', '']); - }); - - it('pins to the tail when content overflows the region', () => { - const viewport = makeViewport(10, 4); - const lines = viewport.render(40); - expect(lines.map(stripAnsi)).toEqual(['line-7', 'line-8', 'line-9', 'line-10']); - expect(viewport.isPinned()).toBe(true); - }); - - it('scrolls up by wheel deltas and clamps at the top', () => { - const viewport = makeViewport(10, 4); - viewport.render(40); - viewport.scrollBy(3); - expect(viewport.getScrollOffset()).toBe(3); - const scrolled = viewport.render(40).map(stripAnsi); - expect(scrolled.slice(0, 3)).toEqual(['line-4', 'line-5', 'line-6']); - expect(scrolled[3]).toContain('line-7'); - expect(scrolled[3]).toContain('▼ 3 more'); - viewport.scrollBy(100); - expect(viewport.getScrollOffset()).toBe(6); - const topLines = viewport.render(40).map(stripAnsi); - expect(topLines.slice(0, 3)).toEqual(['line-1', 'line-2', 'line-3']); - expect(topLines[3]).toContain('line-4'); - viewport.scrollBy(-100); - expect(viewport.getScrollOffset()).toBe(0); - expect(viewport.isPinned()).toBe(true); - }); - - it('keeps the visible window steady when new content arrives while scrolled up', () => { - const container = makeContainer(10); - const viewport = new TranscriptViewport(container); - viewport.setHeight(4); - viewport.render(40); - viewport.scrollBy(4); - expect(viewport.render(40)[0]).toContain('line-3'); - container.addChild(new StubLines(['line-11'])); - expect(viewport.render(40)[0]).toContain('line-3'); - }); - - it('clamps the scroll offset when the transcript shrinks', () => { - const container = makeContainer(10); - const viewport = new TranscriptViewport(container); - viewport.setHeight(4); - viewport.render(40); - viewport.scrollBy(6); - container.clear(); - container.addChild(new StubLines(['only'])); - const lines = viewport.render(40); - expect(viewport.getScrollOffset()).toBe(0); - expect(lines[0]).toContain('only'); - }); - - it('shows a "N more" chip on the last row while scrolled up', () => { - const viewport = makeViewport(10, 4); - viewport.render(40); - expect(viewport.chipHit(4, 39)).toBe(false); - viewport.scrollBy(3); - const lines = viewport.render(40); - expect(stripAnsi(lines[3] ?? '')).toContain('▼ 3 more'); - expect(viewport.chipHit(4, 39)).toBe(true); - expect(viewport.chipHit(3, 39)).toBe(false); - expect(viewport.chipHit(4, 1)).toBe(false); - viewport.scrollToBottom(); - viewport.render(40); - expect(viewport.chipHit(4, 39)).toBe(false); - }); - - it('maps 1-based screen positions to buffer coordinates', () => { - const viewport = makeViewport(10, 4); - viewport.render(40); - // Pinned: visible window is rows 6..9 of the buffer. - expect(viewport.screenToBuffer(1, 3)).toEqual({ row: 6, col: 2 }); - expect(viewport.screenToBuffer(4, 41)).toEqual({ row: 9, col: 40 }); - expect(viewport.screenToBuffer(0, 1)).toBeUndefined(); - expect(viewport.screenToBuffer(5, 1)).toBeUndefined(); - }); - - it('highlights the selection with inverse video', () => { - const viewport = makeViewport(3, 5); - viewport.render(40); - viewport.setSelection({ row: 0, col: 0 }, { row: 1, col: 4 }); - const lines = viewport.render(40); - expect(lines[0]).toContain('\u001B[7m'); - expect(stripAnsi(lines[0] ?? '')).toBe('line-1'); - }); - - it('extracts selected text in reading order, ANSI-stripped and trimmed', () => { - const container = new Container(); - container.addChild(new StubLines(['\u001B[31mred\u001B[39m plain', 'second line ', 'third'])); - const viewport = new TranscriptViewport(container); - viewport.setHeight(5); - viewport.render(40); - // Drag backwards from row 1 col 6 to row 0 col 4: selection normalizes. - viewport.setSelection({ row: 1, col: 6 }, { row: 0, col: 4 }); - expect(viewport.extractSelectionText()).toBe('plain\nsecond'); - }); - - it('treats a bare click as no selection', () => { - const viewport = makeViewport(2, 5); - viewport.render(40); - viewport.setSelection({ row: 0, col: 2 }, { row: 0, col: 2 }); - expect(viewport.hasSelection()).toBe(false); - expect(viewport.extractSelectionText()).toBe(''); - }); - - it('clamps wide selections to each line width', () => { - const viewport = makeViewport(2, 5); - viewport.render(40); - viewport.setSelection({ row: 0, col: 0 }, { row: 1, col: 500 }); - expect(viewport.extractSelectionText()).toBe('line-1\nline-2'); - }); - - it('stripAnsi removes SGR, private CSI, and OSC hyperlink sequences', () => { - expect(stripAnsi('\u001B[31mred\u001B[39m')).toBe('red'); - expect(stripAnsi('\u001B[?2026hsync\u001B[?2026l')).toBe('sync'); - expect(stripAnsi('\u001B]8;;https://example.com\u0007link\u001B]8;;\u0007')).toBe('link'); - }); -}); - -describe('ViewportLayoutRoot', () => { - function makeRoot(rows: number, transcriptLines: number) { - const viewport = new TranscriptViewport(makeContainer(transcriptLines)); - const chrome = new StubLines(['chrome-a', 'chrome-b']); - const footer = new StubLines(['footer-a']); - const terminal = { rows } as unknown as Terminal; - const root = new ViewportLayoutRoot(terminal, viewport, [chrome], footer); - return { root, viewport }; - } - - it('fills exactly terminal.rows lines: viewport + chrome + footer', () => { - const { root, viewport } = makeRoot(10, 3); - root.setFooterMounted(true); - const lines = root.render(40); - expect(lines).toHaveLength(10); - // 3 chrome/footer lines pin to the bottom; the viewport gets the rest. - expect(viewport.getHeight()).toBe(7); - expect(lines[7]).toBe('chrome-a'); - expect(lines[8]).toBe('chrome-b'); - expect(lines[9]).toBe('footer-a'); - }); - - it('renders the footer only after it is mounted', () => { - const { root, viewport } = makeRoot(10, 3); - const withoutFooter = root.render(40); - // The viewport expands to fill the unmounted footer slot. - expect(withoutFooter).toHaveLength(10); - expect(viewport.getHeight()).toBe(8); - expect(withoutFooter).not.toContain('footer-a'); - root.setFooterMounted(true); - expect(root.render(40).at(-1)).toBe('footer-a'); - }); - - it('places the footer after the chrome lines', () => { - const { root } = makeRoot(12, 3); - root.setFooterMounted(true); - const lines = root.render(40); - expect(lines.at(-3)).toBe('chrome-a'); - expect(lines.at(-2)).toBe('chrome-b'); - expect(lines.at(-1)).toBe('footer-a'); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome-banner.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome-banner.test.ts deleted file mode 100644 index 4b314035..00000000 --- a/apps/pythinker-code/test/tui/components/chrome/welcome-banner.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - buildWelcomeCopy, - buildWelcomeInfoItems, - renderWelcomeBanner, -} from '#/tui/components/chrome/welcome-banner'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import type { AppState } from '#/tui/types'; -import type { GitStatusCache } from '#/utils/git/git-status'; - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -const appState: AppState = { - version: '1.2.3', - workDir: '/tmp/project', - sessionId: 'ses-1', - sessionTitle: null, - model: 'pythinker-k2', - permissionMode: 'manual', - thinkingLevel: 'off', - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - isCompacting: false, - isReplaying: false, - streamingPhase: 'idle', - streamingStartTime: 0, - planMode: false, - dynamicWorkflowMode: false, - theme: 'dark', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - availableModels: {}, - availableProviders: {}, - mcpServersSummary: null, -}; - -describe('renderWelcomeBanner', () => { - const previousChalkLevel = chalk.level; - const previousTerm = process.env['TERM']; - - beforeEach(() => { - chalk.level = 3; - process.env['TERM'] = 'xterm-256color'; - }); - - afterEach(() => { - chalk.level = previousChalkLevel; - if (previousTerm === undefined) delete process.env['TERM']; - else process.env['TERM'] = previousTerm; - }); - - it('renders the Python-style headline and strapline', () => { - const lines = renderWelcomeBanner({ - width: 100, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }); - const joined = lines.join('\n'); - expect(joined).toContain('Welcome to Pythinker — think first, then code.'); - expect(joined).toContain('Review · Secure · Diagnose · Build with confidence.'); - expect(joined).toMatch(/Type .*\/help.* for commands\./); - expect(joined).toContain('Pythinker Code'); - expect(joined).toContain('v1.2.3'); - const topBorder = lines - .find((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').includes('╭')) - ?.replaceAll(/\u001B\[[0-9;]*m/g, ''); - expect(topBorder?.endsWith('╮')).toBe(true); - }); - - it('embeds version in the panel title and facts without a Version row', () => { - const lines = renderWelcomeBanner({ - width: 100, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }); - const joined = lines.join('\n'); - expect(joined).toContain('v1.2.3'); - expect(joined).not.toMatch(/Version:/); - }); - - it('keeps the stacked layout on narrow terminals', () => { - const plain = renderWelcomeBanner({ - width: 60, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }).map(stripAnsi); - - const logoRowIndex = plain.findIndex((line) => line.includes('●') && line.includes('│')); - const welcomeRowIndex = plain.findIndex( - (line) => line.includes('Welcome to Pythinker') && line.includes('│'), - ); - expect(logoRowIndex).toBeGreaterThanOrEqual(0); - expect(welcomeRowIndex).toBeGreaterThan(logoRowIndex); - expect(plain[welcomeRowIndex]).not.toMatch(/●.*Welcome to Pythinker/); - expect(plain[welcomeRowIndex]).not.toContain('Tips'); - expect(plain.some((line) => line.includes('Review · Secure · Diagnose'))).toBe(true); - expect(plain.some((line) => line.includes('/help'))).toBe(true); - }); - - it('starts the tips column beside the welcome copy on wide terminals', () => { - const plain = renderWelcomeBanner({ - width: 120, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }).map(stripAnsi); - - const welcomeRow = plain.find((line) => line.includes('Welcome to Pythinker')); - expect(welcomeRow).toBeDefined(); - expect(welcomeRow).toMatch(/Welcome to Pythinker[^│]*│[^│]*Tips/); - expect(plain.some((line) => line.includes('/help'))).toBe(true); - }); - - it('orders facts and right-aligns their labels in the wide left column', () => { - const gitCache: GitStatusCache = { - getStatus: () => ({ - branch: 'main', - dirty: false, - ahead: 0, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - pullRequest: null, - }), - }; - const plain = renderWelcomeBanner({ - width: 120, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, gitCache), - copy: buildWelcomeCopy(false), - asciiMode: false, - }).map(stripAnsi); - - const labels = ['Directory', 'Branch', 'Model', 'Session', 'Auto-save']; - const rows = labels.map((label) => plain.findIndex((line) => line.includes(label))); - expect(rows.every((row) => row >= 0)).toBe(true); - for (let index = 1; index < rows.length; index++) { - expect(rows[index]).toBeGreaterThan(rows[index - 1]!); - } - - const directoryRow = plain[rows[0]!]!; - const branchRow = plain[rows[1]!]!; - expect(directoryRow.indexOf('Directory') + 'Directory'.length).toBe( - branchRow.indexOf('Branch') + 'Branch'.length, - ); - }); - - it('aligns panel borders on every row', () => { - for (const width of [80, 100, 120]) { - const plain = renderWelcomeBanner({ - width, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }).map(stripAnsi); - - const panelLines = plain.filter((line) => line.includes('│') || line.includes('╭') || line.includes('╰')); - for (const line of panelLines) { - expect(visibleWidth(line)).toBe(width); - } - } - }); - - it('keeps every line within the requested width', () => { - for (const width of [0, 1, 2, 4, 10, 39, 80, 100]) { - for (const line of renderWelcomeBanner({ - width, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - })) { - expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(width, 0)); - } - } - }); - - it('uses multiple brand colors on the robot mark', () => { - const lines = renderWelcomeBanner({ - width: 100, - version: appState.version, - infoItems: buildWelcomeInfoItems(appState, null), - copy: buildWelcomeCopy(false), - asciiMode: false, - }); - const logoLine = lines.find((line) => line.includes('●')); - expect(logoLine).toBeDefined(); - expect(logoLine).toMatch(/\u001B\[38;2;\d+;\d+;\d+m/); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts deleted file mode 100644 index 110de8e1..00000000 --- a/apps/pythinker-code/test/tui/components/chrome/welcome-eye-animation.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { - WELCOME_ANTENNA_SPIN_DURATION_MS, - WELCOME_ANTENNA_SPIN_TICK_MS, - WELCOME_BLINK_INTERVAL_MS, - WelcomeLogoAnimator, - welcomeLogoAnimationEnabled, -} from '#/tui/components/chrome/welcome-logo-animation'; -import { LOGO_EYES_OPEN } from '#/tui/components/chrome/pythinker-logo'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { setRainbowColors } from '#/tui/easter-eggs/rainbow-colors'; -import { currentTheme } from '#/tui/theme'; -import { darkColors } from '#/tui/theme/colors'; -import type { AppState } from '#/tui/types'; - -const appState: AppState = { - version: '1.2.3', - workDir: '/tmp/project', - sessionId: 'ses-1', - sessionTitle: null, - model: 'pythinker-k2', - permissionMode: 'manual', -thinkingLevel: 'off', - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - isCompacting: false, - isReplaying: false, - streamingPhase: 'idle', - streamingStartTime: 0, - planMode: false, - dynamicWorkflowMode: false, - theme: 'dark', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - availableModels: {}, - availableProviders: {}, - mcpServersSummary: null, -}; - -describe('WelcomeComponent eye animation', () => { - const previousTerm = process.env['TERM']; - const previousNoAnim = process.env['PYTHINKER_NO_ANIMATION']; - - beforeEach(() => { - vi.useFakeTimers(); - process.env['TERM'] = 'xterm-256color'; - delete process.env['PYTHINKER_NO_ANIMATION']; - delete process.env['CI']; - delete process.env['NO_COLOR']; - setRainbowColors(undefined); - currentTheme.setPalette(darkColors); - }); - - afterEach(() => { - vi.useRealTimers(); - setRainbowColors(undefined); - if (previousTerm === undefined) delete process.env['TERM']; - else process.env['TERM'] = previousTerm; - if (previousNoAnim === undefined) delete process.env['PYTHINKER_NO_ANIMATION']; - else process.env['PYTHINKER_NO_ANIMATION'] = previousNoAnim; - }); - - it('runs the installer blink cadence on the animator', () => { - expect(welcomeLogoAnimationEnabled()).toBe(true); - const states: string[] = []; - const host = { - setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { - states.push(`${state.left}:${state.right}`); - }, - setAntennaFrame(_frame: number | null) {}, - }; - const animator = new WelcomeLogoAnimator(host, () => {}); - animator.start(); - - expect(states[0]).toBe('glance:open'); - vi.advanceTimersByTime(540); - expect(states).toContain('closed:open'); - expect(states).toContain('open-shine:open'); - vi.advanceTimersByTime(600); - expect(states.at(-1)).toBe('open:open'); - animator.dispose(); - }); - - it('repeats the blink every 5 seconds', () => { - const states: string[] = []; - const host = { - setEyeBlinkState(state: typeof LOGO_EYES_OPEN) { - states.push(`${state.left}:${state.right}`); - }, - setAntennaFrame(_frame: number | null) {}, - }; - const animator = new WelcomeLogoAnimator(host, () => {}); - animator.start(); - - const glanceCount = () => states.filter((s) => s === 'glance:open').length; - vi.advanceTimersByTime(1140); // one full two-eye blink sequence - expect(states.at(-1)).toBe('open:open'); - expect(glanceCount()).toBe(1); - - vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS); - expect(glanceCount()).toBe(2); - - vi.advanceTimersByTime(1140 + WELCOME_BLINK_INTERVAL_MS); - expect(glanceCount()).toBe(3); - animator.dispose(); - - const afterDispose = glanceCount(); - vi.advanceTimersByTime(WELCOME_BLINK_INTERVAL_MS + 2000); - expect(glanceCount()).toBe(afterDispose); - }); - - it('spins the antenna for six seconds on startup, then restores the bulb', () => { - const frames: (number | null)[] = []; - const host = { - setEyeBlinkState(_state: typeof LOGO_EYES_OPEN) {}, - setAntennaFrame(frame: number | null) { - frames.push(frame); - }, - }; - const animator = new WelcomeLogoAnimator(host, () => {}); - animator.start(); - - expect(frames[0]).toBe(0); - vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_TICK_MS * 3); - expect(frames).toEqual([0, 1, 2, 3]); - - vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); - expect(frames.at(-1)).toBeNull(); - - const settled = frames.length; - vi.advanceTimersByTime(WELCOME_ANTENNA_SPIN_DURATION_MS); - expect(frames.length).toBe(settled); - animator.dispose(); - }); - - it('plays the installer blink sequence on startup', async () => { - let renderCount = 0; - let lastOutput = ''; - const welcome = new WelcomeComponent(appState, () => { - renderCount++; - lastOutput = welcome.render(100).join('\n'); - }); - - await Promise.resolve(); - expect(renderCount).toBeGreaterThan(0); - - await vi.advanceTimersByTimeAsync(1100); - const joined = welcome.render(100).join('\n'); - const faceLine = joined.split('\n').find((line) => line.includes('◖')); - expect(faceLine).toBeDefined(); - expect(faceLine).toMatch(/◉.*◉/); - - welcome.dispose(); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts index f4abb2b0..b6bb8a33 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts @@ -1,13 +1,10 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - setRainbowColors, - type RainbowColorController, -} from '#/tui/easter-eggs/rainbow-colors'; +import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; +import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; @@ -15,11 +12,12 @@ const TRUECOLOR_PATTERN = /\u001B\[38;2;(\d+);(\d+);(\d+)m/g; const appState: AppState = { version: '1.2.3', workDir: '/tmp/project', + additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, - model: 'pythinker-k2', + model: 'kimi-k2', permissionMode: 'manual', - thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -27,13 +25,14 @@ const appState: AppState = { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, planMode: false, + inputMode: 'prompt', dynamicWorkflowMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, availableProviders: {}, mcpServersSummary: null, @@ -47,60 +46,51 @@ function truecolorCodes(text: string): Set<string> { return codes; } -/** Header rows that contain the logo or welcome title. */ +/** The two header rows (logo + title) of the rendered welcome box. */ function headerOf(lines: string[]): string { - return lines - .filter( - (line) => line.includes('●') || line.includes('Welcome to Pythinker — think first, then code.'), - ) - .join('\n'); + return [lines[3], lines[4]].join('\n'); } -function setColorsView(colored: boolean, phase: number): void { - const colors: RainbowColorController = { +function setDanceView(colored: boolean, phase: number): void { + const dance: RainbowDanceController = { colored, phase, start: () => {}, stop: () => {}, dispose: () => {}, }; - setRainbowColors(colors); + setRainbowDance(dance); } describe('WelcomeComponent', () => { const previousChalkLevel = chalk.level; - const previousTerm = process.env['TERM']; beforeEach(() => { chalk.level = 3; - process.env['TERM'] = 'xterm-256color'; }); afterEach(() => { chalk.level = previousChalkLevel; - if (previousTerm === undefined) delete process.env['TERM']; - else process.env['TERM'] = previousTerm; - setRainbowColors(undefined); + setRainbowDance(undefined); }); - it('renders the banner with brand palette colors by default', () => { + it('renders the banner in a single brand color by default', () => { const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); - // Multi-color SVG palette on the logo, but no rainbow flow. - expect(codes.size).toBeGreaterThanOrEqual(3); - expect(codes.size).toBeLessThan(8); + // No rainbow by default — just the brand primary (plus the dim tagline). + expect(codes.size).toBeLessThanOrEqual(2); }); - it('paints the banner in rainbow while colors are active', () => { - setColorsView(true, 0); - const codes = truecolorCodes(new WelcomeComponent(appState).render(80).join('\n')); + it('paints the banner in rainbow while colored', () => { + setDanceView(true, 0); + const codes = truecolorCodes(headerOf(new WelcomeComponent(appState).render(80))); expect(codes.size).toBeGreaterThanOrEqual(5); }); - it('renders exactly the default banner when colors are inactive', () => { + it('renders exactly the default banner when not colored', () => { const base = headerOf(new WelcomeComponent(appState).render(80)); - setColorsView(false, 5); + setDanceView(false, 5); const off = headerOf(new WelcomeComponent(appState).render(80)); expect(off).toBe(base); diff --git a/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts new file mode 100644 index 00000000..4d65c46a --- /dev/null +++ b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -0,0 +1,315 @@ +import type { Terminal } from '@pymodel/pi-tui'; +import type { BackgroundTaskInfo } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { AgentActivityViewer, formatSubagentActivityPreview } from '#/tui/components/dialogs/agent-activity-viewer'; +import type { SubagentActivityRecord } from '#/tui/controllers/subagent-activity-store'; + +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +/** Kitty CSI-u form of Ctrl+O (codepoint 111, modifier 1+4). */ +const CTRL_O = '\u001B[111;5u'; + +/** Minimal Terminal stub — only `rows` is read by the component. */ +function fakeTerminal(rows: number, columns = 120): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: () => Promise.resolve(), + write: () => {}, + get columns() { + return columns; + }, + get rows() { + return rows; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function agentTask(overrides: Record<string, unknown> = {}): BackgroundTaskInfo { + return { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent-1', + description: 'find things', + status: 'running', + startedAt: Date.now() - 60_000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +function record(overrides: Partial<SubagentActivityRecord> = {}): SubagentActivityRecord { + return { + agentId: 'agent-1', + agentName: 'explore', + description: 'find things', + parentToolCallId: 'tc-1', + steps: [], + totalSteps: 0, + status: 'running', + version: 1, + ...overrides, + }; +} + +function makeViewer( + props: Partial<Parameters<typeof AgentActivityViewer.prototype.setProps>[0]> & { + record?: SubagentActivityRecord; + } = {}, + rows = 20, + columns = 80, +): AgentActivityViewer { + return new AgentActivityViewer( + { + taskId: 'agent-task-1', + info: agentTask(), + record: props.record, + onClose: vi.fn(), + ...props, + }, + fakeTerminal(rows, columns), + ); +} + +function renderPlain(viewer: AgentActivityViewer, width = 80): string { + return strip(viewer.render(width).join('\n')); +} + +describe('AgentActivityViewer', () => { + it('fills exactly terminal.rows lines', () => { + const viewer = makeViewer({}, 20); + expect(viewer.render(80).length).toBe(20); + }); + + it('shows agent label, status and step range in the header', () => { + const viewer = makeViewer({ + record: record({ + steps: [ + { step: 8, textTail: '', toolCalls: [] }, + { step: 9, textTail: '', toolCalls: [] }, + ], + totalSteps: 12, + }), + }); + const text = renderPlain(viewer, 120); + expect(text).toContain('Agent activity'); + expect(text).toContain('explore › find things'); + expect(text).toContain('running'); + expect(text).toContain('step 8–9 / 12'); + expect(text).toContain('earlier steps discarded'); + }); + + it('renders steps with tool call headers and result renderer output', () => { + const viewer = makeViewer({ + record: record({ + steps: [ + { + step: 0, + textTail: 'Looking for the event bus definition.', + toolCalls: [ + { + id: 't1', + name: 'Grep', + args: { pattern: 'IEventBus' }, + status: 'done', + startedAt: 0, + result: { + tool_call_id: 't1', + output: 'src/a.ts:1:IEventBus\nsrc/b.ts:2:IEventBus', + is_error: false, + }, + }, + ], + }, + ], + totalSteps: 1, + }), + }); + const text = renderPlain(viewer); + expect(text).toContain('── step 0 ──'); + expect(text).toContain('Looking for the event bus definition.'); + expect(text).toContain('Used Grep (IEventBus) · 2 matches'); + // grep glance renderer: path samples below the header (`path:line` form) + expect(text).toContain('src/a.ts:1, src/b.ts:2'); + }); + + it('collapses long output by default and expands it with ctrl+o', () => { + const longOutput = Array.from({ length: 10 }, (_, i) => `line ${String(i + 1)}`).join('\n'); + const makeRecord = (): SubagentActivityRecord => + record({ + steps: [ + { + step: 0, + textTail: '', + toolCalls: [ + { + id: 't1', + name: 'Bash', + args: { command: 'ls' }, + status: 'done', + startedAt: 0, + result: { tool_call_id: 't1', output: longOutput, is_error: false }, + }, + ], + }, + ], + totalSteps: 1, + }); + + const collapsed = makeViewer({ record: makeRecord() }); + const collapsedText = renderPlain(collapsed); + expect(collapsedText).toContain('ctrl+o to expand'); + expect(collapsedText).not.toContain('line 10'); + + collapsed.handleInput(CTRL_O); + const expandedText = renderPlain(collapsed); + expect(expandedText).toContain('line 10'); + }); + + it('opens pinned to the latest activity and keeps scroll position when the user scrolled up', () => { + const steps = Array.from({ length: 8 }, (_, i) => ({ + step: i, + textTail: `step ${String(i)} text`, + toolCalls: [], + })); + const rec = record({ steps, totalSteps: 8 }); + const viewer = makeViewer({ record: rec }, 12); + + // Initial render follows the tail: the last step is visible. + expect(renderPlain(viewer)).toContain('step 7 text'); + + // User scrolls to the top, then new activity arrives (version bump): + // the view must stay where the user parked it. + viewer.handleInput('g'); + expect(renderPlain(viewer)).toContain('step 0 text'); + rec.steps.push({ step: 8, textTail: 'step 8 text', toolCalls: [] }); + rec.version += 1; + viewer.setProps({ taskId: 'agent-task-1', info: agentTask(), record: rec, onClose: vi.fn() }); + const after = renderPlain(viewer); + expect(after).toContain('step 0 text'); + expect(after).not.toContain('step 8 text'); + }); + + it('shows an explicit empty state when no record exists', () => { + const viewer = makeViewer({ record: undefined }); + expect(renderPlain(viewer)).toContain('[no activity recorded]'); + }); + + it('renders the terminal result summary section', () => { + const viewer = makeViewer({ + info: agentTask({ status: 'completed' }), + record: record({ status: 'completed', resultSummary: 'Found 3 call sites.' }), + }); + const text = renderPlain(viewer); + expect(text).toContain('completed'); + expect(text).toContain('Result'); + expect(text).toContain('Found 3 call sites.'); + }); + + it('closes on q and escape', () => { + const onClose = vi.fn(); + const viewer = makeViewer({ record: record(), onClose }); + viewer.handleInput('q'); + expect(onClose).toHaveBeenCalledTimes(1); + viewer.handleInput('\u001B'); + expect(onClose).toHaveBeenCalledTimes(2); + }); +}); + +describe('formatSubagentActivityPreview', () => { + it('renders steps, tool calls and the terminal result as plain text', () => { + const text = formatSubagentActivityPreview( + record({ + status: 'completed', + resultSummary: 'Found 3 call sites.', + totalSteps: 1, + steps: [ + { + step: 0, + textTail: 'Looking around.', + toolCalls: [ + { + id: 't1', + name: 'Grep', + args: { pattern: 'IEventBus' }, + status: 'done', + startedAt: 0, + result: { + tool_call_id: 't1', + output: 'src/a.ts:1:IEventBus\nsrc/b.ts:2:IEventBus', + is_error: false, + }, + }, + { + id: 't2', + name: 'Read', + args: { path: '/repo/src/a.ts' }, + status: 'running', + startedAt: 0, + liveOutputTail: 'reading…', + }, + ], + }, + ], + }), + ); + expect(text).toContain('── step 0 ──'); + expect(text).toContain('Looking around.'); + expect(text).toContain('✓ Used Grep (IEventBus) · 2 matches'); + expect(text).toContain('● Using Read (/repo/src/a.ts)'); + expect(text).toContain('│ reading…'); // live tail for the in-flight call + expect(text).toContain('Result:'); + expect(text).toContain('Found 3 call sites.'); + // The preview frame styles whole lines itself — the preview stays ANSI-free. + expect(text).not.toMatch(/\[[0-9;]*m/); + }); + + it('shows the live output tail for a running call', () => { + const text = formatSubagentActivityPreview( + record({ + totalSteps: 1, + steps: [ + { + step: 0, + textTail: '', + toolCalls: [ + { + id: 't1', + name: 'Bash', + args: { command: 'pnpm test' }, + status: 'running', + startedAt: 0, + liveOutputTail: '42 passing', + }, + ], + }, + ], + }), + ); + expect(text).toContain('● Using Bash (pnpm test)'); + expect(text).toContain('│ 42 passing'); + }); + + it('returns a waiting placeholder for a fresh running record', () => { + expect(formatSubagentActivityPreview(record())).toBe('Waiting for activity…'); + }); + + it('returns an empty string for a terminal record without any activity', () => { + expect(formatSubagentActivityPreview(record({ status: 'failed' }))).toBe(''); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/pythinker-code/test/tui/components/dialogs/api-key-input-dialog.test.ts index 1e193824..ea4a8e6e 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/api-key-input-dialog.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -1,8 +1,7 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; describe('ApiKeyInputDialogComponent', () => { it('keeps every line within narrow widths', () => { @@ -19,90 +18,4 @@ describe('ApiKeyInputDialogComponent', () => { } } }); - - it('lets API key text own printable confirmation bindings and recovers Escape', () => { - const results: unknown[] = []; - const dialog = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => results.push(result)); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { x: 'confirm:next' } }, - ]), - ); - dialog.handleInput('x'); - dialog.handleInput('\r'); - expect(results).toEqual([{ kind: 'ok', value: 'x' }]); - - const cancelled: unknown[] = []; - const recovery = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => cancelled.push(result)); - recovery.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]); - recovery.handleInput('\u001B'); - expect(cancelled).toEqual([{ kind: 'cancel' }]); - }); - - it('treats printable default cancel keys as text', () => { - const results: unknown[] = []; - const dialog = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => results.push(result)); - for (const char of 'anthropic/plugin.json') dialog.handleInput(char); - - expect(dialog.render(120).join('\n')).not.toContain('n / Esc'); - dialog.handleInput('\r'); - - expect(results).toEqual([{ kind: 'ok', value: 'anthropic/plugin.json' }]); - }); - - it('uses an alternate cancel binding while bare Escape preserves API key input', () => { - const bindings = parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'alt+x': 'confirm:no' } }, - ]); - const preserved: unknown[] = []; - const input = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => preserved.push(result)); - input.setKeybindings(bindings); - input.handleInput('d'); - input.handleInput('\u001B'); - input.handleInput('\r'); - expect(preserved).toEqual([{ kind: 'ok', value: 'd' }]); - - const cancelled: unknown[] = []; - const cancelInput = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => cancelled.push(result)); - cancelInput.setKeybindings(bindings); - cancelInput.handleInput('d'); - cancelInput.handleInput('\u001Bx'); - expect(cancelled).toEqual([{ kind: 'cancel' }]); - }); - - it('executes a semantic two-key cancel chord', () => { - const results: unknown[] = []; - const dialog = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => results.push(result)); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+k ctrl+x': 'confirm:no' }, - }, - ]), - ); - dialog.handleInput('d'); - dialog.handleInput('ctrl+k'); - dialog.handleInput('ctrl+x'); - expect(results).toEqual([{ kind: 'cancel' }]); - }); - - it('keeps unavailable printable chords intact in API key input', () => { - const results: unknown[] = []; - const dialog = new ApiKeyInputDialogComponent('Pythinker Code', [], (result) => results.push(result)); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'x y': 'confirm:next' } }, - ]), - ); - dialog.handleInput('x'); - dialog.handleInput('y'); - dialog.handleInput('\r'); - expect(results).toEqual([{ kind: 'ok', value: 'xy' }]); - }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/pythinker-code/test/tui/components/dialogs/approval-panel.test.ts index 8af9e9c7..df785963 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/approval-panel.test.ts @@ -1,8 +1,7 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; import type { DiffDisplayBlock, FileContentDisplayBlock, @@ -62,6 +61,33 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('y/a/n/f'); }); + it('renders choice descriptions beneath the label when present', () => { + const pending: PendingApproval = { + data: { + id: 'approval_goal', + tool_call_id: 'tool_goal', + tool_name: 'CreateGoal', + action: 'Creating a goal', + description: '', + display: [], + choices: [ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: 'Tools are approved automatically, and questions are skipped.', + }, + { label: 'Do not start', response: 'cancelled', selected_label: 'cancel' }, + ], + }, + }; + const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); + expect(out).toContain('1. Switch to Auto and start'); + expect(out).toContain('Tools are approved automatically, and questions are skipped.'); + // A choice without a description stays label-only — no stray blank helper line. + expect(out).toContain('2. Do not start'); + }); + it('renders dangerous shell warnings with simple copy and no icon', () => { const pending: PendingApproval = { data: { @@ -89,115 +115,6 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('⚠'); }); - // The whole point of asking before a Dynamic Workflow is that the operator - // sees the fan-out. A block that reaches the panel and paints nothing would - // still typecheck, so assert the painted lines rather than the payload. - it('paints the Dynamic Workflow plan: counts, prompt size, template and items', () => { - const pending: PendingApproval = { - data: { - id: 'approval_workflow', - tool_call_id: 'tool_workflow', - tool_name: 'DynamicWorkflow', - action: 'run', - description: 'Review the diff', - display: [ - { - type: 'workflow_plan', - agent_count: 3, - items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], - prompt_tokens: 128, - prompt_template: 'Review {{item}} for races', - model: 'claude-sonnet-4', - }, - ], - choices: [{ label: 'Approve once', response: 'approved' }], - }, - }; - - const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); - - expect(out).toContain('3 subagents'); - expect(out).toContain('~128 prompt tokens'); - expect(out).toContain('model: claude-sonnet-4'); - expect(out).toContain('Review {{item}} for races'); - expect(out).toContain('src/a.ts'); - expect(out).toContain('src/c.ts'); - }); - - // The plan is what the operator is judging, and every field in it came from - // the model. Escape sequences left intact could repaint the panel deciding - // their fate — blank a line, redraw the buttons, or reverse the text order. - it('strips terminal control sequences from the plan before painting it', () => { - const esc = String.fromCodePoint(0x1B); - const rtlOverride = String.fromCodePoint(0x202E); - const pending: PendingApproval = { - data: { - id: 'approval_workflow_ansi', - tool_call_id: 'tool_workflow_ansi', - tool_name: 'DynamicWorkflow', - action: 'run', - description: 'Sweep', - display: [ - { - type: 'workflow_plan', - agent_count: 2, - items: [ - `${esc}[2Kharmless-item`, - // OSC 8 hyperlink, and a right-to-left override. - `${esc}]8;;http://evil.example${esc}\\second-item${rtlOverride}`, - ], - prompt_tokens: 12, - prompt_template: `${esc}[31mReview {{item}}`, - model: `${esc}[1msonnet`, - }, - ], - choices: [{ label: 'Approve once', response: 'approved' }], - }, - }; - - const raw = new ApprovalPanelComponent(pending, () => {}).render(80).join('\n'); - const out = strip(raw); - - expect(out).toContain('harmless-item'); - expect(out).toContain('second-item'); - expect(out).toContain('Review {{item}}'); - expect(out).toContain('model: sonnet'); - // `strip` only removes SGR colour codes, so anything else the model smuggled - // in would still be sitting in `out`. - expect(out).not.toContain(`${esc}[2K`); - expect(out).not.toContain(']8;;'); - expect(out).not.toContain('http://evil.example'); - expect(out).not.toContain(rtlOverride); - }); - - it('caps the plan item list and says how many it held back', () => { - const pending: PendingApproval = { - data: { - id: 'approval_workflow_big', - tool_call_id: 'tool_workflow_big', - tool_name: 'DynamicWorkflow', - action: 'run', - description: 'Sweep', - display: [ - { - type: 'workflow_plan', - agent_count: 40, - items: Array.from({ length: 40 }, (_unused, index) => `item-${String(index)}`), - prompt_tokens: 4096, - }, - ], - choices: [{ label: 'Approve once', response: 'approved' }], - }, - }; - - const out = strip(new ApprovalPanelComponent(pending, () => {}).render(80).join('\n')); - - expect(out).toContain('40 subagents'); - expect(out).toContain('item-9'); - expect(out).not.toContain('item-10'); - expect(out).toContain('+30 more'); - }); - it('wraps a long single-line shell command instead of truncating it', () => { const head = 'approve-long-command-head'; const tail = 'approve-long-command-tail'; @@ -238,7 +155,6 @@ describe('ApprovalPanelComponent', () => { it('shortcut 4 enters feedback mode and submits the typed feedback', () => { const { dialog, responses } = makeDialog(); - dialog.setKeybindings(parseKeybindingBlocks([])); dialog.handleInput('4'); dialog.handleInput('n'); dialog.handleInput('o'); @@ -255,186 +171,16 @@ describe('ApprovalPanelComponent', () => { expect(out).not.toContain('\n > '); }); - it('uses default confirmation yes/no bindings while leaving unrelated letters alone', () => { - for (const key of ['a', 'f']) { + it('legacy y/a/n/f shortcuts no longer trigger approval actions', () => { + for (const key of ['y', 'a', 'n', 'f']) { const { dialog, responses } = makeDialog(); dialog.handleInput(key); expect(responses).toEqual([]); } - const accepted = makeDialog(); - accepted.dialog.handleInput('y'); - expect(accepted.responses).toEqual([{ response: 'approved', feedback: undefined }]); - const rejected = makeDialog(); - rejected.dialog.handleInput('n'); - expect(rejected.responses).toEqual([{ response: 'rejected' }]); - }); - - it('uses remapped confirmation navigation, approval, and rejection actions', () => { - const bindings = parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'alt+n': 'confirm:next', - 'alt+p': 'confirm:previous', - 'alt+y': 'confirm:yes', - 'alt+x': 'confirm:no', - }, - }, - ]); - const accepted = makeDialog(); - accepted.dialog.setKeybindings(bindings); - accepted.dialog.handleInput('\u001Bn'); - accepted.dialog.handleInput('\u001By'); - expect(accepted.responses).toEqual([{ response: 'approved_for_session', feedback: undefined }]); - - const rejected = makeDialog(); - rejected.dialog.setKeybindings(bindings); - rejected.dialog.handleInput('\u001Bx'); - expect(rejected.responses).toEqual([{ response: 'rejected' }]); - }); - - it('lets feedback text own a printable remapped navigation key', () => { - const { dialog, responses } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { x: 'confirm:next' } }, - ]), - ); - dialog.handleInput('4'); - dialog.handleInput('x'); - dialog.handleInput('\r'); - expect(responses).toEqual([{ response: 'rejected', feedback: 'x' }]); - }); - - it('recovers bare Escape after default cancel bindings are explicitly removed', () => { - const bindings = [ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]; - const active = makeDialog(); - active.dialog.setKeybindings(bindings); - active.dialog.handleInput('\u001B'); - expect(active.responses).toEqual([{ response: 'rejected' }]); - - const feedback = makeDialog(); - feedback.dialog.setKeybindings(bindings); - feedback.dialog.handleInput('4'); - feedback.dialog.handleInput('\u001B'); - expect(feedback.responses).toEqual([{ response: 'rejected' }]); - }); - - it('uses an alternate cancel binding in feedback mode while bare Escape only exits feedback', () => { - const bindings = parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'alt+x': 'confirm:no' } }, - ]); - const locallyCancelled = makeDialog(); - locallyCancelled.dialog.setKeybindings(bindings); - locallyCancelled.dialog.handleInput('4'); - locallyCancelled.dialog.handleInput('d'); - locallyCancelled.dialog.handleInput('\u001B'); - expect(locallyCancelled.responses).toEqual([]); - locallyCancelled.dialog.handleInput('4'); - locallyCancelled.dialog.handleInput('q'); - locallyCancelled.dialog.handleInput('\r'); - expect(locallyCancelled.responses).toEqual([{ response: 'rejected', feedback: 'q' }]); - - const cancelled = makeDialog(); - cancelled.dialog.setKeybindings(bindings); - cancelled.dialog.handleInput('4'); - cancelled.dialog.handleInput('d'); - cancelled.dialog.handleInput('\u001Bx'); - expect(cancelled.responses).toEqual([{ response: 'rejected' }]); - }); - - it('uses semantic two-key chords in active and feedback modes', () => { - const bindings = parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - enter: 'confirm:yes', - 'ctrl+k ctrl+n': 'confirm:next', - 'ctrl+k ctrl+x': 'confirm:no', - }, - }, - ]); - const active = makeDialog(); - active.dialog.setKeybindings(bindings); - active.dialog.handleInput('ctrl+k'); - active.dialog.handleInput('ctrl+n'); - active.dialog.handleInput('\r'); - expect(active.responses).toEqual([ - { - response: 'approved_for_session', - feedback: undefined, - selected_label: undefined, - }, - ]); - - const feedback = makeDialog(); - feedback.dialog.setKeybindings(bindings); - feedback.dialog.handleInput('4'); - feedback.dialog.handleInput('d'); - feedback.dialog.handleInput('ctrl+k'); - feedback.dialog.handleInput('ctrl+x'); - expect(feedback.responses).toEqual([{ response: 'rejected' }]); - }); - - it('keeps unavailable printable chords intact in feedback input', () => { - const { dialog, responses } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'x y': 'confirm:next' } }, - ]), - ); - dialog.handleInput('4'); - dialog.handleInput('x'); - dialog.handleInput('y'); - dialog.handleInput('\r'); - expect(responses).toEqual([ - { response: 'rejected', feedback: 'xy', selected_label: undefined }, - ]); - }); - - it('keeps unavailable chord prefixes out of active numeric choices', () => { - const { dialog, responses } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { '2 x': 'confirm:nextField' }, - }, - ]), - ); - dialog.handleInput('2'); - expect(responses).toEqual([ - { - response: 'approved_for_session', - feedback: undefined, - selected_label: undefined, - }, - ]); - }); - - it('renders effective feedback cancel hints while retaining local editing hints', () => { - const { dialog } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'alt+x': 'confirm:no' } }, - ]), - ); - dialog.handleInput('4'); - const hint = strip(dialog.render(80).join('\n')); - expect(hint).toContain('Type feedback'); - expect(hint).toContain('↵ submit'); - expect(hint).toContain('alt+x reject'); - expect(hint).not.toContain('esc reject'); }); it('feedback input supports left/right cursor editing', () => { const { dialog, responses } = makeDialog(); - dialog.setKeybindings(parseKeybindingBlocks([])); dialog.handleInput('4'); dialog.handleInput('n'); dialog.handleInput('o'); @@ -725,7 +471,6 @@ describe('ApprovalPanelComponent', () => { pending, (response) => responses.push(response), ); - dialog.setKeybindings(parseKeybindingBlocks([])); dialog.handleInput('2'); dialog.handleInput('n'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts index 2740c1bb..a5353867 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,4 +1,4 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -85,47 +85,6 @@ describe('ApprovalPreviewViewer', () => { expect(scrolled).toMatch(/row-\d{2,}/); }); - it.each([ - ['legacy', '\u0006'], - ['Kitty CSI-u', '\u001B[102;5u'], - ])('%s ctrl+f pages down', (_encoding, key) => { - const lines = Array.from( - { length: 50 }, - (_, i) => `row-${String(i + 1).padStart(3, '0')}`, - ); - const viewer = makeViewer({ - block: { type: 'file_content', path: 'src/big.ts', content: lines.join('\n') }, - rows: 12, - }); - - viewer.handleInput(key); - - const scrolled = strip(viewer.render(100).join('\n')); - expect(scrolled).toContain('row-008'); - expect(scrolled).not.toContain('row-001'); - }); - - it.each([ - ['legacy', '\u0002'], - ['Kitty CSI-u', '\u001B[98;5u'], - ])('%s ctrl+b pages up', (_encoding, key) => { - const lines = Array.from( - { length: 50 }, - (_, i) => `row-${String(i + 1).padStart(3, '0')}`, - ); - const viewer = makeViewer({ - block: { type: 'file_content', path: 'src/big.ts', content: lines.join('\n') }, - rows: 12, - }); - viewer.handleInput('\u001B[6~'); - - viewer.handleInput(key); - - const scrolled = strip(viewer.render(100).join('\n')); - expect(scrolled).toContain('row-001'); - expect(scrolled).not.toContain('row-009'); - }); - it('scrolls to the end with G and back to the top with g', () => { const lines: string[] = []; for (let i = 1; i <= 100; i++) lines.push(`L${String(i)}`); @@ -179,6 +138,26 @@ describe('ApprovalPreviewViewer', () => { expect(text).toContain('BETA'); }); + it('shows surrounding context lines for a diff block', () => { + const viewer = makeViewer({ + block: { + type: 'diff', + path: 'src/foo.ts', + old_text: ['before1', 'before2', 'old', 'after1', 'after2'].join('\n'), + new_text: ['before1', 'before2', 'new', 'after1', 'after2'].join('\n'), + }, + rows: 24, + }); + + const text = strip(viewer.render(100).join('\n')); + expect(text).toContain('before1'); + expect(text).toContain('before2'); + expect(text).toContain('old'); + expect(text).toContain('new'); + expect(text).toContain('after1'); + expect(text).toContain('after2'); + }); + // Sanity: rendering is a pure slice — repeated render() calls without // input changes produce the same output, no incremental state drift. it('renders deterministically across repeated calls', () => { diff --git a/apps/pythinker-code/test/tui/components/dialogs/cache-hint-dialog.test.ts b/apps/pythinker-code/test/tui/components/dialogs/cache-hint-dialog.test.ts new file mode 100644 index 00000000..6f33c9d1 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/dialogs/cache-hint-dialog.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CacheHintDialogComponent } from '#/tui/components/dialogs/cache-hint-dialog'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function renderDialog(idleSeconds = (26 * 24 + 22) * 3600, totalTokens = 293000) { + const onSelect = vi.fn(); + const onCancel = vi.fn(); + const dialog = new CacheHintDialogComponent({ idleSeconds, totalTokens, onSelect, onCancel }); + return { dialog, onSelect, onCancel, lines: dialog.render(120).map(strip) }; +} + +describe('CacheHintDialogComponent', () => { + it('renders the title with idle duration and token count', () => { + const { lines } = renderDialog(); + expect( + lines.some((l) => l.includes('This session has been idle for 26d 22h and is ~286k tokens.')), + ).toBe(true); + }); + + it('renders the body line and the standard hint vocabulary', () => { + const { lines } = renderDialog(); + expect( + lines.some((l) => + l.includes('Cache expired — the next message re-sends the entire history at full price.'), + ), + ).toBe(true); + const titleIdx = lines.findIndex((l) => l.includes('This session has been idle')); + expect(lines[titleIdx + 1]).toContain('↑↓ navigate'); + expect(lines[titleIdx + 1]).toContain('Enter select'); + expect(lines[titleIdx + 1]).toContain('Esc cancel'); + }); + + it('renders all four options in order with right-column descriptions', () => { + const { lines } = renderDialog(); + const compact = lines.findIndex((l) => l.includes('Compact and continue')); + const fresh = lines.findIndex((l) => l.includes('Start a new session')); + const asIs = lines.findIndex((l) => l.includes('Continue as-is')); + const never = lines.findIndex((l) => l.includes("Don't ask me again")); + + expect(compact).toBeGreaterThanOrEqual(0); + expect(compact).toBeLessThan(fresh); + expect(fresh).toBeLessThan(asIs); + expect(asIs).toBeLessThan(never); + expect(lines[compact]).toContain('one-time compact cost · cheapest way to keep this topic'); + expect(lines[fresh]).toContain('zero context cost · best for a new task'); + expect(lines[asIs]).toContain('full history kept · highest cost per turn'); + }); + + it('aligns description columns across options', () => { + const { lines } = renderDialog(); + const colOf = (labelNeedle: string, descNeedle: string) => { + const line = lines.find((l) => l.includes(labelNeedle)); + expect(line).toBeDefined(); + return line!.indexOf(descNeedle); + }; + const reference = colOf('Compact and continue', 'one-time compact cost'); + expect(colOf('Start a new session', 'zero context cost')).toBe(reference); + expect(colOf('Continue as-is', 'full history kept')).toBe(reference); + }); + + it('selects the highlighted option on Enter, compact by default', () => { + const { dialog, onSelect } = renderDialog(); + dialog.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('compact'); + }); + + it('navigates with arrows before selecting', () => { + const { dialog, onSelect } = renderDialog(); + dialog.handleInput('\u001B[B'); // down + dialog.handleInput('\u001B[B'); // down + dialog.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('continue'); + }); + + it('cancels on Esc without selecting', () => { + const { dialog, onSelect, onCancel } = renderDialog(); + dialog.handleInput('\u001B'); + expect(onCancel).toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts index 9601f929..ec590e25 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/choice-picker.test.ts @@ -1,13 +1,12 @@ -import { Key } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; +import { ChoicePickerComponent, type ChoiceOption } from '#/tui/components/dialogs/choice-picker'; import { EditorSelectorComponent } from '#/tui/components/dialogs/editor-selector'; import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector'; import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; import { ThemeSelectorComponent } from '#/tui/components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '#/tui/components/dialogs/update-preference-selector'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; +import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; const ANSI_SGR = /\[[0-9;]*m/g; @@ -17,43 +16,6 @@ function strip(text: string): string { } describe('ChoicePickerComponent', () => { - it('keeps configured Select hints authoritative over caller hints', () => { - const picker = new ChoicePickerComponent({ - title: 'Pick one', - hint: '↑↓ navigate · Enter select · Esc cancel', - options: [ - { value: 'first', label: 'First' }, - { value: 'second', label: 'Second' }, - ], - secondaryAction: { - key: 'w', - label: 'write to file', - onSelect: vi.fn(), - }, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - picker.setKeybindings( - [ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Select', bindings: { up: null, 'alt+k': 'select:previous' } }, - ]), - ], - ); - - picker.handleInput(Key.down); - picker.handleInput(Key.up); - const afterOldKey = strip(picker.render(80).join('\n')); - expect(afterOldKey).toContain('❯ Second'); - expect(afterOldKey).not.toContain('↑'); - picker.handleInput('\u001Bk'); - const output = strip(picker.render(160).join('\n')); - expect(output).toContain('❯ First'); - expect(output).toContain('alt+k'); - expect(output).toContain('W write to file'); - }); - it('uses the model-dialog header vocabulary (capitalized keys, "type to search")', () => { const picker = new ChoicePickerComponent({ title: 'Add provider', @@ -142,8 +104,6 @@ describe('ChoicePickerComponent', () => { const settingsOutput = settings.render(120).map(strip); expect(settingsOutput).toContain(' ❯ Model'); expect(settingsOutput).toContain(' Switch the active model and thinking mode.'); - expect(settingsOutput).toContain(' Choose how Pythinker formats responses.'); - expect(settingsOutput).toContain(' Choose whether /copy always uses the full response.'); expect(settingsOutput).toContain(' Turn automatic CLI updates on or off.'); const upgradePreference = new UpdatePreferenceSelectorComponent({ @@ -153,9 +113,7 @@ describe('ChoicePickerComponent', () => { }); const upgradePreferenceOutput = upgradePreference.render(120).map(strip); expect(upgradePreferenceOutput).toContain(' ❯ On ← current'); - expect(upgradePreferenceOutput).toContain( - ' Update automatically in the background.', - ); + expect(upgradePreferenceOutput).toContain(' Install new versions in the background.'); }); it('routes Space into the query for searchable lists instead of selecting', () => { @@ -188,25 +146,34 @@ describe('ChoicePickerComponent', () => { expect(onSelect).toHaveBeenCalledWith('a'); }); - it('routes an optional secondary action to the focused choice', () => { - const onSecondary = vi.fn(); - const picker = new ChoicePickerComponent({ - title: 'Copy response', - options: [ - { value: 'full', label: 'Full response' }, - { value: 'block:0', label: 'Code block' }, - ], - secondaryAction: { - key: 'w', - label: 'write to file', - onSelect: onSecondary, + it('renders the selected option description in descriptionTone, others in textMuted', () => { + const options: ChoiceOption[] = [ + { value: 'none', label: 'No attachment', description: 'Text feedback only' }, + { + value: 'logs+codebase', + label: 'Logs + codebase', + description: 'Include your codebase for deeper diagnosis.', + descriptionTone: 'warning', }, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); + ]; - expect(picker.render(100).map(strip).join('\n')).toContain('W write to file'); - picker.handleInput('w'); - expect(onSecondary).toHaveBeenCalledWith('full'); + const renderDescLine = (currentValue: string): string | undefined => { + const picker = new ChoicePickerComponent({ + title: 'Share diagnostic info?', + options, + currentValue, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + return picker.render(120).find((line) => strip(line).includes('Include your codebase')); + }; + + const warningLine = currentTheme.fg('warning', ' Include your codebase for deeper diagnosis.'); + const mutedLine = currentTheme.fg('textMuted', ' Include your codebase for deeper diagnosis.'); + + // Selected option: description uses the configured tone. + expect(renderDescLine('logs+codebase')).toBe(warningLine); + // Unselected option: description falls back to textMuted. + expect(renderDescLine('none')).toBe(mutedLine); }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts index 4066bcae..4f415bc3 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/compaction.test.ts @@ -1,21 +1,15 @@ import chalk from 'chalk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { CompactionComponent } from '#/tui/components/dialogs/compaction'; -import { BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; afterEach(() => { - vi.useRealTimers(); currentTheme.setPalette(darkColors); }); -function strip(text: string | undefined): string { - return text?.replaceAll(/\u001B\[[0-9;]*m/gu, '') ?? ''; -} - -function ansiCodes(text: string | undefined): string[] { - return text?.match(/\u001B\[[0-9;]*m/gu) ?? []; +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } describe('CompactionComponent', () => { @@ -26,162 +20,129 @@ describe('CompactionComponent', () => { const lines = component.render(120).map(strip); const text = lines.join('\n'); - expect(text).toContain('Compacting conversation…'); + expect(text).toContain('Compacting context...'); expect(text).toContain(' keep the recent files only'); } finally { component.dispose(); } }); - it('renders a progress bar while compacting and drops it when finished', () => { - const component = new CompactionComponent(); + it('renders a tip suffix while compacting', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); try { - const bar = component.render(120).map(strip).find((l) => l.includes('▱')); - expect(bar).toBeDefined(); - // Two-space indent, a 40-cell bar, then the percentage. - expect(bar).toMatch(/^ {2}[▰]*[▱]* \d+% *$/u); - expect(Array.from(bar ?? '').filter((c) => c === '▰' || c === '▱')).toHaveLength(40); - - component.markDone(1000, 200); - const afterLines = component.render(120).map(strip); - expect(afterLines.join('\n')).not.toContain('▱'); - expect(afterLines.join('\n')).toContain('└ Compacted (1000 → 200 tokens)'); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); + + expect(text).toContain('Compacting context... · Tip: ctrl+s: steer mid-turn'); } finally { component.dispose(); } }); - it('shows elapsed seconds and advances the percentage once per second', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = new CompactionComponent(); + it('does not render a tip after compaction completes', () => { + const component = new CompactionComponent(undefined, undefined, 'ctrl+s: steer mid-turn'); try { - vi.advanceTimersByTime(27_000); + component.markDone(1000, 500); const lines = component.render(120).map(strip); - expect(lines.map((line) => line.trimEnd())).toContain('Compacting conversation… (27s)'); - const bar = lines.find((line) => line.includes('▱')); - expect(bar?.trimEnd()).toBe(` ${'▰'.repeat(11)}${'▱'.repeat(29)} 27%`); + const text = lines.join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).not.toContain('Tip:'); + expect(text).not.toContain('Ctrl-O'); } finally { component.dispose(); } }); - it('keeps the progress bar static while header shimmer and elapsed time update', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - chalk.level = 3; + it('renders a cancelled terminal state', () => { const component = new CompactionComponent(); try { - vi.advanceTimersByTime(27_000); - const firstRender = component.render(120); - const firstHeader = firstRender.find((line) => strip(line).includes('Compacting conversation…')); - const firstBar = firstRender.find((line) => strip(line).includes('▱')); - - expect(firstHeader).toBeDefined(); - expect(firstBar).toBeDefined(); - - const headerSamples = [firstHeader]; - const barSamples = [firstBar]; - const shimmerSampleCount = 12; - for (let sample = 0; sample < shimmerSampleCount; sample++) { - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); - const render = component.render(120); - headerSamples.push( - render.find((line) => strip(line).includes('Compacting conversation…')), - ); - barSamples.push(render.find((line) => strip(line).includes('▱'))); - } - - expect(headerSamples.every((header) => header !== undefined)).toBe(true); - expect(barSamples.every((bar) => bar !== undefined)).toBe(true); - expect(new Set(headerSamples.map(strip))).toEqual(new Set([strip(firstHeader)])); - expect(new Set(headerSamples).size).toBeGreaterThan(1); - expect(new Set(barSamples)).toEqual(new Set([firstBar])); - expect(strip(firstBar).trimEnd()).toBe(` ${'▰'.repeat(11)}${'▱'.repeat(29)} 27%`); - expect(firstBar).toContain(currentTheme.fg('primary', '▰'.repeat(11))); - expect(firstBar).not.toContain(currentTheme.fg('progressFill', '▰'.repeat(11))); - expect(firstBar).toContain(currentTheme.fg('progressEmpty', '▱'.repeat(29))); - - vi.advanceTimersByTime( - 1_000 - BRAILLE_SPINNER_INTERVAL_MS * shimmerSampleCount, - ); - const elapsedRender = component.render(120); - const elapsedHeader = elapsedRender.find((line) => - strip(line).includes('Compacting conversation…'), - ); - const elapsedBar = elapsedRender.find((line) => strip(line).includes('▱')); + component.markCanceled(); + const lines = component.render(120).map(strip); + const text = lines.join('\n'); - expect(strip(elapsedHeader).trimEnd()).toBe('Compacting conversation… (28s)'); - expect(strip(elapsedBar).trimEnd()).toBe(` ${'▰'.repeat(11)}${'▱'.repeat(29)} 28%`); - expect(ansiCodes(elapsedBar)).toEqual(ansiCodes(firstBar)); + expect(text).toContain('Compaction cancelled'); + expect(text).not.toContain('Compacting context...'); } finally { - chalk.level = previousLevel; component.dispose(); } }); - it('narrows the progress bar to fit a small terminal', () => { + it('keeps the completed compaction summary hidden until expanded', () => { const component = new CompactionComponent(); try { - const bar = component.render(30).map(strip).find((l) => l.includes('▱')); - expect(bar).toBeDefined(); - expect(Array.from(bar ?? '').filter((c) => c === '▰' || c === '▱')).toHaveLength(20); + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + const collapsed = component.render(120).map(strip).join('\n'); + + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('Ctrl-O to show compaction summary'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + component.setExpanded(true); + const expanded = component.render(120).map(strip).join('\n'); + + expect(expanded).toContain('Compaction complete'); + expect(expanded).toContain('Ctrl-O to hide compaction summary'); + expect(expanded).toContain('Keep the src/tui compaction notes.'); } finally { component.dispose(); } }); - it('collapses to a ctrl+o hint and expands to the full summary', () => { + it('hides the compaction summary again when collapsed', () => { const component = new CompactionComponent(); try { - component.markDone(1000, 200, 'First summary line.\nSecond summary line.'); - const collapsed = component.render(120).map(strip).join('\n'); - expect(collapsed).toContain('└ Compacted (ctrl+o to see full summary)'); - expect(collapsed).not.toContain('First summary line.'); - + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); component.setExpanded(true); - const expanded = component.render(120).map(strip).join('\n'); - // Expanded trades the hint for the token counts and shows the body. - expect(expanded).toContain('└ Compacted (1000 → 200 tokens)'); - expect(expanded).toContain('First summary line.'); - expect(expanded).toContain('Second summary line.'); - component.setExpanded(false); - expect(component.render(120).map(strip).join('\n')).not.toContain('First summary line.'); + const text = component.render(120).map(strip).join('\n'); + + expect(text).toContain('Compaction complete'); + expect(text).toContain('Ctrl-O to show compaction summary'); + expect(text).not.toContain('Ctrl-O to hide compaction summary'); + expect(text).not.toContain('Keep the src/tui compaction notes.'); } finally { component.dispose(); } }); - it('omits the ctrl+o hint when compaction reported no summary', () => { - const component = new CompactionComponent(); + it('preserves the expanded summary when invalidating with an instruction', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { - component.markDone(1000, 200); + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + component.invalidate(); const text = component.render(120).map(strip).join('\n'); - expect(text).toContain('└ Compacted (1000 → 200 tokens)'); - expect(text).not.toContain('ctrl+o'); + + expect(text).toContain('keep the recent files only'); + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.match(/keep the recent files only/g)).toHaveLength(1); } finally { component.dispose(); } }); - it('renders a cancelled terminal state', () => { - const component = new CompactionComponent(); + it('keeps expanded summary child order on invalidate', () => { + const component = new CompactionComponent(undefined, 'keep the recent files only'); try { - component.markCanceled(); - const lines = component.render(120).map(strip); - const text = lines.join('\n'); + component.markDone(120, 24, 'Keep the src/tui compaction notes.'); + component.setExpanded(true); + currentTheme.setPalette(lightColors); + component.invalidate(); + const text = component.render(120).map(strip).join('\n'); - expect(text).toContain('Compaction cancelled'); - expect(text).not.toContain('Compacting conversation…'); + expect(text).toContain('Keep the src/tui compaction notes.'); + expect(text.indexOf('keep the recent files only')).toBeLessThan( + text.indexOf('Keep the src/tui compaction notes.'), + ); } finally { component.dispose(); } @@ -196,7 +157,7 @@ describe('CompactionComponent', () => { try { const headerOf = (): string => { - const line = component.render(120).find((l) => strip(l).includes('Compacting conversation…')); + const line = component.render(120).find((l) => strip(l).includes('Compacting context...')); if (line === undefined) throw new Error('header line not found'); return line; }; diff --git a/apps/pythinker-code/test/tui/components/dialogs/custom-registry-import.test.ts b/apps/pythinker-code/test/tui/components/dialogs/custom-registry-import.test.ts index e28adcf6..f0e345eb 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/custom-registry-import.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/custom-registry-import.test.ts @@ -1,11 +1,10 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { CustomRegistryImportDialogComponent, type CustomRegistryImportResult, } from '#/tui/components/dialogs/custom-registry-import'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; import { darkColors } from '#/tui/theme/colors'; const ANSI = /\[[0-9;]*m/g; @@ -70,148 +69,6 @@ describe('CustomRegistryImportDialogComponent', () => { }); }); - it('uses remapped field focus without stealing token text', () => { - const { dialog, onDone } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'alt+f': 'confirm:nextField', - x: 'confirm:next', - }, - }, - ]), - ); - dialog.handleInput('\u001Bf'); - dialog.handleInput('x'); - dialog.handleInput('\r'); - expect(onDone).toHaveBeenCalledWith({ - kind: 'ok', - value: { url: 'https://example.com/api.json', apiKey: 'x' }, - }); - }); - - it('recovers bare Escape after default cancel bindings are explicitly removed', () => { - const { dialog, onDone } = makeDialog(); - dialog.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]); - dialog.handleInput(ESC); - expect(onDone).toHaveBeenCalledWith({ kind: 'cancel' }); - }); - - it('uses an alternate cancel binding in token input while bare Escape preserves the draft', () => { - const bindings = parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'alt+f': 'confirm:nextField', - 'alt+x': 'confirm:no', - }, - }, - ]); - const preserved = makeDialog(); - preserved.dialog.setKeybindings(bindings); - preserved.dialog.handleInput('\u001Bf'); - preserved.dialog.handleInput('d'); - preserved.dialog.handleInput(ESC); - preserved.dialog.handleInput('\r'); - expect(preserved.onDone).toHaveBeenCalledWith({ - kind: 'ok', - value: { url: 'https://example.com/api.json', apiKey: 'd' }, - }); - - const cancelled = makeDialog(); - cancelled.dialog.setKeybindings(bindings); - cancelled.dialog.handleInput('\u001Bf'); - cancelled.dialog.handleInput('d'); - cancelled.dialog.handleInput('\u001Bx'); - expect(cancelled.onDone).toHaveBeenCalledWith({ kind: 'cancel' }); - }); - - it('executes multi-key field chords without losing resolver state', () => { - const { dialog } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+k ctrl+n': 'confirm:nextField', - 'ctrl+k ctrl+p': 'confirm:previousField', - }, - }, - ]), - ); - dialog.handleInput('\u000B'); - dialog.handleInput('\u000E'); - expect(plain(dialog)).toContain('Enter to submit'); - dialog.handleInput('\u000B'); - dialog.handleInput('\u0010'); - expect(plain(dialog)).toContain('next field'); - }); - - it('executes semantic field key IDs', () => { - const { dialog } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'alt+f': 'confirm:nextField', - 'alt+b': 'confirm:previousField', - }, - }, - ]), - ); - dialog.handleInput('alt+f'); - expect(plain(dialog)).toContain('Enter to submit'); - dialog.handleInput('alt+b'); - expect(plain(dialog)).toContain('next field'); - }); - - it('executes semantic two-key field chords', () => { - const { dialog } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+k ctrl+n': 'confirm:nextField', - 'ctrl+k ctrl+p': 'confirm:previousField', - }, - }, - ]), - ); - dialog.handleInput('ctrl+k'); - dialog.handleInput('ctrl+n'); - expect(plain(dialog)).toContain('Enter to submit'); - dialog.handleInput('ctrl+k'); - dialog.handleInput('ctrl+p'); - expect(plain(dialog)).toContain('next field'); - }); - - it('keeps unavailable printable chords intact in the active field', () => { - const { dialog, onDone } = makeDialog(''); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'x y': 'confirm:next' } }, - ]), - ); - dialog.handleInput('x'); - dialog.handleInput('y'); - dialog.handleInput('\r'); - dialog.handleInput('z'); - dialog.handleInput('\r'); - expect(onDone).toHaveBeenCalledWith({ - kind: 'ok', - value: { url: 'xy', apiKey: 'z' }, - }); - }); - it('keeps every line within narrow widths', () => { const { dialog } = makeDialog('https://example.com/very/long/registry/path.json'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts index 05dd4400..53ff5b17 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/effort-selector.test.ts @@ -1,91 +1,151 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI = /\u001B\[[0-9;]*m/g; +const ANSI = /\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); -const DOWN = `${ESC}[B`; +const LEFT = `${ESC}[D`; +const RIGHT = `${ESC}[C`; -function make(levels: readonly string[] = ['off', 'low', 'medium', 'high'], currentValue = 'medium') { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const component = new EffortSelectorComponent({ - levels, - currentValue, - modelName: 'Kimi K2', - onSelect, - onCancel, - }); - return { component, onSelect, onCancel }; +function text(component: EffortSelectorComponent, width = 120): string { + return component.render(width).map(strip).join('\n'); } describe('EffortSelectorComponent', () => { - it('uses remapped Select navigation and honors an unbound Down key', () => { - const { component, onSelect } = make(['low', 'medium'], 'low'); - component.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - component.handleInput(DOWN); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith('low'); + it('renders efforts as horizontal segments with the active one bracketed', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = text(picker); + // All efforts are rendered on a single row. + expect(out).toContain('Off'); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).toContain('Max'); + // The active level is wrapped in brackets; the rest are not. + expect(out).toContain('[ High ]'); + expect(out).not.toContain('[ Off ]'); + expect(out).not.toContain('[ Max ]'); + }); - component.handleInput('alt+j'); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith('medium'); + it('invokes onSelect with the chosen effort on Enter', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('high'); }); - it('renders title, hint, levels, and the current marker', () => { - const { component } = make(); - const out = component.render(80).map(strip).join('\n'); + it('moves the active segment with Left/Right and stops at the edges', () => { + const onSelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onCancel: vi.fn(), + }); - expect(out).toContain('Thinking effort'); - expect(out).toContain('Kimi K2'); - expect(out).toContain('↑↓ navigate · Enter select · Esc cancel'); - expect(out).toContain('medium ← current'); - // The cursor starts on the current level. - expect(out).toMatch(/❯ medium/); - }); + // index 2 (high) -> 3 (max). + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); - it('selects the level under the cursor with Enter', () => { - const { component, onSelect } = make(); - component.handleInput(DOWN); // medium -> high - component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith('high'); - }); + // Already at the right edge — another Right stays put. + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('max'); - it('keeps PageUp and PageDown local to the effort list', () => { - const { component, onSelect } = make( - Array.from({ length: 10 }, (_, index) => `level-${String(index)}`), - 'level-0', - ); + // Walk back to the left edge (max -> high -> low -> off). + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); - component.handleInput(`${ESC}[6~`); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith('level-8'); + // Already at the left edge — another Left stays put. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith('off'); + }); - component.handleInput(`${ESC}[5~`); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith('level-0'); + it('invokes onSessionOnlySelect on Alt+S instead of onSelect', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith('high'); + expect(onSelect).not.toHaveBeenCalled(); }); - it('cancels with Esc without selecting', () => { - const { component, onSelect, onCancel } = make(); - component.handleInput(ESC); + it('cancels on Escape', () => { + const onCancel = vi.fn(); + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel, + }); + picker.handleInput(ESC); expect(onCancel).toHaveBeenCalledTimes(1); - expect(onSelect).not.toHaveBeenCalled(); }); - it('never renders a line wider than the terminal', () => { - const { component } = make(['off', 'low', 'medium', 'high', 'xhigh', 'max'], 'max'); - for (const width of [20, 40, 80]) { - for (const line of component.render(width)) { - expect(visibleWidth(line)).toBeLessThanOrEqual(width); - } - } + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + }); + + it('renders no warning line without the warning option', () => { + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toBe(''); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching effort invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new EffortSelectorComponent({ + efforts: ['off', 'low', 'high', 'max'], + currentValue: 'high', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const lines = picker.render(40).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('←→ switch')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/experiments-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/experiments-selector.test.ts index bbafa37d..2dd1d948 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/experiments-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/experiments-selector.test.ts @@ -5,7 +5,6 @@ import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, } from '#/tui/components/dialogs/experiments-selector'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; const ANSI = /\u001B\[[0-9;]*m/g; @@ -37,35 +36,6 @@ function text(component: ExperimentsSelectorComponent, width = 120): string { } describe('ExperimentsSelectorComponent', () => { - it('uses remapped Select navigation and honors an unbound Down key', () => { - const onApply = vi.fn(); - const selector = new ExperimentsSelectorComponent({ - features: [ - feature({ id: 'agent_memory' }), - feature({ id: 'vim_mode', title: 'Second feature' }), - ], - onApply, - onCancel: vi.fn(), - }); - selector.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - selector.handleInput(`${ESC}[B`); - selector.handleInput(' '); - selector.handleInput(ENTER); - expect(onApply).toHaveBeenLastCalledWith([{ id: 'agent_memory', enabled: false }]); - - selector.handleInput('alt+j'); - selector.handleInput(' '); - selector.handleInput(ENTER); - expect(onApply).toHaveBeenLastCalledWith([ - { id: 'agent_memory', enabled: false }, - { id: 'vim_mode', enabled: false }, - ]); - }); - it('renders searchable feature toggles with source details', () => { const selector = new ExperimentsSelectorComponent({ features: [ @@ -150,38 +120,4 @@ describe('ExperimentsSelectorComponent', () => { selector.handleInput(ESC); expect(onCancel).toHaveBeenCalledOnce(); }); - - it('keeps PageUp and PageDown local to the experiments list', () => { - const onApply = vi.fn(); - const ids = [ - 'agent_fork_context', - 'agent_memory', - 'agent_teams', - 'coordinator_mode', - 'lsp', - 'micro_compaction', - 'powershell', - 'task_graph', - 'token_budget', - 'vim_mode', - ] as const; - const selector = new ExperimentsSelectorComponent({ - features: ids.map((id) => feature({ id, title: id })), - onApply, - onCancel: vi.fn(), - }); - - selector.handleInput(`${ESC}[6~`); - selector.handleInput(' '); - selector.handleInput(ENTER); - expect(onApply).toHaveBeenLastCalledWith([{ id: 'token_budget', enabled: false }]); - - selector.handleInput(`${ESC}[5~`); - selector.handleInput(' '); - selector.handleInput(ENTER); - expect(onApply).toHaveBeenLastCalledWith([ - { id: 'agent_fork_context', enabled: false }, - { id: 'token_budget', enabled: false }, - ]); - }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/feedback-input-dialog.test.ts b/apps/pythinker-code/test/tui/components/dialogs/feedback-input-dialog.test.ts index 3065132b..cba64db2 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/feedback-input-dialog.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/feedback-input-dialog.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; @@ -6,7 +6,6 @@ import { FeedbackInputDialogComponent, type FeedbackInputDialogResult, } from '#/tui/components/dialogs/feedback-input-dialog'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; import { darkColors } from '#/tui/theme/colors'; const ESC = String.fromCodePoint(27); @@ -117,86 +116,4 @@ describe('FeedbackInputDialogComponent', () => { dialog.handleInput(ESC); expect(collected).toEqual([{ kind: 'ok', value: 'hi' }]); }); - - it('lets feedback text own printable confirmation bindings and recovers Escape', () => { - const { dialog, collected } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { x: 'confirm:next' } }, - ]), - ); - dialog.handleInput('x'); - dialog.handleInput('\r'); - expect(collected).toEqual([{ kind: 'ok', value: 'x' }]); - - const recovery = makeDialog(); - recovery.dialog.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]); - recovery.dialog.handleInput(ESC); - expect(recovery.collected).toEqual([{ kind: 'cancel' }]); - }); - - it('uses an alternate cancel binding while bare Escape preserves feedback input', () => { - const bindings = parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'alt+x': 'confirm:no' } }, - ]); - const preserved = makeDialog(); - preserved.dialog.setKeybindings(bindings); - preserved.dialog.handleInput('d'); - preserved.dialog.handleInput(ESC); - preserved.dialog.handleInput('\r'); - expect(preserved.collected).toEqual([{ kind: 'ok', value: 'd' }]); - - const cancelled = makeDialog(); - cancelled.dialog.setKeybindings(bindings); - cancelled.dialog.handleInput('d'); - cancelled.dialog.handleInput('\u001Bx'); - expect(cancelled.collected).toEqual([{ kind: 'cancel' }]); - }); - - it('executes a semantic two-key cancel chord', () => { - const { dialog, collected } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+k ctrl+x': 'confirm:no' }, - }, - ]), - ); - dialog.handleInput('d'); - dialog.handleInput('ctrl+k'); - dialog.handleInput('ctrl+x'); - expect(collected).toEqual([{ kind: 'cancel' }]); - }); - - it('keeps unavailable printable chords intact in feedback input', () => { - const { dialog, collected } = makeDialog(); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'x y': 'confirm:next' } }, - ]), - ); - dialog.handleInput('x'); - dialog.handleInput('y'); - dialog.handleInput('\r'); - expect(collected).toEqual([{ kind: 'ok', value: 'xy' }]); - }); - - it('honors last-wins null removal before filtering nested candidates', () => { - const { dialog, collected } = makeDialog(); - dialog.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]); - dialog.handleInput('n'); - dialog.handleInput('\r'); - expect(collected).toEqual([{ kind: 'ok', value: 'n' }]); - }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/pythinker-code/test/tui/components/dialogs/goal-queue-manager.test.ts index fbcc7ecb..e9957da4 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { @@ -7,7 +7,6 @@ import { type GoalQueueManagerAction, } from '#/tui/components/dialogs/goal-queue-manager'; import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); @@ -36,36 +35,6 @@ function text(component: GoalQueueManagerComponent | GoalQueueEditDialogComponen } describe('GoalQueueManagerComponent', () => { - it('uses remapped Select navigation, honors an unbound Down key, and leaves reorder input local', () => { - const onAction = vi.fn(); - const manager = new GoalQueueManagerComponent({ - goals: [goal('g1', 'First'), goal('g2', 'Second'), goal('g3', 'Third')], - onAction, - onCancel: vi.fn(), - }); - manager.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - manager.handleInput(DOWN); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g1' }); - - manager.handleInput('alt+j'); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g2' }); - - manager.handleInput(' '); - manager.handleInput('alt+j'); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g2' }); - - manager.handleInput(DOWN); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'move', goalId: 'g2', direction: 'down' }); - expect(onAction).toHaveBeenCalledTimes(4); - }); - it('renders the upcoming goals and the management hint', () => { const manager = new GoalQueueManagerComponent({ goals: [goal('g1', 'Ship queued goal')], @@ -165,43 +134,6 @@ describe('GoalQueueManagerComponent', () => { expect(onAction).toHaveBeenCalledWith({ kind: 'edit', goalId: 'g1' }); }); - it('keeps PageUp and PageDown local to the goal list', () => { - const onAction = vi.fn(); - const manager = new GoalQueueManagerComponent({ - goals: [goal('g1', 'First'), goal('g2', 'Second'), goal('g3', 'Third')], - pageSize: 2, - onAction, - onCancel: vi.fn(), - }); - - manager.handleInput(`${ESC}[6~`); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g3' }); - - manager.handleInput(`${ESC}[5~`); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g1' }); - }); - - it('keeps PageUp and PageDown local while reorder mode owns input', () => { - const onAction = vi.fn(); - const manager = new GoalQueueManagerComponent({ - goals: [goal('g1', 'First'), goal('g2', 'Second'), goal('g3', 'Third')], - pageSize: 2, - onAction, - onCancel: vi.fn(), - }); - - manager.handleInput(' '); - manager.handleInput(`${ESC}[6~`); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g3' }); - - manager.handleInput(`${ESC}[5~`); - manager.handleInput('e'); - expect(onAction).toHaveBeenLastCalledWith({ kind: 'edit', goalId: 'g1' }); - }); - it('cancels with Esc', () => { const onCancel = vi.fn(); const manager = new GoalQueueManagerComponent({ diff --git a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts index 627efa8c..16f469f6 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/model-selector.test.ts @@ -1,12 +1,12 @@ import type { ModelAlias } from '@pymodel/pythinker-code-sdk'; -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; -import { parseKeybindingBlocks } from '#/tui/keybindings'; import { currentTheme } from '#/tui/theme'; +import { darkColors } from '#/tui/theme/colors'; -const ANSI = /\u001B\[[0-9;]*m/g; +const ANSI = /\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); const UP = `${ESC}[A`; @@ -14,18 +14,30 @@ const DOWN = `${ESC}[B`; const LEFT = `${ESC}[D`; const RIGHT = `${ESC}[C`; -function model( +function model(displayName: string, capabilities: string[] = ['thinking']): ModelAlias { + return { + provider: 'managed:pythinker-code', + model: displayName.toLowerCase().replaceAll(' ', '-'), + maxContextSize: 200_000, + displayName, + capabilities, + } as unknown as ModelAlias; +} + +function effortModel( displayName: string, + supportEfforts: string[], + defaultEffort?: string, capabilities: string[] = ['thinking'], - supportEfforts?: string[], ): ModelAlias { return { - provider: 'moonshot-cn', + provider: 'managed:pythinker-code', model: displayName.toLowerCase().replaceAll(' ', '-'), maxContextSize: 200_000, displayName, capabilities, supportEfforts, + defaultEffort, } as unknown as ModelAlias; } @@ -34,286 +46,209 @@ function text(component: ModelSelectorComponent, width = 120): string { } describe('ModelSelectorComponent', () => { - it('reports whether resolver, paging, search, or unrelated input was consumed', () => { - const picker = new ModelSelectorComponent({ - models: { - first: model('First Model'), - second: model('Second Model'), - }, - currentValue: 'first', - currentEffort: 'medium', - searchable: true, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - expect(picker.handleInput(RIGHT)).toBe(true); - expect(picker.handleInput(`${ESC}[5~`)).toBe(true); - expect(picker.handleInput(`${ESC}[6~`)).toBe(true); - expect(picker.handleInput('s')).toBe(true); - expect(picker.handleInput('\u0000')).toBe(false); - }); - - it('uses remapped effort controls and renders the effective shortcut', () => { - const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { pythinker: model('Kimi K2', ['thinking']) }, - currentValue: 'pythinker', - currentEffort: 'medium', - onSelect, - onCancel: vi.fn(), - }); - picker.setKeybindings( - parseKeybindingBlocks([ - { - context: 'ModelPicker', - bindings: { right: null, 'alt+l': 'modelPicker:increaseEffort' }, - }, - { context: 'Select', bindings: { enter: 'select:accept' } }, - ]), - ); - - picker.handleInput(RIGHT); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'medium' }); - picker.handleInput('\u001Bl'); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'high' }); - const output = text(picker); - expect(output).toContain('alt+l'); - expect(output).not.toContain('→'); - }); - it('lays out the provider as a right column and marks the current model', () => { const picker = new ModelSelectorComponent({ models: { pythinker: model('Kimi K2') }, currentValue: 'pythinker', - currentEffort: 'medium', + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); const out = text(picker); // Model name on the left, provider on the right, with the current marker. - expect(out).toMatch(/❯ Kimi K2\s+moonshot-cn ← current/u); - expect(out).not.toContain('Kimi K2 (moonshot-cn)'); + expect(out).toMatch(/❯ Kimi K2\s+Pythinker Code ← current/); + // Provider is no longer inlined in parentheses next to the name. + expect(out).not.toContain('Kimi K2 (Pythinker Code)'); }); - it('moves the effort draft with Left/Right (no wraparound)', () => { + it('toggles thinking with Left/Right (not with "/")', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { pythinker: model('Kimi K2', ['thinking']) }, currentValue: 'pythinker', - currentEffort: 'medium', + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); - // The current model reflects its live effort. + // "/" no longer toggles thinking (it used to); here it is simply ignored. + picker.handleInput('/'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'medium' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'on' }); - // Right arrow moves one level up (medium -> high). + // Right arrow flips the draft (true -> false). picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'high' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'off' }); - // Another Right is a no-op at the top end (no wraparound). - picker.handleInput(RIGHT); - picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'high' }); - - // Left walks back down (high -> medium -> low -> off), then stops. - picker.handleInput(LEFT); - picker.handleInput(LEFT); - picker.handleInput(LEFT); + // Left arrow flips it back. picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', effort: 'off' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'on' }); }); - it('shows the Left/Right hint only when the model has multiple levels', () => { - const toggleable = new ModelSelectorComponent({ + it('shows the Left/Right thinking hint only for toggleable models', () => { + const picker = new ModelSelectorComponent({ models: { pythinker: model('Kimi K2', ['thinking']) }, currentValue: 'pythinker', - currentEffort: 'high', + currentThinkingEffort: 'off', onSelect: vi.fn(), onCancel: vi.fn(), }); - expect(text(toggleable)).toContain('Thinking (←→ to switch)'); - - const unsupported = new ModelSelectorComponent({ - models: { plain: model('Kimi Plain', ['tool_use']) }, - currentValue: 'plain', - currentEffort: 'off', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - expect(text(unsupported)).not.toContain('←→ to switch'); + expect(text(picker)).toContain('Thinking (←→ to switch)'); }); - it('offers the fallback low/med/high levels without supportEfforts metadata', () => { + it('hides the Thinking footer when thinkingControl is false', () => { const picker = new ModelSelectorComponent({ models: { pythinker: model('Kimi K2', ['thinking']) }, currentValue: 'pythinker', - currentEffort: 'medium', + currentThinkingEffort: 'on', + thinkingControl: false, onSelect: vi.fn(), onCancel: vi.fn(), }); - const out = text(picker); - expect(out).toContain('off'); - expect(out).toContain('low'); - expect(out).toContain('[ med ]'); - expect(out).toContain('high'); + expect(text(picker)).not.toContain('Thinking'); }); - it('uses the model-declared supportEfforts in canonical order', () => { + it('ignores Left/Right when thinkingControl is false', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ - models: { kimi: model('Kimi K2', ['thinking'], ['max', 'low', 'high']) }, - currentValue: 'kimi', - currentEffort: 'high', + models: { pythinker: model('Kimi K2', ['thinking']) }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + thinkingControl: false, onSelect, onCancel: vi.fn(), }); - const out = text(picker); - expect(out).toContain('[ high ]'); - expect(out).toContain('max'); - expect(out).not.toContain('med'); - - // Right moves high -> max within the declared set. + // Same setup as the toggle test above: either arrow would flip 'on' to 'off'. + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'on' }); picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', effort: 'max' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'on' }); }); - it('forces always-on models onto a level and unsupported models off', () => { + it('forces always-thinking models on and unsupported models off', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { - always: model('Kimi Thinking', ['always_thinking'], ['high', 'max']), - plain: model('Kimi Plain', ['tool_use']), + always: model('Pythinker Thinking', ['always_thinking']), + plain: model('Pythinker Plain', ['tool_use']), }, currentValue: 'always', - currentEffort: 'high', + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); - // Always-on: no Off segment at all. + // Always-on: On selected, Off greyed out with an explanation. const alwaysOut = text(picker); - expect(alwaysOut).toContain('[ high ]'); - expect(alwaysOut).not.toContain('off'); + expect(alwaysOut).toContain('[ On ]'); + expect(alwaysOut).toContain('Off (Unsupported)'); + expect(alwaysOut).not.toContain('Always on'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', effort: 'high' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); - // Unsupported: single muted "Off (Unsupported)" control. + // Unsupported: Off selected, On greyed out — same style, mirrored. picker.handleInput(DOWN); const plainOut = text(picker); - expect(plainOut).toContain('Off (Unsupported)'); + expect(plainOut).toContain('On (Unsupported)'); + expect(plainOut).toContain('[ Off ]'); + expect(plainOut).not.toContain('] unsupported'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', effort: 'off' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); - it('keeps the live effort when switching to another model instead of resetting to its first level', () => { + it('ignores Left/Right on always-on and unsupported models', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { - current: model('Kimi K2', ['thinking'], ['low', 'high', 'max']), - other: model('Kimi K3', ['thinking'], ['low', 'high', 'max']), + always: model('Pythinker Thinking', ['always_thinking']), + plain: model('Pythinker Plain', ['tool_use']), }, - currentValue: 'current', - currentEffort: 'max', + currentValue: 'always', + currentThinkingEffort: 'on', onSelect, onCancel: vi.fn(), }); - picker.handleInput(DOWN); + picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'other', effort: 'max' }); - }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'always', thinking: 'on' }); - it('clamps the live effort when it is not in the current model’s set', () => { - const onSelect = vi.fn(); - const picker = new ModelSelectorComponent({ - models: { kimi: model('Kimi K2', ['thinking'], ['low', 'high']) }, - currentValue: 'kimi', - currentEffort: 'max', - onSelect, - onCancel: vi.fn(), - }); - - // max is not supported by this model — clamped down to high. - expect(text(picker)).toContain('[ high ]'); + picker.handleInput(DOWN); + picker.handleInput(LEFT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith({ alias: 'kimi', effort: 'high' }); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'plain', thinking: 'off' }); }); - it('renders the unsupported thinking control muted', () => { + it('renders the unavailable thinking segment muted', () => { const picker = new ModelSelectorComponent({ - models: { plain: model('Kimi Plain', ['tool_use']) }, - currentValue: 'plain', - currentEffort: 'off', + models: { always: model('Pythinker Thinking', ['always_thinking']) }, + currentValue: 'always', + currentThinkingEffort: 'on', onSelect: vi.fn(), onCancel: vi.fn(), }); const raw = picker.render(120).join('\n'); - expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported)')); + expect(raw).toContain(currentTheme.fg('textMuted', ' Off (Unsupported) ')); }); - it('keeps the effort draft when moving across models', () => { + it('keeps the thinking draft when moving across models', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { - plain: model('Kimi Plain', ['tool_use']), - thinking: model('Kimi Thinking', ['thinking']), + plain: model('Pythinker Plain', ['tool_use']), + thinking: model('Pythinker Thinking', ['thinking']), }, currentValue: 'plain', - currentEffort: 'off', + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); - picker.handleInput(DOWN); // -> thinking model (keeps the live off state) - picker.handleInput(RIGHT); // off -> low + picker.handleInput(DOWN); // -> thinking model (keeps live Off) + picker.handleInput(RIGHT); // toggle -> On picker.handleInput(UP); // -> plain - picker.handleInput(DOWN); // -> thinking (the low override persists) + picker.handleInput(DOWN); // -> thinking (the On override persists) picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', effort: 'low' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'thinking', thinking: 'on' }); }); - it('keeps the live off state when moving to another capable model', () => { + it('keeps the live Off state when switching to another thinking-capable model', () => { const onSelect = vi.fn(); const picker = new ModelSelectorComponent({ models: { current: model('Pythinker Current', ['thinking']), - other: model('Pythinker Other', ['thinking'], ['medium', 'high']), + other: model('Pythinker Other', ['thinking']), }, currentValue: 'current', - currentEffort: 'off', // thinking deliberately off on the active model + currentThinkingEffort: 'off', // thinking deliberately off on the active model onSelect, onCancel: vi.fn(), }); // The active model reflects its live (off) state. - expect(text(picker)).toContain('[ off ]'); + expect(text(picker)).toContain('[ Off ]'); picker.handleInput(DOWN); // -> the other thinking-capable model - // A capable, non-active model keeps the live effort instead of resetting. - expect(text(picker)).toContain('[ off ]'); + // A capable, non-active model keeps the live effort. + expect(text(picker)).toContain('[ Off ]'); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'other', effort: 'off' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'off' }); }); it('fuzzy-filters by typing and reports a match count', () => { const onCancel = vi.fn(); const picker = new ModelSelectorComponent({ - models: { k2: model('Kimi K2'), turbo: model('Kimi Turbo') }, + models: { k2: model('Kimi K2'), turbo: model('Pythinker Turbo') }, currentValue: 'k2', - currentEffort: 'high', + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel, @@ -323,7 +258,7 @@ describe('ModelSelectorComponent', () => { picker.handleInput('u'); const out = text(picker); expect(out).toContain('Search: tu'); - expect(out).toContain('Kimi Turbo'); + expect(out).toContain('Pythinker Turbo'); expect(out).not.toContain('Kimi K2'); expect(out).toContain('1 / 2'); @@ -340,7 +275,7 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models, currentValue: 'm0', - currentEffort: 'high', + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -354,10 +289,10 @@ describe('ModelSelectorComponent', () => { const picker = new ModelSelectorComponent({ models: { long: model('A Very Long Model Display Name That Should Be Truncated Hard'), - cjk: model('An extremely long model display name that must be truncated correctly'), + cjk: model('\u8D85\u957F\u7684\u4E2D\u6587\u6A21\u578B\u540D\u79F0\u9700\u8981\u88AB\u6B63\u786E\u622A\u65AD\u5904\u7406'), }, currentValue: 'long', - currentEffort: 'high', + currentThinkingEffort: 'off', searchable: true, onSelect: vi.fn(), onCancel: vi.fn(), @@ -370,32 +305,261 @@ describe('ModelSelectorComponent', () => { } }); - it('collapses duplicate aliases for the same underlying model and prefers the canonical alias', () => { + it('invokes onSessionOnlySelect on Alt+S with the effective thinking state', () => { + const onSelect = vi.fn(); + const onSessionOnlySelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { pythinker: model('Kimi K2', ['thinking']) }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + onSelect, + onSessionOnlySelect, + onCancel: vi.fn(), + }); + + // Toggle thinking Off, then Alt+S applies the choice to the session only. + picker.handleInput(RIGHT); + picker.handleInput(`${ESC}s`); + expect(onSessionOnlySelect).toHaveBeenCalledWith({ alias: 'pythinker', thinking: 'off' }); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('ignores Alt+S and hides its hint when onSessionOnlySelect is not provided', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { pythinker: model('Kimi K2') }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + onSelect, + onCancel: vi.fn(), + }); + + picker.handleInput(`${ESC}s`); + expect(onSelect).not.toHaveBeenCalled(); + expect(text(picker)).not.toContain('Alt+S session-only'); + }); + + it('shows the Alt+S session-only hint when onSessionOnlySelect is provided', () => { + const picker = new ModelSelectorComponent({ + models: { pythinker: model('Kimi K2') }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + onSelect: vi.fn(), + onSessionOnlySelect: vi.fn(), + onCancel: vi.fn(), + }); + expect(text(picker)).toContain('Alt+S session-only'); + }); + + it('renders effort segments with the default effort highlighted', () => { + const picker = new ModelSelectorComponent({ + models: { pythinker: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'pythinker', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + // The default effort (high) is the active segment. + expect(out).toContain('[ High ]'); + // All declared efforts plus the Off entry are present. + expect(out).toContain('Low'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + // Multi-segment control advertises the switch hint. + expect(out).toContain('Thinking (←→ to switch)'); + }); + + it('derives official Anthropic effort segments from the model name', () => { const onSelect = vi.fn(); - const terra: ModelAlias = { - provider: 'terra', - model: 'terra-13b', - maxContextSize: 200_000, - displayName: 'Terra 13B', - capabilities: ['thinking'], - } as unknown as ModelAlias; const picker = new ModelSelectorComponent({ models: { - 'terra/custom': terra, - 'terra/terra-13b': terra, + opus: { + provider: 'anthropic', + model: 'claude-opus-4-6', + maxContextSize: 200000, + }, }, - currentValue: 'terra/custom', - selectedValue: 'terra/custom', - currentEffort: 'medium', + currentValue: 'opus', + currentThinkingEffort: 'high', onSelect, onCancel: vi.fn(), }); - const lines = text(picker).split('\n').filter((line) => line.includes('Terra 13B')); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain('← current'); + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('[ High ]'); + expect(out).toContain('Max'); + expect(out).toContain('Off'); + expect(out).not.toContain('Xhigh'); + picker.handleInput(RIGHT); picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'medium' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'opus', thinking: 'max' }); + }); + + it('derives official always-on Anthropic models without an Off segment', () => { + const picker = new ModelSelectorComponent({ + models: { + fable: { + provider: 'anthropic', + model: 'claude-fable-5', + maxContextSize: 200000, + }, + }, + currentValue: 'fable', + currentThinkingEffort: 'high', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Xhigh'); + expect(out).toContain('Max'); + expect(out).not.toContain('Off'); + }); + + it('cycles efforts with Left/Right and clamps at the ends', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { pythinker: effortModel('Kimi K2', ['low', 'high', 'max'], 'high') }, + currentValue: 'pythinker', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + // high -> max (Right), then clamp on a second Right. + picker.handleInput(RIGHT); + picker.handleInput(RIGHT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'max' }); + + // max -> high -> low -> off (Left x3), then clamp on another Left. + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput(LEFT); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'off' }); + }); + + it('always-on effort models hide Off and clamp selection at the last effort', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + pythinker: effortModel('Kimi K2', ['low', 'high', 'max'], 'high', ['always_thinking']), + }, + currentValue: 'pythinker', + currentThinkingEffort: 'high', + onSelect, + onCancel: vi.fn(), + }); + + const raw = picker.render(120).join('\n'); + // Off is not surfaced at all — the selectable segments are effort-only. + expect(raw).not.toContain('Off (Unsupported)'); + // The active effort is still highlighted. + expect(strip(raw)).toContain('[ High ]'); + + // Cycling clamps at the last effort and never reaches Off. + picker.handleInput(RIGHT); // high -> max + picker.handleInput(RIGHT); // clamp at max + picker.handleInput('\r'); + expect(onSelect).toHaveBeenLastCalledWith({ alias: 'pythinker', thinking: 'max' }); + }); + + it('keeps the live effort when switching effort-capable models', () => { + const onSelect = vi.fn(); + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Pythinker Other', ['low', 'high', 'max'], 'max'), + }, + currentValue: 'current', + currentThinkingEffort: 'max', + onSelect, + onCancel: vi.fn(), + }); + + // The live effort survives the model switch. + expect(text(picker)).toContain('[ Max ]'); + picker.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ alias: 'other', thinking: 'max' }); + }); + + it('coerces the live effort to the nearest supported level on model switch', () => { + const picker = new ModelSelectorComponent({ + models: { + other: effortModel('Pythinker Other', ['low', 'high']), + }, + currentValue: 'current', + currentThinkingEffort: 'max', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(text(picker)).toContain('[ High ]'); + }); + + it('renders the warning line directly below the key-hint line when provided', () => { + const picker = new ModelSelectorComponent({ + models: { pythinker: model('Kimi K2') }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(hintIdx).toBeGreaterThanOrEqual(0); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + // Model list is pushed below the inserted warning line, not overlapped. + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(hintIdx + 1); + }); + + it('wraps a warning longer than the width instead of truncating it', () => { + const warning = + 'Note: Switching models invalidates the existing prompt cache. Use /new to avoid extra token costs.'; + const picker = new ModelSelectorComponent({ + models: { pythinker: model('Kimi K2') }, + currentValue: 'pythinker', + currentThinkingEffort: 'on', + warning, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = picker.render(50).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('↑↓ navigate')); + expect(lines[hintIdx + 1]).not.toBe(''); + expect(lines[hintIdx + 2]).not.toBe(''); + // Word-wrapped: nothing dropped — the full warning survives across lines. + const squashed = lines.join('').replaceAll(/\s+/g, ''); + expect(squashed).toContain(warning.replaceAll(/\s+/g, '')); + }); +}); + +describe('ModelSelectorComponent overrides', () => { + it('uses overridden support_efforts for selectable efforts', () => { + const picker = new ModelSelectorComponent({ + models: { + pythinker: { + ...effortModel('Kimi K2', ['low', 'high', 'max'], 'max'), + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + currentValue: 'pythinker', + currentThinkingEffort: 'max', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = text(picker); + expect(out).toContain('Low'); + expect(out).toContain('High'); + expect(out).not.toContain('Max'); }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts index 88c72f44..1212b241 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/platform-selector.test.ts @@ -1,146 +1,21 @@ import { describe, expect, it, vi } from 'vitest'; -import { CATALOG_PLATFORM_VALUE_PREFIX } from '@pymodel/pythinker-code-sdk'; +import { OPENAI_CODEX_OAUTH_PLATFORM_ID } from '@pymodel/pythinker-code-oauth'; + import { PlatformSelectorComponent } from '#/tui/components/dialogs/platform-selector'; -import { promptPlatformSelection } from '#/tui/commands/prompts'; const SGR = new RegExp(`${String.fromCodePoint(27)}\\[[0-9;]*m`, 'gu'); -function rendered(component: PlatformSelectorComponent): string { - return component.render(120).join('\n').replaceAll(SGR, ''); -} - describe('PlatformSelectorComponent', () => { - it('features the requested API connections and searches the full catalog', () => { + it('offers OpenAI Codex OAuth without a managed account entry', () => { const onSelect = vi.fn(); - const component = new PlatformSelectorComponent({ - catalog: { - deepseek: { - id: 'deepseek', - name: 'DeepSeek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - }, - 'zai-coding-plan': { - id: 'zai-coding-plan', - name: 'Z.AI Coding Plan', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.z.ai/api/coding/paas/v4', - }, - 'minimax-coding-plan': { - id: 'minimax-coding-plan', - name: 'MiniMax Coding Plan', - npm: '@ai-sdk/anthropic', - api: 'https://api.minimax.io/anthropic/v1', - }, - 'kimi-for-coding': { - id: 'kimi-for-coding', - name: 'Kimi For Coding', - npm: '@ai-sdk/anthropic', - api: 'https://api.kimi.com/coding/v1', - }, - fireworks: { - id: 'fireworks', - name: 'Fireworks AI', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.fireworks.example.test/v1', - }, - vertex: { - id: 'google-vertex-anthropic', - name: 'Vertex Anthropic', - npm: '@ai-sdk/google-vertex/anthropic', - }, - }, - onSelect, - onCancel: vi.fn(), - }); + const component = new PlatformSelectorComponent({ onSelect, onCancel: vi.fn() }); + const output = component.render(100).join('\n').replaceAll(SGR, ''); - const output = rendered(component); expect(output).toContain('OpenAI Codex (OAuth)'); - expect(output).not.toContain('Kimi (OAuth)'); - expect(output.indexOf('OpenAI Codex (OAuth)')).toBeLessThan(output.indexOf('DeepSeek API')); - expect(output).toContain('DeepSeek API'); - expect(output).toContain('GLM Coding Plan'); - expect(output).toContain('MiniMax Token Plan'); - expect(output).toContain('Kimi For Coding'); - expect(output.indexOf('DeepSeek API')).toBeLessThan(output.indexOf('Fireworks AI')); - expect(output).not.toContain('Vertex Anthropic'); + expect(output).not.toContain('Pythinker (OAuth)'); - component.handleInput('f'); - expect(rendered(component)).toContain('Search: f'); - expect(rendered(component)).toContain('Fireworks AI'); component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith(`${CATALOG_PLATFORM_VALUE_PREFIX}fireworks`); - }); - - it('omits featured providers that are missing or do not support one-key connections', () => { - const component = new PlatformSelectorComponent({ - catalog: { - deepseek: { - id: 'deepseek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - }, - 'kimi-for-coding': { - id: 'kimi-for-coding', - npm: '@ai-sdk/google-vertex/anthropic', - api: 'https://api.kimi.com/coding/v1', - }, - }, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const output = rendered(component); - expect(output).toContain('DeepSeek API'); - const values = ( - component as unknown as { readonly opts: { readonly options: readonly { readonly value: string }[] } } - ).opts.options.map((option) => option.value); - expect(values).toContain(`${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`); - expect(values).not.toContain(`${CATALOG_PLATFORM_VALUE_PREFIX}zai-coding-plan`); - expect(values).not.toContain(`${CATALOG_PLATFORM_VALUE_PREFIX}minimax-coding-plan`); - expect(values).not.toContain(`${CATALOG_PLATFORM_VALUE_PREFIX}kimi-for-coding`); - }); - - it('loads the live catalog before returning a connection selection', async () => { - const fetchMock = vi.fn(async () => - new Response( - JSON.stringify({ - deepseek: { - id: 'deepseek', - name: 'DeepSeek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ); - vi.stubGlobal('fetch', fetchMock); - let mounted: PlatformSelectorComponent | undefined; - const host = { - cancelInFlight: undefined, - mountEditorReplacement: (component: PlatformSelectorComponent) => { - mounted = component; - }, - restoreEditor: vi.fn(), - showLoginProgressSpinner: vi.fn(() => ({ stop: vi.fn() })), - showStatus: vi.fn(), - }; - - try { - const selection = promptPlatformSelection(host as never); - await vi.waitFor(() => expect(mounted).toBeDefined()); - mounted!.handleInput('d'); - mounted!.handleInput('\r'); - - await expect(selection).resolves.toMatchObject({ - platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, - catalog: { deepseek: { id: 'deepseek' } }, - }); - expect(fetchMock).toHaveBeenCalledOnce(); - } finally { - vi.unstubAllGlobals(); - } + expect(onSelect).toHaveBeenCalledWith(OPENAI_CODEX_OAUTH_PLATFORM_ID); }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts index f7f4801f..4134e9d6 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -1,30 +1,22 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import type { PluginSummary } from '@pymodel/pythinker-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import chalk from 'chalk'; +import type { CapabilityStatus, PluginSummary } from '@pymodel/pythinker-code-sdk'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, + type PluginInstallTrustConfirmResult, type PluginMcpSelection, type PluginRemoveConfirmResult, + type PluginsPanelSelection, } from '#/tui/components/dialogs/plugins-selector'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -import { darkColors } from '#/tui/theme/colors'; -import { pluginTrustLabel } from '#/tui/utils/plugin-source-label'; -import type { - PluginMarketplace, - PluginMarketplaceEntry, -} from '#/utils/plugin-marketplace'; - -const ANSI_SGR = /\[[0-9;]*m/g; -const MID = '\u00B7'; -const ESC = String.fromCodePoint(27); -const RIGHT = `${ESC}[C`; -const LEFT = `${ESC}[D`; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; +import { isOfficialPluginInstall, isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; + +const ANSI_SGR = /\u001B\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, '').replaceAll('\u276F', '?'); @@ -48,47 +40,84 @@ function dangerShortcut(text: string): string { return withAnsiColors(() => chalk.hex(darkColors.error).bold(text)); } -function marketplace( - plugins: readonly PluginMarketplaceEntry[], -): PluginMarketplace { - return { - format: 'pythinker', - source: '/tmp/marketplace.json', - sourceLabel: '/tmp/marketplace.json', - name: 'Example marketplace', - plugins, - }; +function warningMark(): string { + // Opening ANSI escape for the warning color; the install-trust notice is the + // only element in that dialog using it, so its presence confirms the tone. + return withAnsiColors(() => chalk.hex(darkColors.warning)('\u0001').split('\u0001')[0]!); } -function marketplaceEntry( - overrides: Partial<PluginMarketplaceEntry> = {}, -): PluginMarketplaceEntry { - const source = 'https://example.com/example.zip'; - return { - id: 'example', - displayName: 'Example', - source, - sourceLabel: source, - marketplaceName: 'Example marketplace', - supportedComponents: ['skills'], - unsupportedComponents: [], - install: { kind: 'supported', source, options: {} }, - ...overrides, - }; +const superpowers = { + id: 'superpowers', + displayName: 'Superpowers', + version: '5.1.0', + enabled: true, + state: 'ok' as const, + skillCount: 14, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'local-path' as const, +}; + +const officialEntries = [ + { + id: 'pythinker-datasource', + tier: 'official' as const, + displayName: 'Pythinker Datasource', + description: 'Query supported data sources', + version: '3.1.1', + source: 'https://x/d.zip', + keywords: ['data'], + }, +]; +const thirdPartyEntries = [ + { id: 'superpowers', tier: 'curated' as const, displayName: 'Superpowers', source: 'https://x/s.zip' }, +]; +const marketplaceEntries = [...officialEntries, ...thirdPartyEntries]; + +function makePanel(opts: { + installed?: readonly PluginSummary[]; + capabilities?: readonly CapabilityStatus[]; + catalogIsDefault?: boolean; + initialTab?: 'installed' | 'official' | 'third-party' | 'custom'; + selectedId?: string; + pluginHint?: { id: string; text: string }; +}) { + const installed = opts.installed ?? []; + const onSelect = vi.fn<(s: PluginsPanelSelection) => void>(); + const onRequestMarketplace = vi.fn(); + const panel = new PluginsPanelComponent({ + installed, + installedIds: new Set(installed.map((p) => p.id)), + capabilities: opts.capabilities, + catalogIsDefault: opts.catalogIsDefault, + initialTab: opts.initialTab, + selectedId: opts.selectedId, + pluginHint: opts.pluginHint, + onSelect, + onCancel: vi.fn(), + onRequestMarketplace, + }); + return { panel, onSelect, onRequestMarketplace }; } -function pluginSummary(id: string, version?: string): PluginSummary { +function makeCapability(overrides: Partial<CapabilityStatus> = {}): CapabilityStatus { return { - id, - displayName: id, - version, - enabled: true, - state: 'ok', - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', + id: 'pythinker-cu', + displayName: 'Pythinker Computer Use', + description: 'Background GUI automation', + supported: true, + state: 'partial', + steps: [ + { id: 'plugin', state: 'ok' }, + { id: 'app', state: 'ok' }, + { id: 'service', state: 'ok' }, + { id: 'permissions', state: 'missing', detail: 'screenRecording' }, + ], + install: { running: false }, + ...overrides, }; } @@ -102,9 +131,11 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', - originalSource: 'https://code.pythinker.com/pythinker-code/plugins/official/pythinker-datasource.zip', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', })).toBe('official'); expect(pluginTrustLabel({ id: 'superpowers', @@ -114,10 +145,26 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', - originalSource: 'https://code.pythinker.com/pythinker-code/plugins/curated/superpowers.zip', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/curated/superpowers.zip', })).toBe('curated'); + expect(pluginTrustLabel({ + id: 'pythinker-cu', + displayName: 'Pythinker Computer Use', + enabled: true, + state: 'ok', + skillCount: 1, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://cdn.kimi.com/pythinker-computer-use/latest/pythinker-cu-plugin.zip', + })).toBe('official'); expect(pluginTrustLabel({ id: 'demo', displayName: 'Demo', @@ -126,9 +173,11 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'zip-url', - originalSource: 'https://code.pythinker.com/demo.zip', + originalSource: 'https://code.kimi.com/demo.zip', })).toBe('third-party'); expect(pluginTrustLabel({ id: 'local', @@ -138,372 +187,695 @@ describe('plugins selector dialogs', () => { skillCount: 0, mcpServerCount: 0, enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', - originalSource: 'https://code.pythinker.com/pythinker-code/plugins/official/local', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/local', })).toBe('third-party'); }); - it('renders installed plugins as selectable overview entries', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); + it('recognizes installed plugins by official provenance', () => { + const base = { + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + enabled: true, + state: 'ok' as const, + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + }; + // Zip installs from the official CDN path. + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + })).toBe(true); + expect(isOfficialPluginInstall({ + ...base, + id: 'pythinker-cu', + displayName: 'Pythinker Computer Use', + source: 'zip-url', + originalSource: 'https://cdn.kimi.com/pythinker-computer-use/latest/pythinker-cu-plugin.zip', + })).toBe(true); + // Same manifest id from a local path, GitHub, a loopback URL, or a + // third-party URL is not the official build. + expect(isOfficialPluginInstall({ ...base, source: 'local-path' })).toBe(false); + expect(isOfficialPluginInstall({ ...base, source: 'github' })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'http://127.0.0.1:58627/pythinker-code/plugins/official/pythinker-datasource.zip', + })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://example.test/pythinker-code/plugins/official/pythinker-datasource.zip', + })).toBe(false); + }); - const raw = renderRaw(picker); - const out = strip(raw); - expect(out).toContain('Installed plugins (1)'); - expect(out).toContain('Actions'); - expect(out).toContain('? Pythinker Datasource enabled'); - expect(out).toContain(`id pythinker-datasource ${MID} 2 skills ${MID} MCP 1/1`); - expect(out).not.toContain('Space disable'); - expect(out).not.toContain('Enter info'); - expect(out).toContain('Space toggle · M MCP servers · D remove · Enter details'); - expect(out).toContain('Marketplace'); - expect(out).toContain('Summary'); + it('shows installed Pythinker Computer Use and WebBridge plugins as official', () => { + const installed: PluginSummary[] = [ + { + ...superpowers, + id: 'pythinker-cu', + displayName: 'Pythinker Computer Use', + source: 'zip-url', + originalSource: 'https://cdn.kimi.com/pythinker-computer-use/latest/pythinker-cu-plugin.zip', + }, + { + ...superpowers, + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + source: 'zip-url', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-webbridge.zip', + }, + ]; - picker.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ kind: 'info', id: 'pythinker-datasource' }); - }); - - it('ignores Left/Right arrows in the overview (no enter/exit by arrow)', () => { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 2, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel, - }); + const { panel } = makePanel({ installed }); + const out = strip(renderRaw(panel)); - picker.handleInput(RIGHT); // must NOT open details - expect(onSelect).not.toHaveBeenCalled(); - picker.handleInput(LEFT); // must NOT cancel/exit - expect(onCancel).not.toHaveBeenCalled(); + expect(out).toContain('id pythinker-cu'); + expect(out).toContain('via cdn.kimi.com · official'); + expect(out).toContain('id pythinker-webbridge'); + expect(out).toContain('via code.kimi.com · official'); }); - it('renders a searchable marketplace list with persistent bounded details', () => { - const onSelect = vi.fn(); - const entry = marketplaceEntry({ - id: 'superpowers', - displayName: 'Superpowers', - version: '5.1.0', - description: 'Workflow skills for planning and review.', - author: { name: 'Example Author' }, - category: 'productivity', - supportedComponents: ['skills', 'mcpServers'], - unsupportedComponents: ['hooks'], - }); - const picker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([entry]), - installed: new Map(), - onSelect, - onCancel: vi.fn(), - }); + it('treats only the official Pythinker CDN path as a trusted install source', () => { + expect(isOfficialPluginSource('https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip')).toBe(true); + expect(isOfficialPluginSource('https://cdn.kimi.com/pythinker-computer-use/latest/pythinker-cu-plugin.zip')).toBe(true); + expect( + isOfficialPluginSource( + 'https://cdn.kimi.com/pythinker-computer-use-windows/latest/pythinker-cu-win-plugin.zip', + ), + ).toBe(true); + // Curated and other Pythinker CDN paths are not "official" for the install gate. + expect(isOfficialPluginSource('https://code.kimi.com/pythinker-code/plugins/curated/superpowers.zip')).toBe(false); + expect(isOfficialPluginSource('https://code.kimi.com/pythinker-code/plugins/foo.zip')).toBe(false); + expect(isOfficialPluginSource('https://cdn.kimi.com/unrelated/plugin.zip')).toBe(false); + // Non-Pythinker hosts (loopback included), non-https schemes, local paths, and + // GitHub sources are unofficial. + expect(isOfficialPluginSource('https://example.test/pythinker-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://code.kimi.com/pythinker-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://127.0.0.1:58627/pythinker-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('./plugins/pythinker-datasource')).toBe(false); + expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); + expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); + expect(isOfficialPluginSource('not a url')).toBe(false); + }); - const lines = picker.render(80).map(strip); - const out = lines.join('\n'); - expect(out).toContain('Example marketplace (1) (type to search)'); - expect(out).toContain('? Superpowers install · v5.1.0'); - expect(out).toContain('Details · Superpowers'); - expect(out).toContain(`id superpowers ${MID} author Example Author ${MID} category productivity`); - expect(out).toContain('Pythinker trust third-party'); - expect(out).toContain('Supported: skills, MCP'); - expect(out).toContain('not run: hooks'); - expect(out).not.toContain('Actions'); - expect(out).not.toContain('Back to installed plugins'); - expect(lines.filter((line) => /^─+$/.test(line))).toHaveLength(2); - expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true); + it('opens on the Installed tab with the four panel tabs', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Plugins'); + expect(out).toContain('Installed'); + expect(out).toContain('Official'); + expect(out).toContain('Curated'); + expect(out).toContain('Custom'); + expect(out).toContain('? Superpowers enabled'); + expect(out).toContain('Space toggle'); + expect(out).toContain('1 installed'); + }); - picker.handleInput('\r'); + it('repaints from the current theme palette without remounting', () => { + const { panel } = makePanel({ installed: [superpowers] }); + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkOut = renderRaw(panel); + currentTheme.setPalette(lightColors); + const lightOut = renderRaw(panel); + // A palette snapshot cached at construction would render identically + // after the switch; reading currentTheme.palette at render time must + // produce different ANSI output for the same panel instance. + expect(darkOut).not.toBe(lightOut); + } finally { + currentTheme.setPalette(previous); + } + }); + + it('toggles an installed plugin with Space', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput(' '); + expect(onSelect).toHaveBeenCalledWith({ kind: 'toggle', id: 'superpowers', enabled: false }); + }); + + it('routes D / M / R / Enter to remove / mcp / reload / details on the Installed tab', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers] }); + panel.handleInput('d'); + panel.handleInput('m'); + panel.handleInput('r'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'superpowers' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'reload' }); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('Enter on an installed plugin with an available update installs it', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('treats printable i and Space as search and uses two-stage Escape', () => { - const onSelect = vi.fn(); - const onCancel = vi.fn(); - const picker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([ - marketplaceEntry({ id: 'initial-tools', displayName: 'Initial Tools' }), - marketplaceEntry({ id: 'review', displayName: 'Review' }), - ]), - installed: new Map(), - onSelect, - onCancel, + it('Enter on an up-to-date installed plugin opens details', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('I on an installed plugin opens details even when an update is available', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel, onSelect } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + panel.handleInput('i'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'superpowers' }); + }); + + it('renders the inline plugin hint on the installed row', () => { + const datasource = { ...superpowers, id: 'pythinker-datasource', displayName: 'Pythinker Datasource', skillCount: 1 }; + const { panel } = makePanel({ + installed: [datasource], + selectedId: 'pythinker-datasource', + pluginHint: { id: 'pythinker-datasource', text: 'pending /new' }, }); + const out = strip(renderRaw(panel)); + expect(out).toContain('? Pythinker Datasource enabled pending /new'); + }); - picker.handleInput(`${ESC}[105u`); - picker.handleInput(`${ESC}[32u`); - for (const character of 'tools') picker.handleInput(character); + it('lazily loads the Official catalog, then lists installed entries first', () => { + const { panel, onRequestMarketplace } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + expect(onRequestMarketplace).toHaveBeenCalledTimes(1); + expect(strip(renderRaw(panel))).toContain('Loading marketplace'); + + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker Datasource install'); + expect(out).toContain('Query supported data sources'); + expect(out).not.toContain('Query supported data sources · v3.1.1'); + expect(out).not.toContain('id pythinker-datasource'); + expect(out).not.toContain('Official plugin'); + expect(out).not.toContain('· data'); + expect(out).toContain('0 installed · 1 available'); + }); - const searched = strip(picker.render(80).join('\n')); - expect(searched).toContain('Search: i tools'); - expect(searched).toContain('? Initial Tools'); - expect(searched).not.toContain('Review'); - expect(onSelect).not.toHaveBeenCalled(); + it('renders the hardcoded Web Bridge entry on the Official tab while loading', () => { + const { panel } = makePanel({ initialTab: 'official' }); + // The catalog is still loading, but the built-in Web Bridge entry is shown + // immediately because it is baked into the TUI, not fetched. + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker WebBridge open in browser'); + expect(out).toContain('Loading marketplace'); + }); - picker.handleInput(ESC); - expect(onCancel).not.toHaveBeenCalled(); - expect(strip(picker.render(80).join('\n'))).not.toContain('Search:'); - picker.handleInput(ESC); - expect(onCancel).toHaveBeenCalledOnce(); + it('keeps the Web Bridge entry visible when the Official catalog errors', () => { + const { panel } = makePanel({ initialTab: 'official' }); + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker WebBridge open in browser'); + expect(out).toContain('Marketplace unavailable: fetch failed'); }); - it('pages a large catalog without rendering every entry', () => { - const entries = Array.from({ length: 10 }, (_, index) => - marketplaceEntry({ id: `plugin-${index}`, displayName: `Plugin ${index}` }), - ); - const picker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace(entries), - installed: new Map(), - onSelect: vi.fn(), - onCancel: vi.fn(), + it('renders a same-id custom catalog row as a normal plugin, without capability state', () => { + // A custom marketplace may legitimately list an entry reusing the + // pythinker-webbridge id: without the capability: marker it must render and + // install as a plain plugin, not borrow capability status. + const capabilities = [makeCapability({ id: 'pythinker-webbridge', displayName: 'Pythinker WebBridge' })]; + const entries = [ + { + id: 'pythinker-webbridge', + tier: 'official' as const, + displayName: 'Pythinker WebBridge (fork)', + source: 'https://x/fork.zip', + }, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'official', capabilities }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker WebBridge (fork) install'); + + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-webbridge', source: 'https://x/fork.zip' }), }); + }); - const firstPage = strip(picker.render(80).join('\n')); - expect(firstPage).toContain('Plugin 0'); - expect(firstPage).toContain('Plugin 3'); - expect(firstPage).not.toContain('Plugin 4'); - expect(firstPage).toContain('▼ 6 more'); - - picker.handleInput(`${ESC}[6~`); - const secondPage = strip(picker.render(80).join('\n')); - expect(secondPage).toContain('? Plugin 4'); - expect(secondPage).not.toContain('Plugin 0'); - }); - - it('shows installed and update states from full plugin summaries', () => { - const update = marketplaceEntry({ id: 'update', displayName: 'Update', version: '2.0.0' }); - const current = marketplaceEntry({ id: 'current', displayName: 'Current', version: '1.0.0' }); - const picker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([update, current]), - installed: new Map([ - ['update', pluginSummary('update', '1.0.0')], - ['current', pluginSummary('current', '1.0.0')], - ]), - onSelect: vi.fn(), - onCancel: vi.fn(), + it('renders capability rows from the engine while the catalog is still loading', () => { + const capabilities = [ + makeCapability(), + makeCapability({ + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + state: 'not_installed', + steps: [], + }), + ]; + const { panel, onSelect } = makePanel({ initialTab: 'official', capabilities }); + + // No setMarketplace yet — built-in runtime setup must not wait on the + // remote catalog: the engine-known rows render (and the promo is + // suppressed by the real webbridge row). + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker Computer Use install'); + expect(out).toContain('Pythinker WebBridge install'); + expect(out).toContain('Background GUI automation'); + expect(out).not.toContain('id pythinker-cu'); + expect(out).not.toContain('Official plugin'); + expect(out).not.toContain('open in browser'); + expect(out).toContain('Loading marketplace'); + + panel.handleInput('\r'); // index 0 → pythinker-cu routes to capability install + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-cu', source: 'capability:pythinker-cu' }), + }); + panel.handleInput(''); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-webbridge', source: 'capability:pythinker-webbridge' }), }); + }); + + it('keeps built-in rows out while the overridden marketplace is loading', () => { + // /plugins marketplace <url> or the env override must be able to fully + // replace the Official tab — fallback capability rows stay out too. + const capabilities = [makeCapability()]; + const { panel } = makePanel({ initialTab: 'official', capabilities, catalogIsDefault: false }); - const out = strip(picker.render(80).join('\n')); - expect(out).toContain('Update update 1.0.0 → 2.0.0'); - expect(out).toContain(`Current installed ${MID} v1.0.0`); + const out = strip(renderRaw(panel)); + expect(out).not.toContain('Pythinker Computer Use'); + expect(out).toContain('Pythinker WebBridge open in browser'); + expect(out).toContain('Loading marketplace'); }); - it('reports unavailable entries without invoking install and locks duplicate submits', () => { - const unavailableSelect = vi.fn(); - const unavailable = marketplaceEntry({ - id: 'npm-plugin', - displayName: 'NPM Plugin', - install: { kind: 'unsupported', reason: 'npm plugin sources are not supported.' }, - }); - const unavailablePicker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([unavailable]), - installed: new Map(), - onSelect: unavailableSelect, - onCancel: vi.fn(), + it('opens the Web Bridge webpage on Enter instead of installing', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + // Web Bridge is pinned at index 0, so Enter selects it directly. + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'open-url', + url: 'https://www.kimi.com/features/webbridge#local-agent', + label: 'Pythinker WebBridge', }); + }); - expect(strip(unavailablePicker.render(80).join('\n'))).toContain('unavailable'); - unavailablePicker.handleInput('\r'); - expect(unavailableSelect).toHaveBeenCalledWith({ - kind: 'unavailable', - entry: unavailable, - reason: 'npm plugin sources are not supported.', + it('installs a catalog official entry after navigating past Web Bridge', () => { + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\u001B[B'); // ↓ → pythinker-datasource + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-datasource' }), }); + }); - const installSelect = vi.fn(); - const installPicker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([marketplaceEntry()]), - installed: new Map(), - onSelect: installSelect, - onCancel: vi.fn(), + it('lets the real catalog entry win over the pinned Web Bridge promo', () => { + const entries = [ + { + id: 'pythinker-webbridge', + tier: 'official' as const, + displayName: 'Pythinker WebBridge', + source: 'capability:pythinker-webbridge', + }, + ...officialEntries, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'official' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + // Exactly one row, and it is the installable catalog copy — the hardcoded + // open-in-browser promo is suppressed. + expect(out.split('Pythinker WebBridge').length - 1).toBe(1); + expect(out).not.toContain('open in browser'); + panel.handleInput('\r'); // index 0 → the real entry installs + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-webbridge', source: 'capability:pythinker-webbridge' }), }); - installPicker.handleInput('\r'); - installPicker.handleInput('\r'); - expect(installSelect).toHaveBeenCalledOnce(); - }); - - it('keeps status visible and every line within a narrow width', () => { - const picker = new PluginMarketplaceSelectorComponent({ - marketplace: marketplace([marketplaceEntry({ - displayName: 'A very long marketplace plugin name with Launch 🚀 tools', - })]), - installed: new Map(), - onSelect: vi.fn(), - onCancel: vi.fn(), + }); + + it('installs a Curated entry whose id matches the pinned WebBridge', () => { + // A curated/custom marketplace entry can legitimately reuse the + // pythinker-webbridge id; on the Curated tab it must install normally, not + // open the WebBridge page (that shortcut is reserved for the pinned row). + const entries = [ + { + id: 'pythinker-webbridge', + tier: 'curated' as const, + displayName: 'Pythinker WebBridge', + source: 'capability:pythinker-webbridge', + }, + ]; + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Curated'); + expect(out).toContain('Third-party plugins from our partners.'); + expect(out).toContain('Pythinker WebBridge install'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'pythinker-webbridge', source: 'capability:pythinker-webbridge' }), }); + }); - const lines = picker.render(40).map(strip); - expect(lines.join('\n')).toContain('install'); - expect(lines.every((line) => visibleWidth(line) <= 40)).toBe(true); - }); - - it('toggles an installed plugin from the overview with space', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), + it('installs the selected Curated entry on Enter', () => { + const { panel, onSelect } = makePanel({ installed: [superpowers], initialTab: 'third-party' }); + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), }); + }); - picker.handleInput(' '); + it('renders an installing state while an install is in progress', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.setInstalling('Superpowers'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Installing Superpowers…'); + }); + it('keeps a valid selection if ↓ is pressed while the catalog is loading', () => { + const { panel, onSelect } = makePanel({ initialTab: 'third-party' }); + // Catalog still loading (entries empty); pressing ↓ must not drive the + // selection negative, or the later Enter would read entries[-1]. + panel.handleInput('\u001B[B'); // ↓ + panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); + panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ - kind: 'toggle', - id: 'pythinker-datasource', - enabled: false, + kind: 'install', + entry: expect.objectContaining({ id: 'superpowers' }), }); }); - it('uses remapped Select and Plugin actions without consuming unbound keys', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'first', displayName: 'First', enabled: true, state: 'ok', skillCount: 0, - mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, source: 'local-path', - }, - { - id: 'second', displayName: 'Second', enabled: true, state: 'ok', skillCount: 0, - mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); - picker.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Select', bindings: { down: null, 'alt+n': 'select:next' } }, - { - context: 'Plugin', - bindings: { - space: null, - 'd x': 'plugin:install', - 'x y': 'plugin:toggle', - 'ctrl+k ctrl+t': 'plugin:toggle', - 'ctrl+x ctrl+y': 'plugin:toggle', - }, - }, - ]), - ]); + it('shows untiered custom marketplace entries without the partner description', () => { + const untiered = [ + { id: 'custom-plugin', displayName: 'Custom Plugin', source: 'https://x/c.zip' }, + ]; + const { panel } = makePanel({ initialTab: 'third-party', catalogIsDefault: false }); + panel.setMarketplace(untiered, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Custom Plugin install'); + expect(out).not.toContain('Third-party plugins from our partners.'); + }); - picker.handleInput(`${ESC}[B`); - expect(strip(picker.render(120).join('\n'))).toContain('? First'); - picker.handleInput(' '); - expect(onSelect).not.toHaveBeenCalled(); - expect(strip(picker.render(120).join('\n'))).toContain('? First'); - picker.handleInput('\u001Bn'); - expect(strip(picker.render(120).join('\n'))).toContain('? Second'); - picker.handleInput('d'); - expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'remove', id: 'second' }); - picker.handleInput('x'); - picker.handleInput('y'); - picker.handleInput(String.fromCodePoint(0x0b)); - picker.handleInput(String.fromCodePoint(0x14)); - picker.handleInput('ctrl+x'); - picker.handleInput('ctrl+y'); - - expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'toggle', id: 'second', enabled: false }); - expect(onSelect).toHaveBeenNthCalledWith(3, { kind: 'toggle', id: 'second', enabled: false }); - expect(onSelect).toHaveBeenNthCalledWith(4, { kind: 'toggle', id: 'second', enabled: false }); - expect(strip(picker.render(120).join('\n'))).toContain('alt+n navigate'); - }); - - it('issues a remove request from the overview on D', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); + it('shows an update badge when the marketplace version is newer than installed', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers update 4.0.0 → 5.0.0'); + }); - picker.handleInput('d'); - - expect(onSelect).toHaveBeenCalledWith({ kind: 'remove', id: 'pythinker-datasource' }); - }); - - it('opens MCP server management from the overview on M', () => { - const onSelect = vi.fn(); - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 1, - enabledMcpServerCount: 1, - hasErrors: false, - source: 'local-path', - }, - ], - onSelect, - onCancel: vi.fn(), - }); + it('shows an update badge on the Installed tab when the marketplace version is newer', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers enabled update 4.0.0 → 5.0.0'); + }); + + it('updates the Windows backing plugin through its capability entry', () => { + const installed = [ + { + ...superpowers, + id: 'pythinker-cu-win', + displayName: 'Pythinker Computer Use for Windows', + version: '0.2.13', + }, + ]; + const capability = makeCapability({ pluginId: 'pythinker-cu-win' }); + const entry = { + id: 'pythinker-cu', + tier: 'official' as const, + displayName: 'Pythinker Computer Use', + version: '0.2.14', + source: 'capability:pythinker-cu', + builtIn: true, + }; + const { panel, onSelect } = makePanel({ installed, capabilities: [capability] }); + panel.setMarketplace([entry], '/tmp/marketplace.json'); + + expect(strip(renderRaw(panel))).toContain( + 'Pythinker Computer Use for Windows enabled update 0.2.13 → 0.2.14', + ); + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'install', entry }); + }); + + it('keeps installation state separate from capability readiness', () => { + const installed = [ + { ...superpowers, id: 'pythinker-cu', displayName: 'Pythinker Computer Use', version: '0.5.4' }, + ]; + const capabilities = [makeCapability()]; + const entries = [ + { + id: 'pythinker-cu', + tier: 'official' as const, + displayName: 'Pythinker Computer Use', + version: '0.5.4', + source: 'capability:pythinker-cu', + builtIn: true, + }, + ]; + const { panel } = makePanel({ installed, capabilities }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + + const installedOut = strip(renderRaw(panel)); + expect(installedOut).toContain('Pythinker Computer Use enabled'); + expect(installedOut).not.toContain('setup incomplete'); + expect(installedOut).not.toContain('needs permissions'); + + panel.handleInput('\t'); + const officialOut = strip(renderRaw(panel)); + expect(officialOut).toContain('Pythinker Computer Use installed · v0.5.4'); + expect(officialOut).toContain('1 installed · 0 available'); + expect(officialOut).not.toContain('needs permissions'); + }); + + it('uses the Windows backing plugin id for Official installation state', () => { + const installed = [ + { + ...superpowers, + id: 'pythinker-cu-win', + displayName: 'Pythinker Computer Use for Windows', + version: '0.2.14', + }, + ]; + const capabilities = [makeCapability({ pluginId: 'pythinker-cu-win' })]; + const entries = [ + { + id: 'pythinker-cu', + tier: 'official' as const, + displayName: 'Pythinker Computer Use', + version: '0.2.14', + source: 'capability:pythinker-cu', + builtIn: true, + }, + ]; + const { panel } = makePanel({ installed, capabilities, initialTab: 'official' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker Computer Use installed · v0.2.14'); + expect(out).toContain('1 installed · 0 available'); + }); + + it('keeps Enter on the Installed tab consistent with other plugins', () => { + const installed = [ + { ...superpowers, id: 'pythinker-cu', displayName: 'Pythinker Computer Use', version: '0.5.4' }, + ]; + const { panel, onSelect } = makePanel({ installed, capabilities: [makeCapability()] }); + + panel.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'pythinker-cu' }); + }); + + it('keeps unsupported capability diagnostics out of the Installed list', () => { + const installed = [ + { ...superpowers, id: 'pythinker-cu', displayName: 'Pythinker Computer Use', version: '0.5.4' }, + ]; + const capabilities = [ + makeCapability({ supported: false, state: 'unsupported', steps: [] }), + ]; + const { panel, onSelect } = makePanel({ installed, capabilities }); + + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker Computer Use enabled'); + expect(out).not.toContain('unsupported'); + + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ kind: 'details', id: 'pythinker-cu' }); + }); + + it('does not expose capability readiness, version, or optional issues in the marketplace', () => { + const capabilities = [ + makeCapability({ + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + state: 'ready', + version: 'v1.11.5', + steps: [ + { id: 'daemon-binary', state: 'ok' }, + { id: 'daemon', state: 'ok' }, + { id: 'skill', state: 'ok' }, + { id: 'extension', state: 'missing', optional: true }, + ], + }), + ]; + const installed = [ + { ...superpowers, id: 'pythinker-webbridge', displayName: 'Pythinker WebBridge', version: '1.11.3' }, + ]; + const { panel } = makePanel({ installed, capabilities, initialTab: 'official' }); + panel.setMarketplace( + [{ id: 'pythinker-webbridge', displayName: 'Pythinker WebBridge', source: 'capability:pythinker-webbridge', tier: 'official', builtIn: true }], + '/tmp/marketplace.json', + ); + + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker WebBridge installed'); + expect(out).not.toContain('ready'); + expect(out).not.toContain('v1.11.5'); + expect(out).not.toContain('browser extension'); + }); + + it('keeps capability repair details out of marketplace rows', () => { + const capabilities = [ + makeCapability({ + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + state: 'partial', + steps: [ + { id: 'daemon-binary', state: 'ok' }, + { id: 'daemon', state: 'ok' }, + { id: 'skill', state: 'missing' }, + { id: 'skill-shadow', state: 'failed', optional: true }, + ], + }), + ]; + const { panel } = makePanel({ capabilities, initialTab: 'official' }); + panel.setMarketplace( + [{ id: 'pythinker-webbridge', displayName: 'Pythinker WebBridge', source: 'capability:pythinker-webbridge', tier: 'official', builtIn: true }], + '/tmp/marketplace.json', + ); + + const out = strip(renderRaw(panel)); + expect(out).toContain('Pythinker WebBridge install'); + expect(out).not.toContain('agent skill'); + expect(out).not.toContain('skill shadows'); + }); + + it('does not show an update badge on the Installed tab before the marketplace loads', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '4.0.0' }]; + const { panel } = makePanel({ installed }); + // The marketplace has not been loaded yet, so the badge stays hidden rather + // than guessing. + const out = strip(renderRaw(panel)); + expect(out).not.toContain('update'); + }); + + it('shows installed · v<version> when the installed plugin is up to date', () => { + const installed = [{ ...superpowers, id: 'superpowers', version: '5.0.0' }]; + const entries = [ + { + id: 'superpowers', + tier: 'curated' as const, + displayName: 'Superpowers', + version: '5.0.0', + source: 'https://x/s.zip', + }, + ]; + const { panel } = makePanel({ installed, initialTab: 'third-party' }); + panel.setMarketplace(entries, '/tmp/marketplace.json'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Superpowers installed · v5.0.0'); + }); + + it('shows an inline error when the Official catalog fails', () => { + const { panel } = makePanel({ installed: [superpowers] }); + panel.handleInput('\t'); // → Official + panel.setMarketplaceError('fetch failed'); + const out = strip(renderRaw(panel)); + expect(out).toContain('Marketplace unavailable: fetch failed'); + expect(out).toContain('Use the Custom tab'); + }); - picker.handleInput('m'); + it('installs from a URL typed on the Custom tab', () => { + const { panel, onSelect } = makePanel({ initialTab: 'custom' }); + const out = strip(renderRaw(panel)); + expect(out).toContain('Install from a GitHub URL'); + expect(out).toContain('╭'); - expect(onSelect).toHaveBeenCalledWith({ kind: 'mcp', id: 'pythinker-datasource' }); + for (const ch of 'https://github.com/owner/repo') { + panel.handleInput(ch); + } + panel.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith({ + kind: 'install-source', + source: 'https://github.com/owner/repo', + }); }); it('toggles MCP servers from the MCP selector', () => { @@ -518,6 +890,8 @@ describe('plugins selector dialogs', () => { skillCount: 1, mcpServerCount: 1, enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z', @@ -555,33 +929,6 @@ describe('plugins selector dialogs', () => { ]); }); - it('renders plugin action hints inline on the overview row', () => { - const picker = new PluginsOverviewSelectorComponent({ - plugins: [ - { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - version: '1.0.0', - enabled: true, - state: 'ok', - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - source: 'local-path', - }, - ], - selectedId: 'pythinker-datasource', - pluginHint: { id: 'pythinker-datasource', text: 'pending /new' }, - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = picker.render(120).map(strip).join('\n'); - - expect(out).toContain('? Pythinker Datasource enabled pending /new'); - }); - it('defaults plugin removal confirmation to cancel', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ @@ -602,7 +949,7 @@ describe('plugins selector dialogs', () => { expect(results).toEqual([{ kind: 'cancel' }]); }); - it('keeps raw Enter and Space unbound in plugin removal confirmation', () => { + it('confirms plugin removal only after choosing remove', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ id: 'pythinker-datasource', @@ -611,65 +958,57 @@ describe('plugins selector dialogs', () => { results.push(result); }, }); - picker.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Select', bindings: { enter: null, space: null } }, - ]), - ]); - const hint = strip(picker.render(120).join('\n')).split('\n')[2] ?? ''; - expect(hint).not.toContain('Enter'); - expect(hint).not.toContain('Space'); + picker.handleInput('\u001B[B'); + const raw = renderRaw(picker); + expect(strip(raw)).toContain('Enter/Space select'); + // The destructive option label keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Remove plugin')); + picker.handleInput('\r'); - picker.handleInput(' '); - expect(results).toEqual([]); + expect(results).toEqual([{ kind: 'confirm' }]); }); - it('keeps raw Space available to searchable Select queries', () => { - const onSelect = vi.fn(); - const picker = new ChoicePickerComponent({ - title: 'Search plugins', - options: [ - { value: 'alpha-beta', label: 'Alpha Beta' }, - { value: 'alphabet', label: 'Alphabet' }, - ], - searchable: true, - onSelect, - onCancel: vi.fn(), + it('defaults the third-party install trust prompt to exit', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', + onDone: (result) => { + results.push(result); + }, }); - picker.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Select', bindings: { space: null } }, - ]), - ]); - for (const character of 'alpha beta') picker.handleInput(character); + const raw = renderRaw(picker); + const out = raw.split('\n').map(strip); + expect(out).toContain(' Install third-party plugin Superpowers?'); + expect(out).toContain(' ? Exit'); + expect(out).toContain(' Cancel the installation.'); + expect(out).toContain(' Install this third-party plugin anyway.'); + // The warning explains why confirmation is required and uses the + // design-system warning color rather than muted/default text. + expect(out.some((line) => line.includes('Pythinker has not reviewed'))).toBe(true); + expect(out.some((line) => line.includes('trust the source'))).toBe(true); + expect(raw).toContain(warningMark()); - const rendered = strip(picker.render(120).join('\n')); - expect(rendered).toContain('Search: alpha beta'); - expect(rendered).toContain('? Alpha Beta'); - expect(rendered).not.toContain('Alphabet'); - expect(onSelect).not.toHaveBeenCalled(); + picker.handleInput('\r'); + expect(results).toEqual([{ kind: 'cancel' }]); }); - it('confirms plugin removal only after choosing remove', () => { - const results: PluginRemoveConfirmResult[] = []; - const picker = new PluginRemoveConfirmComponent({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + it('installs a third-party plugin only after switching to trust', () => { + const results: PluginInstallTrustConfirmResult[] = []; + const picker = new PluginInstallTrustConfirmComponent({ + label: 'Superpowers', onDone: (result) => { results.push(result); }, }); - picker.handleInput(''); + picker.handleInput('\u001B[B'); const raw = renderRaw(picker); - expect(strip(raw)).toContain('Enter select'); - // The destructive option label keeps its danger styling (error + bold). - expect(raw).toContain(dangerShortcut('Remove plugin')); + expect(strip(raw)).toContain('Enter/Space select'); + // The opt-in option keeps its danger styling (error + bold). + expect(raw).toContain(dangerShortcut('Trust and install')); picker.handleInput('\r'); diff --git a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts index 167ed5ae..c3fd40a2 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/provider-manager.test.ts @@ -7,11 +7,10 @@ import { type ProviderManagerOptions, } from '#/tui/components/dialogs/provider-manager'; import { darkColors } from '#/tui/theme/colors'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -// Truecolor SGR fragments for the darkColors tokens we assert on. -// Forcing chalk.level below guarantees they appear. -const primarySgr = (): string => chalk.hex(darkColors.primary)('x').split('x')[0]!; +// Truecolor SGR fragments for the darkColors tokens we assert on +// (see theme/colors.ts). Forcing chalk.level below guarantees they appear. +const PRIMARY = '38;2;79;168;255'; // colors.primary #4FA8FF const MUTED = '38;2;107;107;107'; // colors.textMuted #6B6B6B const BOLD = '[1m'; const ESC = String.fromCodePoint(27); @@ -46,26 +45,6 @@ describe('ProviderManagerComponent', () => { chalk.level = previousLevel; }); - it('uses remapped Select navigation, honors an unbound Down key, and keeps confirmation input local', () => { - const component = makeComponent({ - providers: { acme: { baseUrl: 'https://acme.test' } } as unknown as Record<string, ProviderConfig>, - }); - component.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - component.handleInput(`${ESC}[B`); - expect(rendered(component)).toMatch(/❯\s+acme/); - component.handleInput('D'); - component.handleInput('alt+j'); - expect(rendered(component)).toContain('[y/N]'); - expect(rendered(component)).toMatch(/❯\s+acme/); - component.handleInput('n'); - component.handleInput('alt+j'); - expect(rendered(component)).toMatch(/❯\s+\[ Add New Platform \]/); - }); - it('renders [ Add New Platform ] in the brand color, never muted, when not selected', () => { // A configured provider occupies row 0 (selected); the add row sits below // it and is therefore not the highlighted row. @@ -77,7 +56,7 @@ describe('ProviderManagerComponent', () => { }); const line = addRowLine(component); expect(line).toBeDefined(); - expect(line).toContain(primarySgr()); + expect(line).toContain(PRIMARY); expect(line).not.toContain(MUTED); }); @@ -88,7 +67,7 @@ describe('ProviderManagerComponent', () => { const line = addRowLine(component); expect(line).toBeDefined(); expect(line).toContain(BOLD); - expect(line).toContain(primarySgr()); + expect(line).toContain(PRIMARY); }); it('marks the active provider with the shared "← current" marker, not a bullet', () => { @@ -101,7 +80,7 @@ describe('ProviderManagerComponent', () => { const plain = component .render(120) .join('\n') - .replaceAll(/\u001B\[[0-9;]*m/g, ''); + .replaceAll(/\[[0-9;]*m/g, ''); expect(plain).toContain('← current'); expect(plain).not.toContain('●'); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/pythinker-code/test/tui/components/dialogs/question-dialog.test.ts index c82ef0b9..1f89705a 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,9 +1,8 @@ -import { CURSOR_MARKER } from '@earendil-works/pi-tui'; +import { CURSOR_MARKER } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; import { currentTheme } from '#/tui/theme'; @@ -41,22 +40,19 @@ function makeDialog( dialog: QuestionDialogComponent; collected: string[][]; methods: Array<string | undefined>; - annotations: Array<Record<string, { preview?: string; notes?: string }> | undefined>; } { const collected: string[][] = []; const methods: Array<string | undefined> = []; - const annotations: Array<Record<string, { preview?: string; notes?: string }> | undefined> = []; const dialog = new QuestionDialogComponent( pending, (response) => { collected.push(response.answers); methods.push(response.method); - annotations.push(response.annotations); }, 6, onToggleToolOutput, ); - return { dialog, collected, methods, annotations }; + return { dialog, collected, methods }; } describe('QuestionDialogComponent', () => { @@ -132,7 +128,6 @@ describe('QuestionDialogComponent', () => { question: 'Approve this plan?', body: '# Plan\n\n1. Make the focused change.', multi_select: false, - allow_other: false, options: [{ label: 'Approve' }, { label: 'Reject' }], }, ]); @@ -141,77 +136,7 @@ describe('QuestionDialogComponent', () => { expect(out).toContain('# Plan'); expect(out).toContain('1. Make the focused change.'); expect(out).toContain('Approve'); - expect(out).not.toContain('Other'); - }); - - it('renders the focused option preview and updates it with the cursor', () => { - const pending = makePending([ - { - question: 'Choose an implementation?', - multi_select: false, - options: [ - { - label: 'Postgres', - description: 'Relational storage', - preview: 'CREATE TABLE example (id integer);', - }, - { - label: 'SQLite', - description: 'Embedded storage', - preview: 'const database = new Database("example.db");', - }, - ], - }, - ]); - const { dialog } = makeDialog(pending); - - const initial = strip(dialog.render(100).join('\n')); - expect(initial).toContain('CREATE TABLE example (id integer);'); - expect(initial).not.toContain('Other'); - expect( - initial - .split('\n') - .some((line) => line.includes('Postgres') && line.includes('Preview')), - ).toBe(true); - expect(strip(dialog.render(60).join('\n'))).toContain( - 'CREATE TABLE example (id integer);', - ); - - dialog.handleInput('\u001B[B'); - const moved = strip(dialog.render(100).join('\n')); - expect(moved).toContain('const database = new Database("example.db");'); - expect(moved).not.toContain('CREATE TABLE example (id integer);'); - }); - - it('submits the selected preview and trimmed notes as annotations', () => { - const pending = makePending([ - { - question: 'Choose an implementation?', - multi_select: false, - options: [ - { label: 'Postgres', preview: 'CREATE TABLE example (id integer);' }, - { label: 'SQLite', preview: 'new Database("example.db")' }, - ], - }, - ]); - const { dialog, annotations } = makeDialog(pending); - dialog.setKeybindings(parseKeybindingBlocks([])); - - dialog.handleInput('n'); - for (const char of ' Keep deployment simple. ') dialog.handleInput(char); - expect(strip(dialog.render(80).join('\n'))).toContain('Notes: Keep deployment simple. '); - dialog.handleInput('\r'); - dialog.handleInput('2'); - dialog.handleInput('1'); - - expect(annotations).toEqual([ - { - 'Choose an implementation?': { - preview: 'new Database("example.db")', - notes: 'Keep deployment simple.', - }, - }, - ]); + expect(out).toContain('Other'); }); it('multi-select uses space and number keys to toggle choices', () => { @@ -236,27 +161,6 @@ describe('QuestionDialogComponent', () => { expect(collected).toEqual([]); }); - it.each([' ', '2'])('deselects committed multi-select Other with %j', (toggle) => { - const pending = makePending([ - { - question: 'Pick many?', - multi_select: true, - options: [{ label: 'A' }], - }, - ]); - const { dialog } = makeDialog(pending); - - dialog.handleInput('2'); - for (const character of 'Custom value') dialog.handleInput(character); - dialog.handleInput('\r'); - dialog.handleInput(toggle); - dialog.handleInput('\t'); - - const review = strip(dialog.render(80).join('\n')); - expect(review).toContain('Not answered'); - expect(review).not.toContain('Custom value'); - }); - it('review shows an unanswered warning and still allows submit', () => { const pending = makePending([ { @@ -450,10 +354,7 @@ describe('QuestionDialogComponent', () => { const out = dialog.render(80).join('\n'); expect(out).toContain( - chalk - .bgHex(currentTheme.color('selectionBg')) - .hex(currentTheme.color('inverseText')) - .bold(' First '), + chalk.bgHex(currentTheme.color('primary')).hex(currentTheme.color('text')).bold(' First '), ); expect(out).not.toContain('(●) First'); }); @@ -493,463 +394,43 @@ describe('QuestionDialogComponent', () => { expect(out).toContain('Mushroom'); }); - it('escape dismisses with empty answers array', () => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const { dialog, collected } = makeDialog(pending); - dialog.handleInput('\u001B'); - expect(collected).toEqual([[]]); - }); - - it('uses remapped confirmation actions for navigation, fields, and toggles', () => { + it('multi-select Other can be toggled off after it is committed', () => { const pending = makePending([ { question: 'Pick toppings?', multi_select: true, - options: [{ label: 'Cheese' }, { label: 'Olives' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'alt+n': 'confirm:next', - 'alt+p': 'confirm:previous', - 'alt+t': 'confirm:toggle', - 'alt+f': 'confirm:nextField', - }, - }, - ]), - ); - dialog.handleInput('\u001Bn'); - dialog.handleInput('\u001Bt'); - expect(strip(dialog.render(80).join('\n'))).toContain('[✓] Olives'); - dialog.handleInput('\u001Bf'); - expect(strip(dialog.render(80).join('\n'))).toContain('Review your answer before submit'); - }); - - it('lets Other input own a printable remapped navigation key', () => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { x: 'confirm:next' } }, - ]), - ); - dialog.handleInput('3'); - dialog.handleInput('x'); - expect(strip(dialog.render(80).join('\n'))).toContain('Other: x'); - }); - - it('lets notes input own a printable remapped navigation key', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+e': 'confirm:toggleExplanation', x: 'confirm:next' }, - }, - ]), - ); - dialog.handleInput('\u0005'); - dialog.handleInput('x'); - expect(strip(dialog.render(80).join('\n'))).toContain('Notes: x'); - }); - - it('resolves a remapped n action before the local notes shortcut', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [ - { label: 'A', preview: 'Preview A' }, - { label: 'B', preview: 'Preview B' }, - ], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: 'confirm:next' } }, - ]), - ); - dialog.handleInput('n'); - const rendered = strip(dialog.render(80).join('\n')); - expect(rendered).toContain('Preview B'); - expect(rendered).not.toContain('press n to add notes'); - expect(rendered).not.toContain('type notes'); - }); - - it('renders the local n notes hint when n is explicitly unbound', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { n: null, 'ctrl+e': null }, - }, - ]), - ]); - expect(strip(dialog.render(80).join('\n'))).toContain('press n to add notes'); - dialog.handleInput('n'); - expect(strip(dialog.render(80).join('\n'))).toContain('type notes'); - }); - - it('uses an alternate cancel binding in Other input while bare Escape preserves the draft', () => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const bindings = parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'alt+x': 'confirm:no' } }, - ]); - const preserved = makeDialog(pending); - preserved.dialog.setKeybindings(bindings); - preserved.dialog.handleInput('3'); - preserved.dialog.handleInput('d'); - preserved.dialog.handleInput('\u001B'); - preserved.dialog.handleInput('z'); - expect(strip(preserved.dialog.render(80).join('\n'))).toContain('Other: dz'); - expect(preserved.collected).toEqual([]); - - const cancelled = makeDialog(pending); - cancelled.dialog.setKeybindings(bindings); - cancelled.dialog.handleInput('3'); - cancelled.dialog.handleInput('d'); - cancelled.dialog.handleInput('\u001Bx'); - expect(cancelled.collected).toEqual([[]]); - }); - - it('uses an alternate cancel binding to leave notes while bare Escape preserves editing', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const bindings = parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'alt+x': 'confirm:no', - }, + options: [{ label: 'Cheese' }, { label: 'Pepperoni' }], }, ]); - const preserved = makeDialog(pending); - preserved.dialog.setKeybindings(bindings); - preserved.dialog.handleInput('\u0005'); - preserved.dialog.handleInput('d'); - preserved.dialog.handleInput('\u001B'); - preserved.dialog.handleInput('z'); - expect(strip(preserved.dialog.render(80).join('\n'))).toContain('Notes: dz'); - - const locallyCancelled = makeDialog(pending); - locallyCancelled.dialog.setKeybindings(bindings); - locallyCancelled.dialog.handleInput('\u0005'); - locallyCancelled.dialog.handleInput('d'); - locallyCancelled.dialog.handleInput('\u001Bx'); - const rendered = strip(locallyCancelled.dialog.render(80).join('\n')); - expect(rendered).toContain('Notes: d'); - expect(rendered).not.toContain('type notes'); - expect(locallyCancelled.collected).toEqual([]); - }); + const { dialog, collected } = makeDialog(pending); - it('executes a multi-key next-field chord from Other input', () => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+k ctrl+n': 'confirm:nextField' }, - }, - ]), - ); + // Select Other and commit a custom value. dialog.handleInput('3'); - dialog.handleInput('d'); - dialog.handleInput('\u000B'); - dialog.handleInput('\u000E'); - expect(strip(dialog.render(80).join('\n'))).toContain('Review your answer before submit'); - }); - - it('executes a multi-key next-field chord from notes input', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'ctrl+k ctrl+n': 'confirm:nextField', - }, - }, - ]), - ); - dialog.handleInput('\u0005'); - dialog.handleInput('d'); - dialog.handleInput('\u000B'); - dialog.handleInput('\u000E'); - expect(strip(dialog.render(80).join('\n'))).toContain('Review your answer before submit'); - }); + dialog.handleInput('M'); + dialog.handleInput('u'); + dialog.handleInput('s'); + dialog.handleInput('h'); + dialog.handleInput('r'); + dialog.handleInput('o'); + dialog.handleInput('o'); + dialog.handleInput('m'); + dialog.handleInput('\r'); - it('executes a semantic next-field key ID from Other input', () => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'alt+f': 'confirm:nextField' }, - }, - ]), - ); + // Toggle it off using the same key. dialog.handleInput('3'); - dialog.handleInput('d'); - dialog.handleInput('alt+f'); - expect(strip(dialog.render(80).join('\n'))).toContain('Review your answer before submit'); - }); - - it('executes a semantic next-field key ID from notes input', () => { - const pending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'alt+f': 'confirm:nextField', - }, - }, - ]), - ); - dialog.handleInput('\u0005'); - dialog.handleInput('d'); - dialog.handleInput('alt+f'); - expect(strip(dialog.render(80).join('\n'))).toContain('Review your answer before submit'); - }); - - it('uses semantic two-key chords in active and nested modes', () => { - const activePending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [ - { label: 'A', preview: 'Preview A' }, - { label: 'B', preview: 'Preview B' }, - ], - }, - ]); - const active = makeDialog(activePending); - active.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+k ctrl+n': 'confirm:next' }, - }, - ]), - ); - active.dialog.handleInput('ctrl+k'); - active.dialog.handleInput('ctrl+n'); - expect(strip(active.dialog.render(80).join('\n'))).toContain('Preview B'); - - const otherPending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const other = makeDialog(otherPending); - other.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'ctrl+k ctrl+f': 'confirm:nextField' }, - }, - ]), - ); - other.dialog.handleInput('3'); - other.dialog.handleInput('d'); - other.dialog.handleInput('ctrl+k'); - other.dialog.handleInput('ctrl+f'); - expect(strip(other.dialog.render(80).join('\n'))).toContain( - 'Review your answer before submit', - ); - }); - - it('keeps unavailable printable chords intact in Other and notes input', () => { - const otherPending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const other = makeDialog(otherPending); - other.dialog.setKeybindings( - parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { 'x y': 'confirm:next' } }, - ]), - ); - other.dialog.handleInput('3'); - other.dialog.handleInput('x'); - other.dialog.handleInput('y'); - expect(strip(other.dialog.render(80).join('\n'))).toContain('Other: xy'); - - const notesPending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const notes = makeDialog(notesPending); - notes.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'x y': 'confirm:next', - }, - }, - ]), - ); - notes.dialog.handleInput('\u0005'); - notes.dialog.handleInput('x'); - notes.dialog.handleInput('y'); - expect(strip(notes.dialog.render(80).join('\n'))).toContain('Notes: xy'); - }); - - it('keeps unavailable chord prefixes out of active local controls', () => { - const previewPending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const notes = makeDialog(previewPending); - notes.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'n x': 'permission:toggleDebug' }, - }, - ]), - ); - notes.dialog.handleInput('n'); - expect(strip(notes.dialog.render(80).join('\n'))).toContain('type notes'); - - const numericPending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const numeric = makeDialog(numericPending); - numeric.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { '2 x': 'permission:toggleDebug' }, - }, - ]), - ); - numeric.dialog.handleInput('2'); - expect(strip(numeric.dialog.render(80).join('\n'))).toContain( - 'Review your answer before submit', - ); + // Select a preset option to confirm the answer still builds correctly. + dialog.handleInput('1'); + dialog.handleInput('\t'); - }); + const review = strip(dialog.render(80).join('\n')); + expect(review).toContain('Cheese'); + expect(review).not.toContain('Mushroom'); - it.each([ - ['Left', '\u001B[D', 'left x'], - ['Right', '\u001B[C', 'right x'], - ])('keeps an unavailable %s chord prefix out of local tab navigation', (_name, key, chord) => { - const pending = makePending([ - { - question: 'Pick one?', - multi_select: false, - options: [{ label: 'A' }, { label: 'B' }], - }, - ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { [chord]: 'permission:toggleDebug' }, - }, - ]), - ); - dialog.handleInput(key); - expect(strip(dialog.render(80).join('\n'))).toContain( - 'Review your answer before submit', - ); + dialog.handleInput('1'); + expect(collected).toEqual([['Cheese']]); }); - it('keeps an unavailable longer Tab chord from shadowing default Tab navigation', () => { + it('escape dismisses with empty answers array', () => { const pending = makePending([ { question: 'Pick one?', @@ -957,130 +438,9 @@ describe('QuestionDialogComponent', () => { options: [{ label: 'A' }, { label: 'B' }], }, ]); - const { dialog } = makeDialog(pending); - dialog.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { 'tab x': 'permission:toggleDebug' }, - }, - ]), - ]); - dialog.handleInput('\t'); - expect(strip(dialog.render(80).join('\n'))).toContain( - 'Review your answer before submit', - ); - }); - - it('renders effective nested hints and omits unbound optional routes', () => { - const otherPending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A' }], - }, - ]); - const notesPending = makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]); - const remapped = makeDialog(otherPending); - remapped.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'alt+b': 'confirm:previousField', - 'alt+f': 'confirm:nextField', - 'alt+x': 'confirm:no', - }, - }, - ]), - ); - remapped.dialog.handleInput('2'); - let hint = strip(remapped.dialog.render(80).join('\n')); - expect(hint).toContain('type answer'); - expect(hint).toContain('↵ save'); - expect(hint).toContain('alt+b / alt+f switch'); - expect(hint).toContain('alt+x cancel'); - expect(hint).not.toContain('tab switch'); - expect(hint).not.toContain('esc cancel'); - - const notes = makeDialog(notesPending); - notes.dialog.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+e': 'confirm:toggleExplanation', - 'alt+b': 'confirm:previousField', - 'alt+f': 'confirm:nextField', - 'alt+x': 'confirm:no', - }, - }, - ]), - ); - notes.dialog.handleInput('\u0005'); - hint = strip(notes.dialog.render(80).join('\n')); - expect(hint).toContain('type notes'); - expect(hint).toContain('↵ save'); - expect(hint).toContain('alt+b / alt+f switch'); - expect(hint).toContain('alt+x return'); - expect(hint).not.toContain('esc return'); - - const unbound = makeDialog(otherPending); - unbound.dialog.setKeybindings(parseKeybindingBlocks([])); - unbound.dialog.handleInput('2'); - hint = strip(unbound.dialog.render(80).join('\n')); - expect(hint).toContain('type answer'); - expect(hint).toContain('↵ save'); - expect(hint).not.toContain('switch'); - expect(hint).not.toContain('cancel'); - }); - - it('recovers bare Escape after default cancel bindings are explicitly removed', () => { - const pending = makePending([ - { question: 'Pick one?', multi_select: false, options: [{ label: 'A' }] }, - ]); - const bindings = [ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]; - const active = makeDialog(pending); - active.dialog.setKeybindings(bindings); - active.dialog.handleInput('\u001B'); - expect(active.collected).toEqual([[]]); - - const other = makeDialog(pending); - other.dialog.setKeybindings(bindings); - other.dialog.handleInput('2'); - other.dialog.handleInput('\u001B'); - expect(other.collected).toEqual([[]]); - - const notes = makeDialog( - makePending([ - { - question: 'Choose one?', - multi_select: false, - options: [{ label: 'A', preview: 'Preview A' }], - }, - ]), - ); - notes.dialog.setKeybindings(bindings); - notes.dialog.handleInput('\u0005'); - notes.dialog.handleInput('d'); - notes.dialog.handleInput('\u001B'); - const rendered = strip(notes.dialog.render(80).join('\n')); - expect(rendered).toContain('Notes: d'); - expect(rendered).not.toContain('type notes'); - expect(notes.collected).toEqual([]); + const { dialog, collected } = makeDialog(pending); + dialog.handleInput('\u001B'); + expect(collected).toEqual([[]]); }); it.each(['\u0003', '\u0004'])('ctrl shortcut %j dismisses question dialog', (key) => { diff --git a/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts b/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts index 515616bd..04e83d22 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/session-picker.test.ts @@ -1,8 +1,7 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { SessionPickerComponent } from '#/tui/components/dialogs/session-picker'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; function stripAnsi(text: string): string { return text.replaceAll(/\[[0-?]*[ -/]*[@-~]/g, ''); @@ -20,32 +19,6 @@ describe('SessionPickerComponent', () => { vi.restoreAllMocks(); }); - it('uses remapped Select navigation and honors an unbound Down key', () => { - const onSelect = vi.fn(); - const component = new SessionPickerComponent({ - sessions: [ - { id: 'ses_first', title: 'First', work_dir: '/tmp', updated_at: 1 }, - { id: 'ses_second', title: 'Second', work_dir: '/tmp', updated_at: 2 }, - ], - loading: false, - currentSessionId: '', - onSelect, - onCancel: vi.fn(), - }); - component.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - component.handleInput('\u001B[B'); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'ses_first' })); - - component.handleInput('alt+j'); - component.handleInput('\r'); - expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'ses_second' })); - }); - it('forwards Ctrl-C and Ctrl-D to optional host shortcuts', () => { const onCtrlC = vi.fn(); const onCtrlD = vi.fn(); @@ -128,36 +101,6 @@ describe('SessionPickerComponent', () => { expect(output).toContain('please redesign the picker UI'); }); - it('renders and searches session tags', () => { - const component = new SessionPickerComponent({ - sessions: [ - { - id: 'ses_tagged', - title: 'Tagged session', - work_dir: '/tmp/project', - updated_at: Date.now(), - metadata: { tag: 'review' }, - }, - { - id: 'ses_other', - title: 'Other session', - work_dir: '/tmp/project', - updated_at: Date.now(), - }, - ], - loading: false, - currentSessionId: 'ses_current', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - expect(renderPlain(component)).toContain('#review'); - for (const key of 'review') component.handleInput(key); - const filtered = renderPlain(component); - expect(filtered).toContain('Tagged session'); - expect(filtered).not.toContain('Other session'); - }); - it('omits the last-prompt row when last_prompt is missing', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -271,6 +214,39 @@ describe('SessionPickerComponent', () => { expect(headerLine).not.toMatch(/Short title\s{8,}/); }); + it('prepends [imported] badge before the title for sessions migrated from pythinker-cli', () => { + const now = new Date('2026-05-11T12:00:00.000Z').getTime(); + vi.spyOn(Date, 'now').mockReturnValue(now); + + const component = new SessionPickerComponent({ + sessions: [ + { + id: 'ses_imported', + title: 'Migrated session', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + metadata: { imported_from_pythinker_cli: true }, + }, + { + id: 'ses_native', + title: 'Fresh session', + work_dir: '/tmp/project', + updated_at: now - 60 * 1000, + }, + ], + loading: false, + currentSessionId: 'ses_other', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = component.render(120).map((line) => stripAnsi(line)); + const importedLine = lines.find((line) => line.includes('Migrated session')); + const nativeLine = lines.find((line) => line.includes('Fresh session')); + expect(importedLine).toContain('[imported] Migrated session'); + expect(nativeLine).not.toContain('[imported]'); + }); + it('keeps every rendered line within the terminal width even for CJK content', () => { const now = new Date('2026-05-11T12:00:00.000Z').getTime(); vi.spyOn(Date, 'now').mockReturnValue(now); @@ -279,10 +255,10 @@ describe('SessionPickerComponent', () => { sessions: [ { id: 'ses_cjk_long_session_id_value', - title: 'Now refactor the TUI sessions list to render several fields and improve the UI', + title: '\u73B0\u5728\u8981\u91CD\u6784\u4E00\u4E0B TUI \u7684 sessions \u5217\u8868,\u8981\u6E32\u67D3\u51E0\u4E2A\u5B57\u6BB5,\u8BA9 UI \u66F4\u597D\u770B', last_prompt: - 'We need to render sessionid title lastPrompt, work directory, and modified time. Redesign the UI.', - work_dir: '/Users/someone/Desktop/i18n-folder/very-long-project-folder-name', + '\u6211\u4EEC\u8981\u6E32\u67D3\u51E0\u4E2A:sessionid title lastPrompt。\u5DE5\u4F5C\u76EE\u5F55,\u4FEE\u6539\u65F6\u95F4。\u9700\u8981\u91CD\u65B0\u8BBE\u8BA1\u4E0B UI。', + work_dir: '/Users/someone/Desktop/\u4E2D\u6587\u76EE\u5F55/very-long-project-folder-name', updated_at: now - 5 * 60 * 1000, }, ], @@ -317,6 +293,7 @@ describe('SessionPickerComponent', () => { last_prompt: 'please redesign the picker UI to be much nicer than before', work_dir: '/Users/getlong/Development/cesiumdb', updated_at: now - 5 * 60 * 1000, + metadata: { imported_from_pythinker_cli: true }, }, ], loading: false, @@ -478,32 +455,6 @@ describe('SessionPickerComponent', () => { expect(output).toContain('Showing 49-52 of 100 loaded / 120 sessions'); }); - it('keeps PageUp and PageDown local to the session list', () => { - const onToggleScope = vi.fn(); - const component = new SessionPickerComponent({ - sessions: Array.from({ length: 5 }, (_, index) => ({ - id: `ses_${String(index)}`, - title: `Session ${String(index)}`, - work_dir: '/tmp', - updated_at: index, - })), - loading: false, - currentSessionId: '', - pageSize: 2, - onSelect: vi.fn(), - onCancel: vi.fn(), - onToggleScope, - }); - - component.handleInput(`${ESC}[6~`); - component.handleInput('\u0001'); - expect(onToggleScope).toHaveBeenLastCalledWith('ses_2'); - - component.handleInput(`${ESC}[5~`); - component.handleInput('\u0001'); - expect(onToggleScope).toHaveBeenLastCalledWith('ses_0'); - }); - it('keeps initial selected session id and loads enough pages for it', () => { const component = new SessionPickerComponent({ sessions: Array.from({ length: 80 }, (_, index) => ({ @@ -758,4 +709,164 @@ describe('SessionPickerComponent', () => { expect(onToggleScope).toHaveBeenCalledOnce(); expect(onToggleScope).toHaveBeenCalledWith('ses_beta'); }); + + it('fires onLoadMore when the cursor reaches the last fetched row', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).toHaveBeenCalledOnce(); + }); + + it('does not fire onLoadMore while a page fetch is in flight', () => { + const onLoadMore = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + loadingMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onLoadMore, + }); + + component.handleInput('\u001B[B'); + + expect(onLoadMore).not.toHaveBeenCalled(); + }); + + it('appendSessions extends the list and keeps the active query', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('g'); + expect(renderPlain(component)).toContain('No matches'); + + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 2 }, + ]); + + const output = renderPlain(component); + expect(output).toContain('Search: g'); + expect(output).toContain('Gamma session'); + expect(output).not.toContain('Alpha session'); + }); + + it('appendSessions keeps the selected row', () => { + const onSelect = vi.fn(); + const beta = { id: 'ses_beta', title: 'Beta session', work_dir: '/tmp/p', updated_at: 2 }; + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }, + beta, + ], + loading: false, + currentSessionId: '', + onSelect, + onCancel: vi.fn(), + }); + + component.handleInput('\u001B[B'); + component.appendSessions([ + { id: 'ses_gamma', title: 'Gamma session', work_dir: '/tmp/p', updated_at: 3 }, + ]); + component.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(beta); + }); + + it('fires onSearchDrain only when the query becomes active with unfetched pages', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + component.handleInput('l'); + + expect(onSearchDrain).toHaveBeenCalledOnce(); + }); + + it('does not fire onSearchDrain when every page is already fetched', () => { + const onSearchDrain = vi.fn(); + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + onSelect: vi.fn(), + onCancel: vi.fn(), + onSearchDrain, + }); + + component.handleInput('a'); + + expect(onSearchDrain).not.toHaveBeenCalled(); + }); + + it('announces unfetched pages and in-flight fetches in the footer', () => { + const component = new SessionPickerComponent({ + sessions: [ + { id: 'ses_a', title: 'Alpha', work_dir: '/tmp/project', updated_at: 1 }, + { id: 'ses_b', title: 'Beta', work_dir: '/tmp/project', updated_at: 2 }, + ], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + expect(renderPlain(component)).toContain('· scroll for more'); + + component.setPaging(true, true); + expect(renderPlain(component)).toContain('· loading more…'); + + component.setPaging(false, false); + const settled = renderPlain(component); + expect(settled).not.toContain('· scroll for more'); + expect(settled).not.toContain('· loading more…'); + }); + + it('notes the background drain in the footer while searching with unfetched pages', () => { + const component = new SessionPickerComponent({ + sessions: [{ id: 'ses_alpha', title: 'Alpha session', work_dir: '/tmp/p', updated_at: 1 }], + loading: false, + currentSessionId: '', + hasMore: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + component.handleInput('a'); + + expect(renderPlain(component)).toContain('· searching all…'); + }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts index 98c2efbd..7e876ce0 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/tabbed-model-selector.test.ts @@ -3,19 +3,16 @@ import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; -import { parseKeybindingBlocks } from '#/tui/keybindings'; -import { darkColors } from '#/tui/theme/colors'; +import { currentTheme } from '#/tui/theme'; +import { darkColors, lightColors } from '#/tui/theme/colors'; const ESC = String.fromCodePoint(27); const SGR = new RegExp(`${ESC}\\[[0-9;]*m`, 'g'); const strip = (s: string): string => s.replaceAll(SGR, ''); const TAB = '\t'; -const SHIFT_TAB = `${ESC}[Z`; const RIGHT = `${ESC}[C`; -const selectionBackgroundSgr = (): string => - chalk.bgHex(darkColors.selectionBg)('x').split('x')[0]!; -const inverseTextSgr = (): string => - chalk.hex(darkColors.inverseText)('x').split('x')[0]!; +// chalk.bgHex(colors.primary) → background truecolor for #4FA8FF. +const PRIMARY_BG = '48;2;79;168;255'; function model(displayName: string, provider: string): ModelAlias { return { @@ -34,11 +31,11 @@ function make(): { const onSelect = vi.fn(); const component = new TabbedModelSelectorComponent({ models: { - k2: model('Kimi K2', 'moonshot-cn'), + k2: model('Kimi K2', 'managed:pythinker-code'), gpt: model('GPT-5', 'openai'), }, currentValue: 'k2', - currentEffort: 'off', + currentThinkingEffort: 'off', onSelect, onCancel: vi.fn(), }); @@ -48,98 +45,72 @@ function make(): { describe('TabbedModelSelectorComponent', () => { let previousLevel: typeof chalk.level; + const previousPalette = currentTheme.palette; beforeAll(() => { previousLevel = chalk.level; chalk.level = 3; + currentTheme.setPalette(darkColors); }); afterAll(() => { chalk.level = previousLevel; + currentTheme.setPalette(previousPalette); }); it('renders an "All" + per-provider tab strip', () => { const out = strip(make().component.render(120).join('\n')); expect(out).toContain('All'); - expect(out).toContain('moonshot-cn'); + expect(out).toContain('Pythinker Code'); expect(out).toContain('openai'); }); - it('highlights the active tab with the contrast-safe selection pair', () => { + it('highlights the active tab with a filled background (AskUserQuestion style)', () => { + // currentValue k2 → the active tab is "Pythinker Code"; its cell carries the + // primary background SGR. const raw = make().component.render(120).join('\n'); - expect(raw).toContain(selectionBackgroundSgr()); - expect(raw).toContain(inverseTextSgr()); + expect(raw).toContain(PRIMARY_BG); }); - it('opens on the current model provider by default', () => { + it('repaints the tab strip from the current theme palette without remounting', () => { const { component } = make(); - const out = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('moonshot-cn'); - expect(out).toContain('Kimi K2'); - expect(out).not.toContain('GPT-5'); - expect(out).toMatch(/❯ Kimi K2\s+moonshot-cn ← current/u); + const stripLine = (lines: string[]): string => + lines.find((l) => l.includes('All') && l.includes('openai')) ?? ''; + const previous = currentTheme.palette; + try { + currentTheme.setPalette(darkColors); + const darkStrip = stripLine(component.render(120)); + currentTheme.setPalette(lightColors); + const lightStrip = stripLine(component.render(120)); + // The strip is drawn from currentTheme.palette at render time; a + // construction-time palette snapshot would render the same strip after + // the switch. + expect(darkStrip).not.toBe(lightStrip); + } finally { + currentTheme.setPalette(previous); + } }); - it('opens the matching provider when the current canonical alias is stale', () => { - const component = new TabbedModelSelectorComponent({ - models: { - minimax: model('MiniMax M3', 'minimax-anthropic'), - sol: model('GPT-5.6-Sol', 'openai-codex'), - }, - currentValue: 'openai-codex/codex-auto-review', - currentEffort: 'max', - onSelect: vi.fn(), - onCancel: vi.fn(), - }); - - const out = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('openai-codex'); - expect(out).toContain('GPT-5.6-Sol'); - expect(out).not.toContain('MiniMax M3'); - expect(out).not.toContain('← current'); + it('opens on the All tab by default (showing every provider\'s models)', () => { + const out = strip(make().component.render(120).join('\n')); + expect(out).toContain('Kimi K2'); + expect(out).toContain('GPT-5'); }); - it('cycles provider tabs with Tab and Shift-Tab', () => { + it('cycles provider tabs with Tab', () => { const { component } = make(); - // tabs = [All, Pythinker Code, openai]; active starts on Pythinker Code. - // One Tab → openai, whose list shows GPT-5 and not Kimi K2. + // tabs = [All, Pythinker Code, openai]; active starts on All. + // Two Tabs → openai, whose list shows GPT-5 and not Kimi K2. component.handleInput(TAB); - let out = strip(component.render(120).join('\n')); + component.handleInput(TAB); + const out = strip(component.render(120).join('\n')); expect(out).toContain('GPT-5'); expect(out).not.toContain('Kimi K2'); - - component.handleInput(SHIFT_TAB); - out = strip(component.render(120).join('\n')); - expect(out).toContain('Kimi K2'); - expect(out).not.toContain('GPT-5'); - }); - - it('uses remapped tab switching and renders the effective shortcut', () => { - const { component } = make(); - component.setKeybindings( - parseKeybindingBlocks([ - { context: 'Tabs', bindings: { tab: null, 'alt+l': 'tabs:next' } }, - ]), - ); - - component.handleInput(TAB); - expect(strip(component.render(120).join('\n'))).toContain('Kimi K2'); - component.handleInput('\u001Bl'); - const output = strip(component.render(120).join('\n')); - expect(output).toContain('GPT-5'); - expect(output).not.toContain('Kimi K2'); - expect(output).toContain('alt+l'); - expect(output).not.toContain('Tab toggle provider'); }); - it('lets the active selector consume Left/Right before tab navigation', () => { + it('forwards thinking toggle (←/→) and selection (Enter) to the active tab', () => { const { component, onSelect } = make(); - - component.handleInput(RIGHT); // off -> low for k2 - const output = strip(component.render(120).join('\n')); - expect(component.activeTabId()).toBe('moonshot-cn'); - expect(output).toContain('Kimi K2'); - + component.handleInput(RIGHT); // toggle thinking on for k2 component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', effort: 'low' }); + expect(onSelect).toHaveBeenCalledWith({ alias: 'k2', thinking: 'on' }); }); it('frames the tab strip with a blank line above and below it', () => { @@ -161,35 +132,41 @@ describe('TabbedModelSelectorComponent', () => { expect(hint!.indexOf('Tab toggle provider')).toBeLessThan(hint!.indexOf('↑↓ navigate')); }); - it('deduplicates the same underlying model in both the All tab and provider tab', () => { - const onSelect = vi.fn(); - const terra = model('Terra 13B', 'terra'); + it('renders the default title, and a custom title when provided', () => { + expect(strip(make().component.render(120).join('\n'))).toContain('Select a model'); + + const titled = new TabbedModelSelectorComponent({ + models: { k2: model('Kimi K2', 'managed:pythinker-code') }, + currentValue: 'k2', + currentThinkingEffort: 'off', + title: ' Choose a model for this task', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + const out = strip(titled.render(120).join('\n')); + expect(out).toContain('Choose a model for this task'); + expect(out).not.toContain('Select a model '); + }); + + it('keeps the tab strip between hint and list when a warning line is present', () => { const component = new TabbedModelSelectorComponent({ models: { - 'terra/custom': terra, - 'terra/terra-13b': terra, + k2: model('Kimi K2', 'managed:pythinker-code'), gpt: model('GPT-5', 'openai'), }, - currentValue: 'terra/custom', - selectedValue: 'terra/custom', - currentEffort: 'medium', - onSelect, + currentValue: 'k2', + currentThinkingEffort: 'off', + warning: 'Switching may increase token usage.', + onSelect: vi.fn(), onCancel: vi.fn(), }); - component.focused = true; - - const providerLines = strip(component.render(120).join('\n')) - .split('\n') - .filter((line) => line.includes('Terra 13B')); - expect(providerLines).toHaveLength(1); - - component.handleInput(SHIFT_TAB); - const allLines = strip(component.render(120).join('\n')) - .split('\n') - .filter((line) => line.includes('Terra 13B')); - expect(allLines).toHaveLength(1); - - component.handleInput('\r'); - expect(onSelect).toHaveBeenCalledWith({ alias: 'terra/terra-13b', effort: 'medium' }); + const lines = component.render(120).map(strip); + const hintIdx = lines.findIndex((l) => l.includes('navigate') && l.includes('Esc cancel')); + expect(lines[hintIdx + 1]).toContain('Switching may increase token usage.'); + expect(lines[hintIdx + 2]).toBe(''); // blank between warning and tabs + const stripIdx = lines.findIndex((l) => l.includes('All') && l.includes('openai')); + expect(stripIdx).toBe(hintIdx + 3); + expect(lines[stripIdx + 1]).toBe(''); // blank between tabs and list + expect(lines.findIndex((l) => l.includes('Kimi K2'))).toBeGreaterThan(stripIdx); }); }); diff --git a/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts new file mode 100644 index 00000000..144f1e40 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/dialogs/trust-prompt.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { WorkspaceTrustMcpServerInfo } from '@pymodel/pythinker-code-sdk'; + +import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function renderLines(gatedMcpServers: readonly WorkspaceTrustMcpServerInfo[] = []): string[] { + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers, + onSelect: vi.fn(), + }); + return prompt.render(100).map(strip); +} + +describe('TrustPromptComponent', () => { + it('renders the header vocabulary and the workspace path', () => { + const lines = renderLines(); + const titleIdx = lines.findIndex((l) => l.includes('Trust this folder?')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + const hint = lines[titleIdx + 1]; + expect(hint).toContain('↑↓ navigate'); + expect(hint).toContain('Enter select'); + expect(hint).toContain('Esc exit'); + expect(lines.some((l) => l.includes('/tmp/demo-workspace'))).toBe(true); + }); + + it('lists the gated project MCP servers when present', () => { + const lines = renderLines([ + { name: 'nested-server', transport: 'stdio', command: 'nested-cmd', args: ['--safe'], cwd: '/tmp' }, + { name: 'root-server', transport: 'http', url: 'https://example.test/mcp' }, + ]); + expect(lines.some((l) => l.includes('Project MCP targets'))).toBe(true); + expect(lines.some((l) => l.includes('nested-server (stdio): command=nested-cmd'))).toBe(true); + expect(lines.some((l) => l.includes('args=["--safe"] cwd=/tmp'))).toBe(true); + expect(lines.some((l) => l.includes('root-server (http): url=https://example.test/mcp'))).toBe(true); + expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false); + }); + + it('strips terminal control characters from workspace-supplied MCP targets', () => { + const lines = renderLines([ + { name: 'evil', transport: 'stdio', command: 'cmd\u001B[2J\u0007evil' }, + { name: 'multi\nline', transport: 'http', url: 'https://example.test/\u001B]8;;https://evil.test\u0007' }, + ]); + const text = lines.join('\n'); + // ESC and BEL are dropped, defusing the sequences into harmless literal text. + expect(text).toContain('evil (stdio): command=cmd[2Jevil'); + expect(text).toContain('multiline (http): url=https://example.test/]8;;https://evil.test'); + expect(text).not.toContain('\u001B]8;;https://evil.test'); + }); + + it("defaults to Don't trust", () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); + + it('selects trust only after moving to it explicitly', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B[A'); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('trust'); + }); + + it('selects distrust after moving the cursor down', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B[B'); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); + + it('treats Esc as distrust', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts index 736e5d70..4b7aafe3 100644 --- a/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/custom-editor.test.ts @@ -1,36 +1,23 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions, TUI, -} from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +} from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; -import { - CustomEditor, - insertAutocompleteGhost, -} from '#/tui/components/editor/custom-editor'; -import { - setRainbowColors, - type RainbowColorController, -} from '#/tui/easter-eggs/rainbow-colors'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -import { darkColors } from '#/tui/theme'; +import { CustomEditor } from '#/tui/components/editor/custom-editor'; +import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; function makeEditor(): CustomEditor { const tui = { requestRender: vi.fn(), + render: vi.fn(() => []), terminal: { rows: 40, cols: 120 }, } as unknown as TUI; return new CustomEditor(tui); } -function stripAnsi(value: string): string { - return value.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - async function flushAutocomplete(): Promise<void> { await Promise.resolve(); await Promise.resolve(); @@ -43,48 +30,21 @@ function providerReturning(items: AutocompleteItem[]): AutocompleteProvider { }; } -describe('autocomplete ghost insertion', () => { - const line = ' ❯ /exi\u001B[7m \u001B[0m '; - // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences - const stripAnsi = (value: string): string => value.replaceAll(/\u001B\[[0-9;]*m/g, ''); - - it('preserves the cursor SGR reset and visible line width', () => { - const output = insertAutocompleteGhost(line, 't ') ?? ''; - - expect(output.split('\u001B[7m')).toHaveLength(2); - expect(output.split('\u001B[0m')).toHaveLength(2); - expect(output.indexOf('\u001B[7m')).toBeLessThan(output.indexOf('\u001B[0m')); - expect(stripAnsi(output).length).toBe(stripAnsi(line).length); - }); - - it('places the first ghost character in the cursor and mutes the remainder', () => { - const previousLevel = chalk.level; - chalk.level = 3; - - try { - const output = insertAutocompleteGhost(line, 't ') ?? ''; - const mutedRemainder = chalk.hex(darkColors.textMuted)(' '); - - expect(output).toContain(`\u001B[7mt\u001B[0m${mutedRemainder}`); - } finally { - chalk.level = previousLevel; - } - }); - - it('does not insert a ghost over a non-blank cursor character', () => { - expect(insertAutocompleteGhost(' ❯ /exi\u001B[7mx\u001B[0m ', 't ')).toBeUndefined(); - }); - - it('uses the cursor cell when no trailing padding is available', () => { - const input = ' ❯ /exi\u001B[7m \u001B[0m'; - const output = insertAutocompleteGhost(input, 't ') ?? ''; - - expect(output).toContain('\u001B[7mt\u001B[0m'); - expect(output.split('\u001B[7m')).toHaveLength(2); - expect(output.split('\u001B[0m')).toHaveLength(2); - expect(stripAnsi(output).length).toBe(stripAnsi(input).length); - }); -}); +function providerRecordingForce(items: AutocompleteItem[]): { + provider: AutocompleteProvider; + calls: Array<{ force: boolean | undefined; text: string }>; +} { + const calls: Array<{ force: boolean | undefined; text: string }> = []; + const provider: AutocompleteProvider = { + getSuggestions: vi.fn(async (lines, cursorLine, cursorCol, options) => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + calls.push({ force: options?.force, text }); + return { items, prefix: text }; + }), + applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), + }; + return { provider, calls }; +} describe('CustomEditor autocomplete Escape handling', () => { it('escape closes a visible slash command menu without firing app-level escape', async () => { @@ -112,7 +72,9 @@ describe('CustomEditor autocomplete Escape handling', () => { getSuggestions: vi.fn( () => new Promise<AutocompleteSuggestions | null>((resolve) => { - resolveSuggestions = (items) =>{ resolve({ items, prefix: '/' }); }; + resolveSuggestions = (items) => { + resolve({ items, prefix: '/' }); + }; }), ), applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ lines, cursorLine, cursorCol })), @@ -131,153 +93,323 @@ describe('CustomEditor autocomplete Escape handling', () => { }); }); -describe('CustomEditor configurable autocomplete routing', () => { - it('prefers autocomplete bindings over chat bindings and returns to chat after closing', async () => { +describe('CustomEditor onNonEscapeInput', () => { + it('fires for a printable key and not for a lone Escape', () => { const editor = makeEditor(); - const onCommand = vi.fn(); - editor.onCommand = onCommand; - editor.setAutocompleteProvider( - providerReturning([ - { value: 'first-command', label: 'first-command' }, - { value: 'second-command', label: 'second-command' }, - ]), - ); - editor.setKeybindings( - parseKeybindingBlocks([ - { context: 'Autocomplete', bindings: { 'alt+j': 'autocomplete:next' } }, - { context: 'Chat', bindings: { 'alt+j': 'chat:modelPicker' } }, - ]), - ); - - editor.handleInput('/'); - await flushAutocomplete(); - editor.handleInput('\u001Bj'); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; - expect(editor.render(80).join('\n')).toContain('second-command'); - expect(onCommand).not.toHaveBeenCalled(); + editor.handleInput('a'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); editor.handleInput('\u001B'); - editor.handleInput('\u001Bj'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); + }); - expect(onCommand).toHaveBeenCalledWith('model'); + it('fires for control keys so they break a pending double-Esc', () => { + const editor = makeEditor(); + const onNonEscapeInput = vi.fn(); + editor.onNonEscapeInput = onNonEscapeInput; + + editor.handleInput('\u0003'); + expect(onNonEscapeInput).toHaveBeenCalledOnce(); }); +}); - it('forwards the canonical Down sequence to the autocomplete list', async () => { +describe('CustomEditor slash argument completion refresh', () => { + it('reopens /add-dir directory completions after tab completion and entering slash', async () => { const editor = makeEditor(); - editor.setAutocompleteProvider( - providerReturning([ - { value: 'first-command', label: 'first-command' }, - { value: 'second-command', label: 'second-command' }, - ]), + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => + prefix === '/' ? [{ value: '/tmp/shared/', label: 'shared/' }] : null, + }, + ], + process.cwd(), + null, ); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); await flushAutocomplete(); - editor.handleInput('\u001B[B'); - const autocomplete = editor as unknown as { - autocompleteList?: { getSelectedItem(): AutocompleteItem | null }; - }; - expect(autocomplete.autocompleteList?.getSelectedItem()?.value).toBe('second-command'); - expect(editor.getText()).toBe('/'); + expect(editor.getText()).toBe('/add-dir /'); expect(editor.isShowingAutocomplete()).toBe(true); }); - it('honors autocomplete null-unbindings over defaults', async () => { + it('reopens the next directory level after tab-accepting a directory', async () => { const editor = makeEditor(); - editor.setAutocompleteProvider( - providerReturning([ - { value: 'first-command', label: 'first-command' }, - { value: 'second-command', label: 'second-command' }, - ]), + const provider = new FileMentionProvider( + [ + { + name: 'add-dir', + description: 'Add directory', + getArgumentCompletions: (prefix) => { + if (prefix === '/') return [{ value: '/tmp/shared/', label: 'shared/' }]; + if (prefix === '/tmp/shared/') + return [{ value: '/tmp/shared/child/', label: 'child/' }]; + return null; + }, + }, + ], + process.cwd(), + null, ); - editor.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Autocomplete', bindings: { down: null } }]), - ]); + editor.setAutocompleteProvider(provider); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await flushAutocomplete(); editor.handleInput('/'); + await new Promise((resolve) => setTimeout(resolve, 20)); await flushAutocomplete(); - editor.handleInput('\u001B[B'); + expect(editor.isShowingAutocomplete()).toBe(true); - expect(editor.render(80).join('\n')).toContain('first-command'); + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + + expect(editor.getText()).toBe('/add-dir /tmp/shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); }); }); -describe('CustomEditor slash menu description wrapping', () => { - // oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to match ANSI SGR escape sequences - const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); - - it('renders a compact slash menu below the composer, aligned with its slash', async () => { +describe('CustomEditor slash command name Tab-accept', () => { + it('reopens subcommand completions after Tab-accepting a slash command name', async () => { const editor = makeEditor(); - editor.setAutocompleteProvider( - providerReturning([ - { value: 'auto', label: 'auto', description: 'Toggle Auto mode' }, - { value: 'colors', label: 'colors', description: 'Toggle colors' }, - ]), + const provider = new FileMentionProvider( + [ + { + name: 'goal', + description: 'Manage goals', + getArgumentCompletions: (prefix) => + prefix === '' + ? [ + { value: 'status', label: 'status' }, + { value: 'pause', label: 'pause' }, + ] + : null, + }, + ], + process.cwd(), + null, ); + editor.setAutocompleteProvider(provider); - editor.handleInput('/'); + for (const char of '/go') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); await flushAutocomplete(); - const lines = editor.render(80).map(stripAnsi); - const promptIndex = lines.findIndex((line) => line.startsWith('❯ /')); - const selectedIndex = lines.findIndex((line) => line.startsWith('❯ auto')); - const colorsIndex = lines.findIndex((line) => line.startsWith(' colors')); - - expect(lines[0]).toMatch(/^─+$/u); - expect(lines.join('\n')).not.toContain('Slash commands'); - expect(promptIndex).toBe(1); - expect(lines[promptIndex + 1]).toMatch(/^─+$/u); - expect(selectedIndex).toBeGreaterThan(promptIndex + 1); - expect(colorsIndex).toBeGreaterThan(selectedIndex); - expect(lines[selectedIndex]).toMatch(/^❯ auto/u); - expect(lines[colorsIndex]).toMatch(/^ {2}colors/u); - expect(lines[selectedIndex]?.indexOf('auto')).toBe( - lines[promptIndex]?.indexOf('/'), - ); + expect(editor.getText()).toBe('/goal '); + expect(editor.isShowingAutocomplete()).toBe(true); }); - it('aligns a wrapped slash menu with the active slash', async () => { + it('does not fall back to file completions for a command without subcommands', async () => { const editor = makeEditor(); - editor.setAutocompleteProvider( - providerReturning([{ value: 'help', label: 'help', description: 'Show help' }]), + const provider = new FileMentionProvider( + [ + { + name: 'compact', + description: 'Compact context', + }, + ], + process.cwd(), + null, ); + editor.setAutocompleteProvider(provider); + + for (const char of '/comp') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); - editor.setText('review this long prompt /he'); editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 20)); await flushAutocomplete(); - const lines = editor.render(24).map(stripAnsi); - const promptLine = lines.find((line) => line.includes('/he')); - const selectedLine = lines.find((line) => line.trimStart().startsWith('❯ help')); + expect(editor.getText()).toBe('/compact '); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor @ mention completion refresh', () => { + it('reopens the next directory level after tab-accepting an @ directory', async () => { + const editor = makeEditor(); + const provider: AutocompleteProvider = { + getSuggestions: vi.fn( + async ( + lines: string[], + cursorLine: number, + cursorCol: number, + ): Promise<AutocompleteSuggestions> => { + const text = (lines[cursorLine] ?? '').slice(0, cursorCol); + if (text === '@') { + return { items: [{ value: '@shared/', label: 'shared/' }], prefix: '@' }; + } + if (text === '@shared/') { + return { items: [{ value: '@shared/child/', label: 'child/' }], prefix: '@shared/' }; + } + return { items: [], prefix: '' }; + }, + ), + applyCompletion: vi.fn( + ( + lines: string[], + cursorLine: number, + cursorCol: number, + item: AutocompleteItem, + prefix: string, + ) => { + const line = lines[cursorLine] ?? ''; + const beforePrefix = line.slice(0, cursorCol - prefix.length); + const afterCursor = line.slice(cursorCol); + const newLine = beforePrefix + item.value + afterCursor; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length, + }; + }, + ), + }; + editor.setAutocompleteProvider(provider); + + editor.handleInput('@'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); + expect(editor.isShowingAutocomplete()).toBe(true); + + editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); - expect(lines[0]).toMatch(/^╭/u); - expect(promptLine).toBeDefined(); - expect(selectedLine).toBeDefined(); - expect(selectedLine?.indexOf('help')).toBe(promptLine?.indexOf('/he')); + expect(editor.getText()).toBe('@shared/'); + expect(editor.isShowingAutocomplete()).toBe(true); }); +}); - it('aligns the slash menu by terminal cells after wide and combining graphemes', async () => { +describe('CustomEditor Tab key handling', () => { + it('does not open autocomplete when Tab is pressed with the dropdown closed', async () => { const editor = makeEditor(); - editor.setAutocompleteProvider( - providerReturning([{ value: 'help', label: 'help', description: 'Show help' }]), - ); + const provider = providerReturning([{ value: '@src/file.ts', label: 'file.ts' }]); + editor.setAutocompleteProvider(provider); - editor.setText('猫🇺🇸e\u0301 /he'); editor.handleInput('\t'); + await new Promise((resolve) => setTimeout(resolve, 30)); await flushAutocomplete(); - const lines = editor.render(40).map(stripAnsi); - const promptLine = lines.find((line) => line.includes('/he')) ?? ''; - const selectedLine = lines.find((line) => line.trimStart().startsWith('❯ help')) ?? ''; - const promptPrefix = promptLine.slice(0, promptLine.indexOf('/he')); - const menuPrefix = selectedLine.slice(0, selectedLine.indexOf('help')); + expect(provider.getSuggestions).not.toHaveBeenCalled(); + expect(editor.isShowingAutocomplete()).toBe(false); + }); +}); + +describe('CustomEditor slash argument hint', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('renders the argument hint after a command with a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('renders the argument hint after a command without a trailing space', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).toContain('[list] | <path>'); + }); + + it('hides the hint once an argument is typed', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/add-dir foo') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render a hint for an unknown command', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + + for (const char of '/unknown ') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); + + it('does not render the argument hint in bash mode', () => { + const editor = makeEditor(); + editor.setArgumentHints(new Map([['add-dir', '[list] | <path>']])); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const plain = editor.render(90).map(stripAnsi).join('\n'); + expect(plain).not.toContain('[list] | <path>'); + }); - expect(promptLine).not.toBe(''); - expect(selectedLine).not.toBe(''); - expect(visibleWidth(menuPrefix)).toBe(visibleWidth(promptPrefix)); + it('does not highlight the slash token in bash mode', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + + for (const char of '/add-dir') { + editor.handleInput(char); + } + + const contentLine = editor.render(90)[1] ?? ''; + const tokenIdx = contentLine.indexOf('/add-dir'); + expect(tokenIdx).toBeGreaterThan(-1); + // Prompt mode wraps `/add-dir` in a primary-colour ANSI sequence; in bash + // mode the token is plain text, so the byte right before it is a space. + expect(contentLine[tokenIdx - 1]).toBe(' '); }); +}); + +describe('CustomEditor slash menu description wrapping', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); it('wraps long slash command descriptions to at most two lines with an ellipsis', async () => { const editor = makeEditor(); @@ -321,39 +453,6 @@ describe('CustomEditor slash menu description wrapping', () => { expect(descriptionLines).toHaveLength(1); expect(plain.join('\n')).not.toContain('…'); }); - - it('renders inline ghost text for slash completions in the middle of the prompt', async () => { - const editor = makeEditor(); - const provider: AutocompleteProvider = { - getSuggestions: vi.fn(async () => ({ - items: [ - { value: 'help', label: 'help' }, - { value: 'hello', label: 'hello' }, - ], - prefix: '/he', - })), - applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ - lines, - cursorLine, - cursorCol, - })), - }; - editor.setAutocompleteProvider(provider); - - editor.setText('ship /he'); - editor.handleInput('\t'); - await flushAutocomplete(); - - const after = editor.render(24).map(stripAnsi); - const promptLine = after.find((line) => line.startsWith('❯ ship /he')); - const selectedLine = after.find((line) => line.trimStart().startsWith('❯ help')); - expect(after.join('\n')).toContain('ship /he'); - expect(after.join('\n')).toContain('lp'); - expect(promptLine).toBeDefined(); - expect(selectedLine).toBeDefined(); - expect(selectedLine?.indexOf('help')).toBe(promptLine?.indexOf('/he')); - expect(Math.max(...after.map((line) => line.length))).toBe(24); - }); }); describe('CustomEditor Kitty key release handling', () => { @@ -417,7 +516,6 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toContain('[paste #1'); expect(editor.getText()).toContain('[paste #2'); - // Cursor sits at the end of marker #2 after the second paste. simulateLargePaste(editor, 'anything'); expect(editor.getText()).toContain('[paste #1'); @@ -433,60 +531,30 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toMatch(/\[paste #1/); - editor.handleInput('\u0016'); + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); expect(editor.getText()).not.toContain('[paste #'); expect(editor.getText()).toContain(longText); }); - it('falls back to text paste when the image paste handler rejects', async () => { + it('can re-expand after undo restores the marker', () => { const editor = makeEditor(); - const onTextPaste = vi.fn(); - editor.onTextPaste = onTextPaste; - editor.onPasteImage = vi.fn(async () => { - throw new Error('clipboard backend broken'); - }); - const rejections: unknown[] = []; - const onRejection = (reason: unknown): void => { - rejections.push(reason); - }; - process.on('unhandledRejection', onRejection); - - try { - editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - expect(onTextPaste).toHaveBeenCalledOnce(); - expect(rejections).toHaveLength(0); - } finally { - process.off('unhandledRejection', onRejection); - } - }); + const longText = 'line\n'.repeat(15).trimEnd(); + simulateLargePaste(editor, longText); - it('keeps other markers expandable after one marker is expanded', () => { - const editor = makeEditor(); - const text1 = 'first\n'.repeat(15).trimEnd(); - const text2 = 'second\n'.repeat(15).trimEnd(); - simulateLargePaste(editor, text1); - editor.handleInput(' '); - simulateLargePaste(editor, text2); + const markerText = editor.getText(); + expect(markerText).toMatch(/\[paste #1/); - // Expand marker #2 (cursor sits at its end after the paste). simulateLargePaste(editor, 'anything'); - expect(editor.getText()).toContain(text2); + expect(editor.getText()).toContain(longText); + + // Undo (Ctrl+-) restores both the marker text and its paste-registry entry. + editor.handleInput('\x1b[45;5u'); expect(editor.getText()).toContain('[paste #1'); - // Move the cursor onto marker #1 and expand it too; its content must - // have survived the setText() inside the first expansion. - const state = (editor as unknown as { state: { cursorLine: number; cursorCol: number } }) - .state; - state.cursorLine = 0; - state.cursorCol = 0; simulateLargePaste(editor, 'anything'); expect(editor.getText()).not.toContain('[paste #'); - expect(editor.getText()).toContain(text1); + expect(editor.getText()).toContain(longText); }); it('suppresses multi-chunk bracketed paste data after marker expansion', () => { @@ -519,22 +587,64 @@ describe('CustomEditor paste marker expansion', () => { editor.handleInput('x'); expect(editor.getText()).toContain('x'); }); -}); -describe('CustomEditor shortcut telemetry hooks', () => { - it('reports newline shortcuts, including Ctrl-J, before delegating to the base editor', () => { + it('falls back to the text paste path when the image paste handler rejects', async () => { const editor = makeEditor(); - const onInsertNewline = vi.fn(); - editor.onInsertNewline = onInsertNewline; + const onTextPaste = vi.fn(); + editor.onTextPaste = onTextPaste; + editor.onPasteImage = vi.fn(async () => { + throw new Error('clipboard backend broken'); + }); - editor.handleInput('a'); - editor.handleInput('\n'); - editor.handleInput('\u001B[106;5u'); + // Regression: a rejecting onPasteImage must not leak an unhandled + // rejection — the CLI's crash path turns those into a silent exit. + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => { + rejections.push(reason); + }; + process.on('unhandledRejection', onRejection); + try { + editor.handleInput(process.platform === 'win32' ? '\u001Bv' : '\u0016'); + await new Promise((resolve) => { + setImmediate(resolve); + }); + + expect(onTextPaste).toHaveBeenCalledOnce(); + expect(rejections).toHaveLength(0); + } finally { + process.off('unhandledRejection', onRejection); + } + }); + + it('queues Enter and typing until an asynchronous image paste inserts its placeholder', async () => { + const editor = makeEditor(); + const submit = vi.fn(); + editor.onSubmit = submit; + let resolvePaste!: (handled: boolean) => void; + editor.onPasteImage = () => + new Promise<boolean>((resolve) => { + resolvePaste = (handled) => { + editor.insertTextAtCursor?.('[image #1 (1×1)] '); + resolve(handled); + }; + }); + + const pasteKey = process.platform === 'win32' ? '\u001Bv' : '\u0016'; + editor.handleInput(pasteKey); + editor.handleInput('hello'); + editor.handleInput('\r'); - expect(onInsertNewline).toHaveBeenCalledTimes(2); - expect(editor.getText()).toBe('a\n\n'); + expect(editor.getText()).toBe(''); + expect(submit).not.toHaveBeenCalled(); + + resolvePaste(true); + await new Promise((resolve) => setImmediate(resolve)); + + expect(submit).toHaveBeenCalledWith('[image #1 (1×1)] hello'); }); +}); +describe('CustomEditor shortcut telemetry hooks', () => { it('reports undo shortcuts before delegating to the base editor', () => { const editor = makeEditor(); const onUndo = vi.fn(); @@ -546,235 +656,162 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onUndo).toHaveBeenCalledOnce(); }); - it('routes Ctrl-T to onCycleEffort without inserting text', () => { + it('invokes onToggleTodoExpand on Ctrl+T', () => { const editor = makeEditor(); - const onCycleEffort = vi.fn(); - editor.onCycleEffort = onCycleEffort; + const onToggleTodoExpand = vi.fn().mockReturnValue(true); + editor.onToggleTodoExpand = onToggleTodoExpand; editor.handleInput('\u0014'); - expect(onCycleEffort).toHaveBeenCalledOnce(); - expect(editor.getText()).toBe(''); + expect(onToggleTodoExpand).toHaveBeenCalledOnce(); }); +}); - it('forwards canonical Up and Down sequences to prompt history', () => { +describe('CustomEditor bash mode border label', () => { + // oxlint-disable-next-line no-control-regex -- ESC (\u001B) is required to match ANSI SGR escape sequences + const stripAnsi = (s: string): string => s.replaceAll(/\u001B\[[0-9;]*m/g, ''); + + it('shows "! shell mode" on the top border in bash mode', () => { const editor = makeEditor(); - editor.addToHistory('older prompt'); - editor.addToHistory('newer prompt'); + editor.inputMode = 'bash'; + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top.startsWith('╭')).toBe(true); + expect(top).toContain('! shell mode'); + expect(top.endsWith('╮')).toBe(true); + }); - editor.handleInput('\u001B[A'); - expect(editor.getText()).toBe('newer prompt'); + it('does not show the shell mode label in prompt mode', () => { + const editor = makeEditor(); + const top = stripAnsi(editor.render(90)[0] ?? ''); + expect(top).not.toContain('! shell mode'); + }); - editor.handleInput('\u001B[B'); - expect(editor.getText()).toBe(''); + it('keeps the top border at full width when the label is present', () => { + const editor = makeEditor(); + editor.inputMode = 'bash'; + const width = 90; + const top = stripAnsi(editor.render(width)[0] ?? ''); + expect(top).toHaveLength(width); }); +}); - it('routes configured Ctrl-R to prompt history search without inserting text', () => { +describe('CustomEditor bash mode via paste', () => { + const PASTE_START = '\u001B[200~'; + const PASTE_END = '\u001B[201~'; + + it('enters bash mode and strips the leading ! when !cmd is pasted into an empty prompt', () => { const editor = makeEditor(); - const onSearchHistory = vi.fn(); - editor.onSearchHistory = onSearchHistory; - editor.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { 'ctrl+r': 'chat:historySearch' }, - }, - ]), - ); + const modes: Array<'prompt' | 'bash'> = []; + editor.onInputModeChange = (mode) => modes.push(mode); - editor.handleInput('\u0012'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); - expect(onSearchHistory).toHaveBeenCalledOnce(); - expect(editor.getText()).toBe(''); + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe('ls'); + expect(modes).toEqual(['bash']); }); - it('routes configured Shift-Up to transcript message actions', () => { + it('enters bash mode on a bare pasted ! with an empty buffer', () => { const editor = makeEditor(); - const onMessageActions = vi.fn(); - editor.onMessageActions = onMessageActions; - editor.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { 'shift+up': 'chat:messageActions' }, - }, - ]), - ); - - editor.handleInput('\u001B[1;2A'); + editor.handleInput(`${PASTE_START}!${PASTE_END}`); - expect(onMessageActions).toHaveBeenCalledOnce(); + expect(editor.inputMode).toBe('bash'); expect(editor.getText()).toBe(''); }); - it('routes configured shortcuts and chords through the existing callbacks', () => { + it('does not enter bash mode when pasting !cmd into a non-empty prompt', () => { const editor = makeEditor(); - const onCycleEffort = vi.fn(); - const onOpenExternalEditor = vi.fn(); - editor.onCycleEffort = onCycleEffort; - editor.onOpenExternalEditor = onOpenExternalEditor; - editor.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { - 'alt+t': 'chat:thinkingToggle', - 'ctrl+k ctrl+g': 'chat:externalEditor', - }, - }, - ]), - ); - - editor.handleInput('\u001Bt'); - editor.handleInput('\u000B'); - editor.handleInput('\u0007'); + editor.handleInput('hello'); + editor.handleInput(`${PASTE_START}!ls${PASTE_END}`); - expect(onCycleEffort).toHaveBeenCalledOnce(); - expect(onOpenExternalEditor).toHaveBeenCalledOnce(); - expect(editor.getText()).toBe(''); + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toContain('hello'); + expect(editor.getText()).toContain('!ls'); }); - it('routes command keybindings without inserting text', () => { + it('does not enter bash mode for a pasted command without a leading !', () => { const editor = makeEditor(); - const onCommand = vi.fn(); - editor.onCommand = onCommand; - editor.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { - 'alt+h': 'command:help', - }, - }, - ]), - ); - - editor.handleInput('\u001Bh'); + editor.handleInput(`${PASTE_START}ls${PASTE_END}`); - expect(onCommand).toHaveBeenCalledWith('help'); - expect(editor.getText()).toBe(''); + expect(editor.inputMode).toBe('prompt'); + expect(editor.getText()).toBe('ls'); }); - it('routes source-compatible editor actions through native behavior', () => { + it('keeps the typed ! behaviour (bash mode, empty buffer)', () => { const editor = makeEditor(); - const onEscape = vi.fn(); - const onRedraw = vi.fn(); - const onCommand = vi.fn(); - const onSubmit = vi.fn(); - editor.onEscape = onEscape; - editor.onRedraw = onRedraw; - editor.onCommand = onCommand; - editor.onSubmit = onSubmit; - editor.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { - 'alt+c': 'chat:cancel', - 'alt+l': 'app:redraw', - 'alt+p': 'chat:modelPicker', - 'alt+s': 'chat:submit', - }, - }, - ]), - ); - editor.setText('send this'); - - editor.handleInput('\u001Bc'); - editor.handleInput('\u001Bl'); - editor.handleInput('\u001Bp'); - editor.handleInput('\u001Bs'); + editor.handleInput('!'); - expect(onEscape).toHaveBeenCalledOnce(); - expect(onRedraw).toHaveBeenCalledOnce(); - expect(onCommand).toHaveBeenCalledWith('model'); - expect(onSubmit).toHaveBeenCalledWith('send this'); + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); }); -}); -describe('CustomEditor rainbow frame', () => { - it('colors the compact prompt glyph with the current rainbow frame color', () => { - const previousLevel = chalk.level; - const colors: RainbowColorController = { - colored: true, - phase: 0, - start: () => {}, - stop: () => {}, - dispose: () => {}, - }; - chalk.level = 3; - setRainbowColors(colors); + it('enters bash mode on a CSI-u encoded ! keystroke (Kitty/VSCode terminals)', () => { + const editor = makeEditor(); + editor.handleInput('\u001B[33u'); - try { - const lines = makeEditor().render(20); - const plainLines = lines.map(stripAnsi); - - expect(plainLines).toHaveLength(3); - expect(plainLines[1]).toMatch(/^❯ /); - expect(plainLines.join('')).not.toMatch(/[╭╮╰╯│]/u); - // The painter advances per segment, so the rules consume frames before - // the glyph; assert every compact row is painted rather than a fixed hue. - expect(lines[0]).toMatch(/\[38;2;\d+;\d+;\d+m─/u); - expect(lines[1]).toMatch(/\[38;2;\d+;\d+;\d+m❯\[39m/u); - expect(lines[2]).toMatch(/\[38;2;\d+;\d+;\d+m─/u); - } finally { - setRainbowColors(undefined); - chalk.level = previousLevel; - } + expect(editor.inputMode).toBe('bash'); + expect(editor.getText()).toBe(''); }); }); -describe('CustomEditor Vim mode indicator', () => { - it('shows each active Vim mode on the composer border', () => { +describe('CustomEditor bash mode file completion', () => { + it('triggers file completion (force:true) for a leading / in bash mode, not the slash menu', async () => { const editor = makeEditor(); - editor.setVimMode(true); - - expect(stripAnsi(editor.render(40).at(-1) ?? '')).toContain(' NORMAL '); - - editor.handleInput('i'); - expect(stripAnsi(editor.render(40).at(-1) ?? '')).toContain(' INSERT '); - - editor.handleInput('\u001B'); - expect(stripAnsi(editor.render(40).at(-1) ?? '')).toContain(' NORMAL '); + const { provider, calls } = providerRecordingForce([{ value: 'auto', label: 'auto' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; - editor.handleInput('v'); - expect(stripAnsi(editor.render(40).at(-1) ?? '')).toContain(' VISUAL '); + editor.handleInput('/'); + await flushAutocomplete(); - editor.setVimMode(false); - expect(stripAnsi(editor.render(40).at(-1) ?? '')).not.toMatch(/ (?:NORMAL|INSERT|VISUAL) /u); + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); }); - it('keeps the Vim mode inside the rounded multiline border', () => { + it('triggers file completion (force:true) for an inline / in bash mode', async () => { const editor = makeEditor(); - editor.setVimMode(true); - editor.setText('first line\nsecond line'); + const { provider, calls } = providerRecordingForce([{ value: 'etc', label: 'etc' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; - expect(stripAnsi(editor.render(40).at(-1) ?? '')).toMatch(/^╰─ NORMAL ─+╯$/u); + for (const char of 'ls /') { + editor.handleInput(char); + } + await flushAutocomplete(); + + expect(calls).toContainEqual(expect.objectContaining({ force: true, text: 'ls /' })); + expect(editor.isShowingAutocomplete()).toBe(true); }); -}); -describe('CustomEditor compact composer', () => { - it('renders a single-line value as one unboxed prompt row', () => { + it('keeps force:false (slash menu) for a leading / in prompt mode', async () => { const editor = makeEditor(); - editor.setText('ship it'); + const { provider, calls } = providerRecordingForce([{ value: 'help', label: 'help' }]); + editor.setAutocompleteProvider(provider); + // inputMode defaults to 'prompt' - const lines = editor.render(40).map(stripAnsi); + editor.handleInput('/'); + await flushAutocomplete(); - expect(lines).toHaveLength(3); - expect(lines[0]).toMatch(/^─+$/u); - expect(lines[1]).toMatch(/^❯ ship it/); - expect(lines[2]).toMatch(/^─+$/u); - expect(lines.join('')).not.toMatch(/[╭╮╰╯│]/u); + expect(calls).toContainEqual(expect.objectContaining({ force: false, text: '/' })); + expect(editor.isShowingAutocomplete()).toBe(true); }); - it('keeps explicit multiline input inside the bordered editor', () => { + it('never falls back to force:false for a slash-shaped command in bash mode', async () => { const editor = makeEditor(); - editor.setText('first line\nsecond line'); + const { provider, calls } = providerRecordingForce([{ value: 'list', label: 'list' }]); + editor.setAutocompleteProvider(provider); + editor.inputMode = 'bash'; - const lines = editor.render(40).map(stripAnsi); + for (const char of '/add-dir ') { + editor.handleInput(char); + } + await new Promise((resolve) => setTimeout(resolve, 30)); + await flushAutocomplete(); - expect(lines[0]).toMatch(/^╭/u); - expect(lines.at(-1)).toMatch(/╯$/u); - expect(lines.join('\n')).toContain('second line'); + // A force:false request would let pi-tui's own slash-command handling pop + // up subcommand completions for `/add-dir `. Bash mode must only ever + // request force:true path completion. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.force === true)).toBe(true); }); }); diff --git a/apps/pythinker-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/pythinker-code/test/tui/components/editor/file-mention-provider.test.ts index 9b62bb37..7366aec1 100644 --- a/apps/pythinker-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/file-mention-provider.test.ts @@ -1,8 +1,9 @@ +import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileMentionProvider } from '#/tui/components/editor/file-mention-provider'; @@ -11,11 +12,22 @@ function ctrl(): AbortSignal { } const NO_FD = null; + +function resolveFdPath(): string | null { + const command = process.platform === 'win32' ? 'where' : 'which'; + const result = spawnSync(command, ['fd'], { encoding: 'utf-8' }); + if (result.status !== 0 || !result.stdout) return null; + const firstLine = result.stdout.split(/\r?\n/).find(Boolean); + return firstLine ? firstLine.trim() : null; +} + +const FD_PATH = resolveFdPath(); +const IS_FD_INSTALLED = Boolean(FD_PATH); const GOAL_COMMAND = { name: 'goal', description: 'Start or manage a goal', getArgumentCompletions: (prefix: string) => - prefix.length === 0 || 'status'.startsWith(prefix) + prefix.length === 0 ? [ { value: 'status', @@ -49,17 +61,43 @@ const HELP_FULL_COMMAND = { description: 'Show help', }; +const ADD_DIR_COMMAND = { + name: 'add-dir', + description: 'Add or list an additional workspace directory', + getArgumentCompletions: (prefix: string) => + prefix === '/' + ? [ + { + value: '/tmp/shared/', + label: 'shared/', + description: '/tmp/shared', + }, + ] + : null, +}; + describe('FileMentionProvider', () => { let workDir: string; + let extraDirs: string[]; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), 'pythinker-file-mention-')); + extraDirs = []; }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); + for (const extraDir of extraDirs) { + rmSync(extraDir, { recursive: true, force: true }); + } }); + function createExtraDir(): string { + const extraDir = mkdtempSync(join(tmpdir(), 'pythinker-file-mention-extra-')); + extraDirs.push(extraDir); + return extraDir; + } + it('returns null when there is no completable prefix', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['hello world'], 0, 11, { signal: ctrl() }); @@ -73,6 +111,21 @@ describe('FileMentionProvider', () => { expect(result).toBeNull(); }); + it('opens @ file mention when typed in the middle of a slash command argument', async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + // Cursor sits in the middle of the /goal argument text, right after a + // freshly typed `@`. The slash-argument guard must not suppress the @ + // file list here. + const line = '/goal Fix the @checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal Fix the @'.length, { + signal: ctrl(), + }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('@'); + expect(result!.items.map((item) => item.value)).toContain('@README.md'); + }); + it('still completes slash arguments at the end of an empty argument', async () => { const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); const line = '/goal '; @@ -82,6 +135,20 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toEqual(['status']); }); + it('opens add-dir directory completions after slash command completion and entering slash', async () => { + const provider = new FileMentionProvider([ADD_DIR_COMMAND], workDir, NO_FD); + const command = ADD_DIR_COMMAND; + const completed = provider.applyCompletion(['/add'], 0, 4, { value: command.name, label: command.name }, '/add'); + const completedLine = completed.lines[0]!; + const line = `${completedLine}/`; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(completedLine).toBe('/add-dir '); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + it('searches slash command aliases and displays aliases in the command label', async () => { const provider = new FileMentionProvider([NEW_COMMAND], workDir, NO_FD); const line = '/clear'; @@ -96,30 +163,6 @@ describe('FileMentionProvider', () => { }); }); - it('completes slash command names in the middle of prompt text', async () => { - const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); - const line = 'please run /he next'; - const cursorCol = 'please run /he'.length; - - const result = await provider.getSuggestions([line], 0, cursorCol, { signal: ctrl() }); - - expect(result).not.toBeNull(); - expect(result!.prefix).toBe('/he'); - expect(result!.items[0]).toMatchObject({ value: 'help', label: 'help' }); - }); - - it('completes slash arguments in the middle of prompt text', async () => { - const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); - const line = 'please run /goal st next'; - const cursorCol = 'please run /goal st'.length; - - const result = await provider.getSuggestions([line], 0, cursorCol, { signal: ctrl() }); - - expect(result).not.toBeNull(); - expect(result!.prefix).toBe('st'); - expect(result!.items.map((item) => item.value)).toEqual(['status']); - }); - it('prefers exact alias matches over fuzzy skill matches', async () => { const provider = new FileMentionProvider( [NEW_COMMAND, LARK_CALENDAR_COMMAND], @@ -234,23 +277,6 @@ describe('FileMentionProvider', () => { expect(result!.prefix).toBe('/'); }); - it('falls through to forced absolute-path completion when a slash token has no command matches', async () => { - const provider = new FileMentionProvider([], workDir, NO_FD); - const rootSegment = workDir.split('/').filter((segment) => segment.length > 0)[0]!; - const prefix = - process.platform === 'darwin' ? '/Us' : `/${rootSegment.slice(0, Math.min(2, rootSegment.length))}`; - const expectedValue = process.platform === 'darwin' ? '/Users/' : `/${rootSegment}/`; - - const result = await provider.getSuggestions([prefix], 0, prefix.length, { - signal: ctrl(), - force: true, - }); - - expect(result).not.toBeNull(); - expect(result!.prefix).toBe(prefix); - expect(result!.items.map((item) => item.value)).toContain(expectedValue); - }); - it('does not trigger the @ branch when @ is preceded by a non-delimiter', async () => { const provider = new FileMentionProvider([], workDir, NO_FD); const result = await provider.getSuggestions(['email@example'], 0, 13, { signal: ctrl() }); @@ -270,15 +296,118 @@ describe('FileMentionProvider', () => { expect(result!.items.map((item) => item.value)).toContain('@src/components/Button.tsx'); }); - it('does not bypass fd filtering with filesystem suggestions when fd returns no matches', async () => { - writeFileSync(join(workDir, 'README.md'), 'readme'); - const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd')); + it('uses the filesystem fallback for additionalDirs when fd is unavailable', async () => { + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, join(workDir, 'missing-fd'), [extraDir]); - const result = await provider.getSuggestions(['@read'], 0, 5, { signal: ctrl() }); + const result = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); - expect(result).toBeNull(); + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); + }); + + it.runIf(IS_FD_INSTALLED)( + 'uses fd for additionalDirs even when cwd is large enough to exhaust the fallback scanner', + async () => { + // Fill cwd with enough entries to push the filesystem fallback past its + // 2000-entry scan cap, so it would never reach the additional root. fd + // searches each root independently and still finds the deep target. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, FD_PATH!, [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it.runIf(IS_FD_INSTALLED)( + 'treats a bare fd command name as executable and resolves it via PATH', + async () => { + // A bare "fd" (system PATH lookup) must not be mistaken for unavailable; + // otherwise the large cwd would push the fallback scanner past its cap + // and hide the deep target in the additional root. + for (let i = 0; i < 2000; i++) { + writeFileSync(join(workDir, `filler-${i}.ts`), 'export {};'); + } + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'deep'), { recursive: true }); + writeFileSync(join(extraDir, 'deep', 'target-needle.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, 'fd', [extraDir]); + + const result = await provider.getSuggestions(['@target-needle'], 0, '@target-needle'.length, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'deep', 'target-needle.ts').replaceAll('\\', '/')}`, + ); + }, + ); + + it('keeps cwd @ mention values relative and additionalDir values absolute', async () => { + mkdirSync(join(workDir, 'src'), { recursive: true }); + writeFileSync(join(workDir, 'src', 'Cwd.ts'), 'export {};'); + const extraDir = createExtraDir(); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Additional.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const cwdResult = await provider.getSuggestions(['@cwd'], 0, 4, { signal: ctrl() }); + expect(cwdResult).not.toBeNull(); + expect(cwdResult!.items.map((item) => item.value)).toContain('@src/Cwd.ts'); + + const additionalResult = await provider.getSuggestions(['@add'], 0, 4, { signal: ctrl() }); + expect(additionalResult).not.toBeNull(); + expect(additionalResult!.items.map((item) => item.value)).toContain( + `@${join(extraDir, 'src', 'Additional.ts').replaceAll('\\', '/')}`, + ); + }); + + it('deduplicates cwd and additionalDir candidates by absolute path', async () => { + const extraDir = join(workDir, 'extra'); + mkdirSync(join(extraDir, 'src'), { recursive: true }); + writeFileSync(join(extraDir, 'src', 'Overlap.ts'), 'export {};'); + const provider = new FileMentionProvider([], workDir, NO_FD, [extraDir]); + + const result = await provider.getSuggestions(['@overlap'], 0, 8, { signal: ctrl() }); + + expect(result).not.toBeNull(); + const overlapItems = result!.items.filter( + (item) => item.description === join(extraDir, 'src', 'Overlap.ts').replaceAll('\\', '/'), + ); + expect(overlapItems).toHaveLength(1); }); + it.runIf(IS_FD_INSTALLED)( + 'does not bypass fd filtering with filesystem suggestions when fd returns no matches', + async () => { + writeFileSync(join(workDir, 'README.md'), 'readme'); + const provider = new FileMentionProvider([], workDir, FD_PATH!); + + const result = await provider.getSuggestions(['@zzz-no-match-xyz'], 0, '@zzz-no-match-xyz'.length, { + signal: ctrl(), + }); + + expect(result).toBeNull(); + }, + ); + it('filesystem fallback returns folders and excludes .git', async () => { mkdirSync(join(workDir, 'src')); mkdirSync(join(workDir, '.git')); @@ -349,59 +478,305 @@ describe('FileMentionProvider', () => { expect(dir.lines[0]).toBe('hey @src/'); }); - it('applyCompletion replaces an embedded slash command token', () => { - const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); - - const result = provider.applyCompletion( - ['please run /he next'], - 0, - 'please run /he'.length, - { value: 'help', label: 'help' }, - '/he', - ); + describe('bash-mode path completion dotfile filtering', () => { + it('hides dot-prefixed entries (matching /add-dir) in bash mode', async () => { + mkdirSync(join(workDir, '.hidden')); + mkdirSync(join(workDir, 'visible')); + writeFileSync(join(workDir, '.dotfile'), ''); + writeFileSync(join(workDir, 'normal.txt'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('visible/'); + expect(labels).toContain('normal.txt'); + expect(labels).not.toContain('.hidden/'); + expect(labels).not.toContain('.dotfile'); + }); - expect(result.lines[0]).toBe('please run /help next'); + it('keeps dot-prefixed entries in prompt mode', async () => { + mkdirSync(join(workDir, '.hidden')); + writeFileSync(join(workDir, '.dotfile'), ''); + + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const text = `cd ${workDir}/`; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(result).not.toBeNull(); + const labels = result!.items.map((item) => item.label); + expect(labels).toContain('.hidden/'); + expect(labels).toContain('.dotfile'); + }); }); - it('applyCompletion replaces an embedded slash argument token', () => { - const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + describe('bash-mode path applyCompletion', () => { + it('does not double the leading slash for a bare / path', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: '/Applications/', label: 'Applications/' }, + '/', + ); + expect(result.lines[0]).toBe('/Applications/'); + expect(result.cursorCol).toBe('/Applications/'.length); + }); - const result = provider.applyCompletion( - ['please run /goal st next'], - 0, - 'please run /goal st'.length, - { value: 'status', label: 'status' }, - 'st', - ); + it('replaces the path prefix after a command without a trailing space', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /App'], + 0, + 7, + { value: '/Applications/', label: 'Applications/' }, + '/App', + ); + expect(result.lines[0]).toBe('cd /Applications/'); + expect(result.cursorCol).toBe('cd /Applications/'.length); + }); - expect(result.lines[0]).toBe('please run /goal status next'); + it('keeps the cursor inside the closing quote for a spaced directory', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'bash'); + const result = provider.applyCompletion( + ['cd /tmp/My'], + 0, + 10, + { value: '"/tmp/My Dir/"', label: 'My Dir/' }, + '/tmp/My', + ); + expect(result.lines[0]).toBe('cd "/tmp/My Dir/"'); + // Cursor sits before the closing quote so the next `/` continues inside it. + expect(result.cursorCol).toBe('cd "/tmp/My Dir/'.length); + }); + + it('keeps pi-tui slash-command behaviour in prompt mode', () => { + const provider = new FileMentionProvider([], workDir, NO_FD, [], () => 'prompt'); + const result = provider.applyCompletion( + ['/'], + 0, + 1, + { value: 'help', label: 'help' }, + '/', + ); + // pi-tui's slash-command branch: beforePrefix + '/' + value + ' ' + expect(result.lines[0]).toBe('/help '); + }); }); - it('applyCompletion preserves punctuation after an embedded slash command token', () => { - const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD); + describe('bash-mode slash argument completion suppression', () => { + it('does not invoke slash argument completions for an absolute path in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [ + { value: '/should-not-appear/', label: 'should-not-appear/' }, + ]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + const text = '/add-dir/tmp/'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('should-not-appear/'); + }); - const result = provider.applyCompletion( - ['please run /he, next'], - 0, - 'please run /he'.length, - { value: 'help', label: 'help' }, - '/he', - ); + it('does not invoke slash argument completions for a trailing-space command in bash mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: 'list', label: 'list' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'bash', + ); + + // `/add-dir ` (trailing space) used to be re-triggered with force:false, + // which let pi-tui's own slash-command handling return subcommand + // completions. Bash mode now only ever triggers force:true path + // completion, so the argument completer must not run. + const text = '/add-dir '; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: true, + }); + + expect(getArgumentCompletions).not.toHaveBeenCalled(); + expect(result?.items.map((item) => item.label) ?? []).not.toContain('list'); + }); - expect(result.lines[0]).toBe('please run /help, next'); + it('keeps slash argument completion in prompt mode', async () => { + const getArgumentCompletions = vi.fn(() => [{ value: '/shared/', label: 'shared/' }]); + const provider = new FileMentionProvider( + [{ name: 'add-dir', description: 'Add directory', getArgumentCompletions }], + workDir, + NO_FD, + [], + () => 'prompt', + ); + + const text = '/add-dir /'; + const result = await provider.getSuggestions([text], 0, text.length, { + signal: ctrl(), + force: false, + }); + + expect(getArgumentCompletions).toHaveBeenCalled(); + expect(result?.items.map((item) => item.label)).toContain('shared/'); + }); }); - it('applyCompletion preserves punctuation after an embedded slash argument token', () => { - const provider = new FileMentionProvider([GOAL_COMMAND], workDir, NO_FD); + describe('inline skill completion', () => { + const REVIEW_COMMAND = { + name: 'skill:review', + aliases: [], + description: 'Review changes', + }; + const SECURITY_COMMAND = { + name: 'skill:security', + aliases: [], + description: 'Check security', + }; + const SKILL_NAMES = new Set(['skill:review', 'skill:security']); + + function skillProvider( + commands: ConstructorParameters<typeof FileMentionProvider>[0] = [ + REVIEW_COMMAND, + SECURITY_COMMAND, + HELP_COMMAND, + ], + ) { + return new FileMentionProvider( + commands, + workDir, + NO_FD, + [], + () => 'prompt', + SKILL_NAMES, + ); + } + + it('offers skill-only suggestions for a `/` after whitespace mid-input', async () => { + const provider = skillProvider(); + const line = 'hello /'; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/'); + expect(result!.items.map((item) => item.value).sort()).toEqual([ + 'skill:review', + 'skill:security', + ]); + }); - const result = provider.applyCompletion( - ['please run /goal st, next'], - 0, - 'please run /goal st'.length, - { value: 'status', label: 'status' }, - 'st', - ); + it('filters inline suggestions by the typed prefix', async () => { + const provider = skillProvider(); + const line = 'hello /rev'; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/rev'); + expect(result!.items.map((item) => item.value)).toEqual(['skill:review']); + }); + + it('offers the skill picker for a `/` at the start of a later line', async () => { + const provider = skillProvider(); + const result = await provider.getSuggestions(['first line', '/'], 1, 1, { signal: ctrl() }); - expect(result.lines[0]).toBe('please run /goal status, next'); + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value).sort()).toEqual([ + 'skill:review', + 'skill:security', + ]); + }); + + it('stays in skill-only mode while typing a token on a later line', async () => { + const provider = skillProvider(); + const result = await provider.getSuggestions(['first line', '/rev'], 1, 4, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/rev'); + expect(result!.items.map((item) => item.value)).toEqual(['skill:review']); + }); + + it('offers inline skills on an indented later line', async () => { + const provider = skillProvider(); + const result = await provider.getSuggestions(['first line', ' /skill:rev'], 1, 12, { + signal: ctrl(), + }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/skill:rev'); + expect(result!.items.map((item) => item.value)).toEqual(['skill:review']); + }); + + it('offers inline skills for an indented token on the first line', async () => { + const provider = skillProvider(); + const result = await provider.getSuggestions([' /skill:rev'], 0, 12, { signal: ctrl() }); + + expect(result).not.toBeNull(); + expect(result!.prefix).toBe('/skill:rev'); + expect(result!.items.map((item) => item.value)).toEqual(['skill:review']); + }); + + it('does not leak built-in commands onto later lines', async () => { + const provider = skillProvider(); + const result = await provider.getSuggestions(['first line', '/hel'], 1, 4, { + signal: ctrl(), + }); + + expect(result?.items.map((item) => item.value) ?? []).not.toContain('help'); + }); + + it('returns null for a prose slash when no skills are registered', async () => { + const provider = new FileMentionProvider([HELP_COMMAND], workDir, NO_FD, [], () => 'prompt'); + const line = 'hello /'; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + expect(result).toBeNull(); + }); + + it('keeps slash-command argument completions ahead of inline skills', async () => { + const provider = skillProvider([ADD_DIR_COMMAND, REVIEW_COMMAND]); + const line = '/add-dir /'; + const result = await provider.getSuggestions([line], 0, line.length, { + signal: ctrl(), + force: false, + }); + + expect(result).not.toBeNull(); + expect(result!.items.map((item) => item.value)).toEqual(['/tmp/shared/']); + }); + + it('applyCompletion preserves the slash and appends a trailing space', () => { + const provider = skillProvider(); + const line = 'hello /rev'; + const result = provider.applyCompletion( + [line], + 0, + line.length, + { value: 'skill:review', label: 'skill:review', data: { inlineSkill: true } }, + '/rev', + ); + + expect(result.lines[0]).toBe('hello /skill:review '); + expect(result.cursorCol).toBe('hello /skill:review '.length); + }); }); }); diff --git a/apps/pythinker-code/test/tui/components/editor/prompt-symbol.test.ts b/apps/pythinker-code/test/tui/components/editor/prompt-symbol.test.ts index f7591560..c7537551 100644 --- a/apps/pythinker-code/test/tui/components/editor/prompt-symbol.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/prompt-symbol.test.ts @@ -3,8 +3,8 @@ import { describe, it, expect } from 'vitest'; import { injectPromptSymbol } from '#/tui/components/editor/custom-editor'; describe('injectPromptSymbol', () => { - it('places the ❯ prompt at columns 2-3 without the legacy > marker', () => { - expect(injectPromptSymbol(' hello world')).toBe(' ❯ hello world'); + it('places a "> " prompt at columns 2-3 (col 0 = border, col 1 = single-space gap)', () => { + expect(injectPromptSymbol(' hello world')).toBe(' > hello world'); }); it('preserves overall visible width (prompt occupies padding slots)', () => { @@ -15,7 +15,7 @@ describe('injectPromptSymbol', () => { it('preserves trailing ANSI escapes (e.g. cursor inverse marker)', () => { const line = ' [7m [0m '; const out = injectPromptSymbol(line); - expect(out).toBe(' ❯ [7m [0m '); + expect(out).toBe(' > [7m [0m '); }); it('emits no SGR (terminal default foreground renders the symbol)', () => { diff --git a/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts b/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts index a45f7ee1..6d3145b1 100644 --- a/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/side-borders.test.ts @@ -76,4 +76,31 @@ describe('wrapWithSideBorders', () => { expect(out[1]).toBe('│ abc'); }); + it('overlays a label on the top border, replacing leading dashes', () => { + const top = '─'.repeat(30); + const out = wrapWithSideBorders([top, ' x ', top], id, { label: ' ! shell mode ' }); + expect(out[0]).toBe(`╭ ! shell mode ${'─'.repeat(14)}╮`); + // width is preserved: corner + label + dashes + corner == input width + expect(out[0]).toHaveLength(top.length); + // bottom border is untouched + expect(out[2]).toBe(`╰${'─'.repeat(28)}╯`); + }); + + it('does not inject the label when it is wider than the top border', () => { + const out = wrapWithSideBorders(['──────', ' x ', '──────'], id, { + label: ' ! shell mode ', + }); + // falls back to a plain border — label must not leak or overflow + expect(out[0]).toBe('╭────╮'); + expect(out[0]).not.toContain('shell mode'); + }); + + it('does not inject the label onto a scroll-indicator top border', () => { + const top = '─── ↑ 5 more ────'; + const out = wrapWithSideBorders([top, ' x ', '─── ↓ 3 more ────'], id, { + label: ' ! shell mode ', + }); + expect(out[0]).toContain('↑ 5 more'); + expect(out[0]).not.toContain('shell mode'); + }); }); diff --git a/apps/pythinker-code/test/tui/components/editor/slash-highlight.test.ts b/apps/pythinker-code/test/tui/components/editor/slash-highlight.test.ts index d368d307..02e885b3 100644 --- a/apps/pythinker-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/slash-highlight.test.ts @@ -1,10 +1,12 @@ import chalk from 'chalk'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { describe, it, expect, beforeAll } from 'vitest'; -import { highlightFirstSlashToken } from '#/tui/components/editor/custom-editor'; -import { currentTheme } from '#/tui/theme'; +import { highlightFirstSlashToken, highlightInlineSkillTokens } from '#/tui/components/editor/custom-editor'; beforeAll(() => { + // Vitest runs without a TTY so chalk auto-detects colour support as + // 0 (no colours). Force full colour so the highlighter actually + // emits the SGR escapes we're asserting on. chalk.level = 3; }); @@ -12,83 +14,119 @@ function strip(s: string): string { return s.replaceAll(/\u001B\[[0-9;]*m/g, ''); } -function cursorLine(text: string, cursorCol: number): string { - const before = text.slice(0, cursorCol); - const after = text.slice(cursorCol); - if (after.length === 0) return `${before}\u001B[7m \u001B[0m`; - return `${before}\u001B[7m${after[0]}\u001B[0m${after.slice(1)}`; +function expectHighlighted(out: string, token: string): void { + expect(out).toMatch(new RegExp(`\\u001B\\[[0-9;]*m${token}\\u001B\\[`)); } describe('highlightFirstSlashToken', () => { - it('colours /cmd when the cursor is inside a leading slash token', () => { - const input = cursorLine(' /help rest of input', ' /he'.length); - const out = highlightFirstSlashToken(input, 'primary'); + it('colours /cmd when line starts with a slash', () => { + const out = highlightFirstSlashToken(' /help rest of input', 'primary'); expect(out).toBeDefined(); - expect(strip(out!)).toBe(strip(input)); - expect(out!).toContain('/he'); - }); - - it('supports a text-strong white highlight for the active composer command', () => { - const input = cursorLine('/help', '/help'.length); - const out = highlightFirstSlashToken(input, 'textStrong'); - - expect(out).toContain(currentTheme.boldFg('textStrong', '/help')); - }); - - it('reapplies the text-strong highlight after an in-token cursor reset', () => { - const input = cursorLine('/help', 2); - const out = highlightFirstSlashToken(input, 'textStrong'); - - expect(out).toContain(`\u001B[0m${currentTheme.boldFg('textStrong', 'lp')}`); - }); - - it('colours slash commands in the middle of the prompt', () => { - const input = cursorLine('ship with /help later', 'ship with /he'.length); - const out = highlightFirstSlashToken(input, 'primary'); - expect(out).toBeDefined(); - expect(strip(out!)).toBe(strip(input)); - expect(out!).toContain('/he'); + // Visible text unchanged + expect(strip(out!)).toBe(' /help rest of input'); + // SGR escapes surround /help + expectHighlighted(out!, '/help'); }); it('colours next in /goal next', () => { - const input = cursorLine('/goal next Ship feature X', '/goal next'.length); - const out = highlightFirstSlashToken(input, 'primary'); + const out = highlightFirstSlashToken('/goal next Ship feature X', 'primary'); expect(out).toBeDefined(); - expect(strip(out!)).toBe(strip(input)); - expect(out!).toContain('/goal'); - expect(out!).toContain('next'); - expect(strip(out!)).toContain(' Ship feature X'); + expect(strip(out!)).toBe('/goal next Ship feature X'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expect(out!).toContain(' Ship feature X'); }); it('colours manage in /goal next manage', () => { - const input = cursorLine('/goal next manage', '/goal next manage'.length); - const out = highlightFirstSlashToken(input, 'primary'); + const out = highlightFirstSlashToken('/goal next manage', 'primary'); expect(out).toBeDefined(); - expect(strip(out!)).toBe(strip(input)); - expect(out!).toContain('/goal'); - expect(out!).toContain('next'); - expect(out!).toContain('manage'); + expect(strip(out!)).toBe('/goal next manage'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expectHighlighted(out!, 'manage'); }); it('returns undefined when the line has no slash', () => { - expect(highlightFirstSlashToken(cursorLine('hello world', 5), 'primary')).toBeUndefined(); + expect(highlightFirstSlashToken('hello world', 'primary')).toBeUndefined(); + }); + + it('returns undefined when slash is not at the leading position', () => { + expect(highlightFirstSlashToken(' hello /not-cmd', 'primary')).toBeUndefined(); }); it('returns undefined for path-like slash tokens', () => { - expect(highlightFirstSlashToken(cursorLine('/user/desktop/ foo', 5), 'primary')).toBeUndefined(); + expect(highlightFirstSlashToken('/user/desktop/ foo', 'primary')).toBeUndefined(); }); it('handles /token at end of line (no trailing whitespace)', () => { - const out = highlightFirstSlashToken(cursorLine('/exit', '/exit'.length), 'primary'); + const out = highlightFirstSlashToken('/exit', 'primary'); expect(out).toBeDefined(); - expect(strip(out!)).toBe('/exit '); + expect(strip(out!)).toBe('/exit'); }); it('passes through pre-existing ANSI (e.g. cursor inverse) in the tail', () => { - const line = cursorLine('/help x', '/he'.length).replace('x', '\u001B[36mx\u001B[0m'); + // Simulate pi-tui Editor inserting an inverse-video cursor marker + // somewhere after the slash token. + const line = '/help x\u001B[7m \u001B[0m'; const out = highlightFirstSlashToken(line, 'primary'); expect(out).toBeDefined(); + // Stripped visible content unchanged expect(strip(out!)).toBe(strip(line)); - expect(out!.includes('\u001B[36m')).toBe(true); + // Inverse cursor SGR is still present afterwards + expect(out!.includes('\u001B[7m')).toBe(true); + }); + + it('only paints the first token, not other slashes further along', () => { + const out = highlightFirstSlashToken('/a /b', 'primary'); + expect(out).toBeDefined(); + // Count the SGR opens — should be exactly one for /a. + const opens = (out!.match(/\u001B\[[0-9;]+m/g) ?? []).length; + expect(opens).toBeGreaterThanOrEqual(2); // chalk bold+fg open and reset(s) + // /b should remain plain — the substring " /b" exists verbatim. + expect(out!).toContain(' /b'); + }); +}); + +describe('highlightInlineSkillTokens', () => { + const SKILLS = new Set(['skill:review', 'skill:security', 'commit']); + + it('colours known skill tokens anywhere in the line', () => { + const out = highlightInlineSkillTokens('please /skill:review this', SKILLS, null, 'primary'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('please /skill:review this'); + expectHighlighted(out!, '/skill:review'); + }); + + it('colours multiple skill tokens in one line', () => { + const out = highlightInlineSkillTokens( + '/skill:review then /skill:security', + SKILLS, + null, + 'primary', + ); + expect(out).toBeDefined(); + expectHighlighted(out!, '/skill:review'); + expectHighlighted(out!, '/skill:security'); + }); + + it('skips the excluded leading command range', () => { + const visible = '/skill:review args'; + const out = highlightInlineSkillTokens( + visible, + SKILLS, + { start: 0, end: 13 }, + 'primary', + ); + expect(out).toBeUndefined(); + }); + + it('ignores unknown tokens and plain slashes', () => { + expect(highlightInlineSkillTokens('and /not-a-skill or /tmp', SKILLS, null, 'primary')).toBeUndefined(); + }); + + it('supports the skill: prefix fallback for bare names', () => { + const out = highlightInlineSkillTokens('please /review this', SKILLS, null, 'primary'); + expect(out).toBeDefined(); + expectHighlighted(out!, '/review'); }); }); diff --git a/apps/pythinker-code/test/tui/components/editor/wrapping-select-list.test.ts b/apps/pythinker-code/test/tui/components/editor/wrapping-select-list.test.ts index 198e8025..69ae1f38 100644 --- a/apps/pythinker-code/test/tui/components/editor/wrapping-select-list.test.ts +++ b/apps/pythinker-code/test/tui/components/editor/wrapping-select-list.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type SelectItem, type SelectListTheme } from '@earendil-works/pi-tui'; +import { visibleWidth, type SelectItem, type SelectListTheme } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { WrappingSelectList } from '#/tui/components/editor/wrapping-select-list'; @@ -40,7 +40,7 @@ describe('WrappingSelectList', () => { ]).render(80); expect(lines).toEqual([ - '❯ [S]goal[D] First command', + '[S]→ goal First command', ' init[D] Second command', ]); }); @@ -57,7 +57,7 @@ describe('WrappingSelectList', () => { ]).render(80); expect(lines).toEqual([ - '❯ [S]goal[D] First command', + '[S]→ goal First command', ' init[D] lorem ipsum dolor sit amet consectetur adipiscing elit sed do', `[D]${DESCRIPTION_INDENT}eiusmod tempor incididunt`, ]); @@ -76,15 +76,15 @@ describe('WrappingSelectList', () => { expect(lines[2]!.endsWith('…')).toBe(true); }); - it('keeps the selected command strong while muting its description lines', () => { + it('paints every line of the selected item with the selected style', () => { const description = 'lorem ipsum dolor sit amet consectetur adipiscing elit '.repeat(4).trim(); const lines = makeList([ { value: 'goal', label: 'goal', description }, { value: 'init', label: 'init', description: 'Second command' }, ]).render(80); - expect(lines[0]).toMatch(/^❯ \[S\]goal\[D\] {8}lorem ipsum/); - expect(lines[1]).toMatch(new RegExp(`^\\[D\\]${DESCRIPTION_INDENT}`)); + expect(lines[0]).toMatch(/^\[S\]→ goal {8}lorem ipsum/); + expect(lines[1]).toMatch(new RegExp(`^\\[S\\]${DESCRIPTION_INDENT}`)); expect(lines[2]).toBe(' init[D] Second command'); }); @@ -94,7 +94,7 @@ describe('WrappingSelectList', () => { { value: 'init', label: 'init', description: 'Second command' }, ]).render(40); - expect(lines).toEqual(['❯ [S]goal', ' init']); + expect(lines).toEqual(['[S]→ goal', ' init']); }); it('keeps the scroll indicator when items overflow maxVisible', () => { @@ -126,7 +126,7 @@ describe('WrappingSelectList', () => { it('never emits a line wider than the requested width, including CJK text', () => { const list = new WrappingSelectList( [ - { value: 'lark', label: 'skill:lark-calendar', description: 'Manage the Lark calendar skill description'.repeat(8) }, + { value: 'lark', label: 'skill:lark-calendar', description: '\u7BA1\u7406\u98DE\u4E66\u65E5\u5386\u7684\u6280\u80FD\u63CF\u8FF0'.repeat(8) }, { value: 'init', label: 'init', description: 'word '.repeat(60).trim() }, ], 5, diff --git a/apps/pythinker-code/test/tui/components/media/code-highlight.test.ts b/apps/pythinker-code/test/tui/components/media/code-highlight.test.ts index 79cd8dda..b8b1b50a 100644 --- a/apps/pythinker-code/test/tui/components/media/code-highlight.test.ts +++ b/apps/pythinker-code/test/tui/components/media/code-highlight.test.ts @@ -36,26 +36,25 @@ describe('code-highlight', () => { } }); - it('emits no red SGR for strings, regexps, and diff deletions', () => { + it('emits no red SGR for strings, regexps and diff deletions', () => { + // cli-highlight styles through its own chalk v4 instance; force colors on + // so the assertions below observe real SGR sequences. const req = createRequire(import.meta.url); const chalkV4 = req( req.resolve('chalk', { paths: [dirname(req.resolve('cli-highlight'))] }), ) as { level: number }; - const previousLevel = chalkV4.level; + const prevLevel = chalkV4.level; chalkV4.level = 1; try { - const javascript = highlightLines( - "const string = 'value';\nconst regexp = /value+/g;", - 'javascript', - ).join('\n'); - expect(javascript).not.toContain(`${ESC}[31m`); - expect(javascript).toContain(`${ESC}[34m`); + const js = highlightLines("const s = 'str';\nconst r = /re+/g;", 'javascript').join('\n'); + expect(js).not.toContain(`${ESC}[31m`); + expect(js).toContain(`${ESC}[34m`); // keywords stay highlighted const diff = highlightLines('+ added\n- removed', 'diff').join('\n'); expect(diff).not.toContain(`${ESC}[31m`); - expect(diff).toContain(`${ESC}[32m`); + expect(diff).toContain(`${ESC}[32m`); // additions stay green } finally { - chalkV4.level = previousLevel; + chalkV4.level = prevLevel; } }); }); diff --git a/apps/pythinker-code/test/tui/components/media/diff-preview.test.ts b/apps/pythinker-code/test/tui/components/media/diff-preview.test.ts index 8e7214e1..d355bb7e 100644 --- a/apps/pythinker-code/test/tui/components/media/diff-preview.test.ts +++ b/apps/pythinker-code/test/tui/components/media/diff-preview.test.ts @@ -155,6 +155,22 @@ describe('renderDiffLinesClustered', () => { expect(text).toContain('ctrl+o to expand'); }); + it('respects oldStart and newStart for line numbers', () => { + const text = stripAnsi( + renderDiffLinesClustered('A\nB\nC', 'A\nX\nC', 'f.ts', { + contextLines: 1, + oldStart: 10, + newStart: 20, + }).join('\n'), + ); + // Context lines keep the new (post-edit) line numbers from newStart; + // deleted lines use oldStart; added lines use newStart. + expect(text).toContain(' 20 A'); + expect(text).toContain(' 11 - B'); + expect(text).toContain(' 21 + X'); + expect(text).toContain(' 22 C'); + }); + it('truncates at cluster boundary and appends the ctrl+o footer when maxLines is set', () => { const oldLines: string[] = []; for (let i = 1; i <= 50; i++) oldLines.push(`L${String(i)}`); diff --git a/apps/pythinker-code/test/tui/components/media/image-thumbnail.test.ts b/apps/pythinker-code/test/tui/components/media/image-thumbnail.test.ts index fb070f6c..b7eee53f 100644 --- a/apps/pythinker-code/test/tui/components/media/image-thumbnail.test.ts +++ b/apps/pythinker-code/test/tui/components/media/image-thumbnail.test.ts @@ -1,19 +1,9 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@pymodel/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; -const getCapabilitiesMock = vi.hoisted(() => vi.fn()); - -vi.mock('@earendil-works/pi-tui', async () => { - const actual = (await vi.importActual('@earendil-works/pi-tui')) as Record<string, unknown>; - return { - ...actual, - getCapabilities: getCapabilitiesMock, - }; -}); - const image: ImageAttachment = { id: 1, kind: 'image', @@ -26,11 +16,13 @@ const image: ImageAttachment = { describe('ImageThumbnail', () => { afterEach(() => { + resetCapabilitiesCache(); vi.restoreAllMocks(); }); it('keeps rendered output within narrow widths', () => { - getCapabilitiesMock.mockReturnValue({ images: undefined } as never); + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + const component = new ImageThumbnail(image); for (const width of [39, 20, 3, 1]) { @@ -41,7 +33,8 @@ describe('ImageThumbnail', () => { }); it('does not rebuild inline image children on repeated same-width renders', () => { - getCapabilitiesMock.mockReturnValue({ images: 'kitty' } as never); + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); + const bufferFrom = vi.spyOn(Buffer, 'from'); const component = new ImageThumbnail(image); bufferFrom.mockClear(); diff --git a/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts b/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts new file mode 100644 index 00000000..37874d92 --- /dev/null +++ b/apps/pythinker-code/test/tui/components/messages/agent-dynamic-workflow-progress.test.ts @@ -0,0 +1,1003 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { visibleWidth } from '@pymodel/pi-tui'; +import chalk from 'chalk'; + +import { + AgentDynamicWorkflowProgressComponent, + type AgentDynamicWorkflowProgressOptions, + agentDynamicWorkflowDescriptionFromArgs, + agentDynamicWorkflowGridHeightForTerminalRows, + agentDynamicWorkflowItemsFromArgs, + agentDynamicWorkflowPartialItemsCountFromArguments, + agentDynamicWorkflowPartialItemsFromArguments, + calculateAgentDynamicWorkflowGridLayout, +} from '#/tui/components/messages/agent-dynamic-workflow-progress'; +import { AgentDynamicWorkflowProgressEstimator } from '#/tui/components/messages/agent-dynamic-workflow-progress-estimator'; +import { currentTheme, darkColors, lightColors } from '#/tui/theme'; + +const DEFAULT_DESCRIPTION = 'Review changed files'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function createComponent( + options: Partial<AgentDynamicWorkflowProgressOptions> = {}, +): AgentDynamicWorkflowProgressComponent { + return new AgentDynamicWorkflowProgressComponent({ + description: options.description ?? DEFAULT_DESCRIPTION, + requestRender: options.requestRender, + availableGridHeight: options.availableGridHeight, + }); +} + +function renderText(component: AgentDynamicWorkflowProgressComponent, width = 100): string { + return strip(component.render(width).join('\n')); +} + +function renderLines(component: AgentDynamicWorkflowProgressComponent, width = 100): string[] { + return renderText(component, width).split('\n'); +} + +function registerSubagents(component: AgentDynamicWorkflowProgressComponent, count: number): void { + for (let index = 1; index <= count; index += 1) { + component.registerSubagent({ + agentId: `agent-${String(index)}`, + description: `${DEFAULT_DESCRIPTION} #${String(index)} (coder)`, + }); + } +} + +function startSubagents(component: AgentDynamicWorkflowProgressComponent, count: number): void { + component.markInputComplete(); + for (let index = 1; index <= count; index += 1) { + component.markStarted(`agent-${String(index)}`); + } +} + +afterEach(() => { + vi.useRealTimers(); + currentTheme.setPalette(darkColors); +}); + +describe('calculateAgentDynamicWorkflowGridLayout', () => { + it('uses a text grid when labels fit within the available height', () => { + const layout = calculateAgentDynamicWorkflowGridLayout({ + width: 100, + height: 3, + count: 9, + }); + + expect(layout).toMatchObject({ + renderText: true, + columns: 3, + rows: 3, + }); + expect(layout.barCells).toBeGreaterThanOrEqual(6); + expect(layout.cellWidth).toBeGreaterThanOrEqual(22); + }); + + it('adds text columns before falling back to compact bars', () => { + const textLayout = calculateAgentDynamicWorkflowGridLayout({ + width: 120, + height: 4, + count: 20, + }); + const compactLayout = calculateAgentDynamicWorkflowGridLayout({ + width: 117, + height: 4, + count: 20, + }); + + expect(textLayout).toMatchObject({ + renderText: true, + columns: 5, + rows: 4, + }); + expect(compactLayout).toMatchObject({ + renderText: false, + columns: 5, + rows: 4, + }); + expect(compactLayout.barCells).toBeGreaterThan(textLayout.barCells); + }); + + it('uses compact bars to satisfy tight height budgets', () => { + const layout = calculateAgentDynamicWorkflowGridLayout({ + width: 100, + height: 5, + count: 30, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 6, + rows: 5, + }); + expect(layout.barCells).toBeGreaterThan(0); + }); + + it('keeps compact rows within the available height even when bars are narrow', () => { + const layout = calculateAgentDynamicWorkflowGridLayout({ + width: 100, + height: 4, + count: 40, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 10, + rows: 4, + }); + expect(layout.barCells).toBe(1); + }); + + it('keeps at least one bar cell when no rows are available', () => { + const layout = calculateAgentDynamicWorkflowGridLayout({ + width: 20, + height: 0, + count: 4, + }); + + expect(layout).toMatchObject({ + renderText: false, + columns: 2, + rows: 2, + }); + expect(layout.barCells).toBeGreaterThan(0); + }); + + it('derives the grid height left inside the AgentDynamicWorkflow block', () => { + expect(agentDynamicWorkflowGridHeightForTerminalRows(undefined)).toBeUndefined(); + expect(agentDynamicWorkflowGridHeightForTerminalRows(10)).toBe(4); + expect(agentDynamicWorkflowGridHeightForTerminalRows(20, 5)).toBe(9); + expect(agentDynamicWorkflowGridHeightForTerminalRows(4)).toBe(0); + }); +}); + +describe('AgentDynamicWorkflowProgressComponent', () => { + it('renders an orchestrating panel before subagents spawn', () => { + const component = createComponent(); + + const output = renderText(component); + + expect(output).toContain('Agent DynamicWorkflow'); + expect(output).toContain('Review changed files'); + expect(output).toContain('Orchestrating...'); + expect(output).not.toContain('01'); + }); + + it('shows the bound model display name in the header', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + const lines = renderLines(component); + const headerLine = lines.find((line) => line.includes('Agent DynamicWorkflow')); + + expect(headerLine).toBeDefined(); + expect(headerLine).toContain('Review changed files ─ kimi-k2-thinking'); + }); + + it('keeps the first reported model when later status updates differ', () => { + const component = createComponent(); + + component.setModelDisplay('kimi-k2-thinking'); + component.setModelDisplay('other-model'); + component.setModelDisplay(''); + + const output = renderText(component); + + expect(output).toContain('kimi-k2-thinking'); + expect(output).not.toContain('other-model'); + }); + + it('repaints from the active palette when the theme changes', () => { + const previousLevel = chalk.level; + chalk.level = 3; // force truecolor so palette differences surface as ANSI + try { + const component = createComponent(); + const titleOf = (): string => { + const line = component.render(100).find((l) => strip(l).includes('Agent DynamicWorkflow')); + if (line === undefined) throw new Error('title line not found'); + return line; + }; + const before = titleOf(); + + currentTheme.setPalette(lightColors); + const after = titleOf(); + + // Same visible text, different ANSI colours (reads currentTheme live). + expect(strip(after)).toBe(strip(before)); + expect(after).not.toBe(before); + } finally { + chalk.level = previousLevel; + } + }); + + it('renders blank padding around the block without a bottom divider', () => { + const component = createComponent(); + + registerSubagents(component, 1); + const lines = renderLines(component); + + expect(lines[0]).toBe(' '); + expect(lines[1]).toContain('Agent DynamicWorkflow'); + expect(lines.at(-1)).toBe(' '); + expect(lines.at(-2)).not.toMatch(/^─+$/); + }); + + it('reserves one blank column on the right edge', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + + const rendered = component.render(80).map(strip); + const gridLine = rendered.find((line) => line.includes('001 [')); + + expect(rendered.every((line) => visibleWidth(line) <= 79)).toBe(true); + expect(rendered.some((line) => line.includes('Agent DynamicWorkflow'))).toBe(true); + expect(gridLine).toBeDefined(); + expect(visibleWidth(gridLine ?? '')).toBeLessThanOrEqual(79); + }); + + it('renders spawned subagents as queued rows without empty progress bars', () => { + const component = createComponent(); + + registerSubagents(component, 2); + + const output = renderText(component); + + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('agents=2'); + }); + + it('fits three queued columns with the narrower gap and minimum cell width', () => { + const component = createComponent(); + + registerSubagents(component, 3); + + const lines = renderLines(component, 97); + const queuedLine = lines.find((line) => line.includes('001 Queued...')); + + expect(queuedLine).toBeDefined(); + expect(queuedLine).toContain('002 Queued...'); + expect(queuedLine).toContain('003 Queued...'); + }); + + it('omits subagent text when the compact grid is needed to fit available height', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + registerSubagents(component, 30); + startSubagents(component, 30); + + const lines = renderLines(component, 102); + const gridLines = lines.filter((line) => /\b\d{3} \[/.test(line)); + + expect(gridLines).toHaveLength(5); + expect(gridLines[0]).toContain('001 ['); + expect(gridLines[0]).toContain('006 ['); + expect(gridLines.join('\n')).not.toContain('Running'); + }); + + it('keeps streamed pending items as text even when compact layout is selected', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + component.updateArgs({ + items: Array.from({ length: 30 }, (_item, index) => `f${String(index + 1)}.ts`), + }); + + const output = renderText(component, 102); + + expect(output).toContain('001 f1.ts'); + expect(output).toContain('006 f6.ts'); + expect(output).not.toContain('001 ['); + }); + + it('prefixes a cancelled running subagent label with the aborted mark without changing the text', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.appendModelDelta({ agentId: 'agent-1', delta: 'Inspecting src/a.ts' }); + component.markCancelled('agent-1'); + + const output = renderText(component); + const cellLine = output.split('\n').find((line) => line.includes('001 [')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Inspecting src/a.ts'); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('shows a cancelled label without a progress bar for queued subagents', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markInputComplete(); + component.markCancelled('agent-1'); + + const output = renderText(component); + const cellLine = output.split('\n').find((line) => line.includes('001 ')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Cancelled.'); + expect(cellLine).not.toContain('['); + expect(cellLine).not.toContain('⊘ Aborted.'); + }); + + it('renders terminal marks against compact bars when subagent text is hidden', () => { + const component = createComponent({ + availableGridHeight: () => 5, + }); + + registerSubagents(component, 30); + startSubagents(component, 30); + component.markCompleted('agent-1'); + component.markFailed('agent-2', 'Agent timed out'); + component.markCancelled('agent-3'); + + const lines = renderLines(component, 102); + const gridLine = lines.find((line) => line.includes('001 [')); + + expect(gridLine).toBeDefined(); + expect(gridLine).toMatch(/001 \[[^\]]+\]✓ +002 \[[^\]]+\]✗ +003 \[[^\]]+\]⊘/); + expect(gridLine).not.toContain('Completed'); + expect(gridLine).not.toContain('Failed'); + expect(gridLine).not.toContain('Aborted'); + }); + + it('advances from queued when a subagent tool call starts and marks terminal states', () => { + const component = createComponent(); + + registerSubagents(component, 2); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + + let output = renderText(component); + expect(output).toContain('001 ['); + expect(output).toContain('Running'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('002 ['); + + component.markCompleted('agent-1'); + component.markFailed('agent-2'); + + output = renderText(component); + expect(output).toContain('001 ['); + expect(output).toContain('✓'); + expect(output).toContain('Completed.'); + expect(output).toContain('002 ['); + expect(output).toContain('Failed'); + }); + + it('renders completed subagent output with a success mark', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markCompleted('agent-1', 'Reviewed imports and found no regressions'); + + const output = renderText(component); + + expect(output).toContain('✓ Reviewed imports and found no regressions'); + expect(output).toContain('Completed.'); + }); + + it('renders failure details from live subagent failures', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markFailed('agent-1', 'Provider request failed\nRetry budget exhausted'); + + const output = renderText(component); + + expect(output).toContain('✗ Provider request failed Retry budget exhausted'); + expect(output).not.toContain('Failed:'); + }); + + it('renders suspended subagents as rate limited and clears the state when they start again', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + + let output = renderText(component); + expect(output).toContain('Rate limited...'); + expect(output).not.toContain('Queued...'); + expect(output).not.toContain('Provider rate limit'); + expect(output).not.toContain('Failed'); + + component.markStarted('agent-1'); + + output = renderText(component); + expect(output).toContain('Running'); + expect(output).not.toContain('Rate limited...'); + }); + + it('renders rate-limited subagents as cancelled when cancelled', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markStarted('agent-1'); + component.markSuspended({ + agentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + }); + component.markCancelled('agent-1'); + + const cellLine = renderLines(component) + .find((line) => line.includes('001 [')); + + expect(cellLine).toBeDefined(); + expect(cellLine).toContain('⊘ Cancelled.'); + expect(cellLine).not.toContain('Rate limited...'); + }); + + it('renders failure details from AgentDynamicWorkflow result output', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_dynamic_workflow_result>', + '<summary>failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n')); + + const output = renderText(component); + + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).not.toContain('Failed:'); + }); + + it('applies no-index AgentDynamicWorkflow result statuses by tag order', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const applied = component.applyResult([ + '<agent_dynamic_workflow_result>', + '<summary>failed: 1, aborted: 1</summary>', + '<subagent agent_id="agent-1" item="src/a.ts" outcome="failed">' + + 'Agent timed out after 30s.</subagent>', + '<subagent agent_id="agent-2" item="src/b.ts" outcome="aborted">' + + 'User interrupted.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n')); + + const output = renderText(component, 120); + + expect(applied).toBe(true); + expect(output).toContain('✗ Agent timed out after 30s.'); + expect(output).toContain('⊘ Cancelled.'); + expect(output).not.toContain('002 ['); + expect(output).not.toContain('Completed.'); + }); + + it('strips nested AgentDynamicWorkflow prefixes from failure details', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_dynamic_workflow_result>', + '<summary>failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="failed">agent_dynamic_workflow: failed', + 'description: Nested review', + 'items: 1', + 'completed: 0', + 'failed: 1', + '', + '[agent 1]', + 'status: failed', + '', + 'subagent error: [provider.rate_limit] 429 request reached user+model max RPM.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n')); + + const output = renderText(component, 120); + + expect(output).toContain('✗ [provider.rate_limit] 429 request reached user+model max RPM.'); + expect(output).not.toContain('agent_dynamic_workflow:'); + expect(output).not.toContain('Failed:'); + }); + + it('renders completed summaries from AgentDynamicWorkflow result output', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts'], + }); + component.applyResult([ + '<agent_dynamic_workflow_result>', + '<summary>completed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Reviewed src/a.ts and confirmed imports are stable.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n')); + + const output = renderText(component); + + expect(output).toContain('✓ Reviewed src/a.ts and confirmed imports are stable.'); + expect(output).toContain('Completed.'); + }); + + it('shows completed total status when only some subagents fail', () => { + const component = createComponent(); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + component.applyResult([ + '<agent_dynamic_workflow_result>', + '<summary>completed: 1, failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Reviewed src/a.ts and confirmed imports are stable.</subagent>', + '<subagent index="2" agent_id="agent-2" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n')); + + const output = renderText(component, 120); + const totalStatusLine = output.split('\n').find((line) => line.includes('Completed.')); + + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(output).toContain('✓ Reviewed src/a.ts'); + expect(output).toContain('✗ Agent timed out after 30s.'); + }); + + it('uses the latest assistant line as completed output when no summary is available', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts\nImports look stable', + }); + component.markCompleted('agent-1'); + + const output = renderText(component); + + expect(output).toContain('✓ Imports look stable'); + expect(output).toContain('Completed.'); + }); + + it('shows latest assistant text after the progress bar with ellipsis truncation', () => { + const component = createComponent(); + + registerSubagents(component, 1); + component.markInputComplete(); + component.recordToolCall({ agentId: 'agent-1', toolCallId: 'call-read' }); + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + }); + + const output = renderText(component, 44); + expect(output).toContain('001 ['); + expect(output).toContain('Reviewing'); + expect(output).toContain('…'); + }); + + it('uses natural status label width for prompting text', () => { + const prompting = createComponent({ + description: '', + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review the changed TypeScript files carefully', + }); + + const promptLine = renderLines(prompting, 80) + .find((line) => line.includes('Prompting...')); + expect(promptLine).toBeDefined(); + + const working = createComponent(); + registerSubagents(working, 1); + startSubagents(working, 1); + + const workingLine = renderLines(working, 80) + .find((line) => line.includes('Working...')); + expect(workingLine).toBeDefined(); + + const promptTextIndex = promptLine?.indexOf('Review the changed') ?? -1; + const progressBarIndex = workingLine?.indexOf('━') ?? -1; + expect(promptTextIndex).toBeGreaterThan(0); + expect(progressBarIndex).toBeGreaterThan(0); + expect(promptTextIndex).toBe(visibleWidth(' Prompting... ')); + expect(progressBarIndex).toBe(visibleWidth(' Working... ')); + }); + + it('renders the activity spinner before the total status line', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.setActivitySpinnerText(() => '⣷'); + + const statusLine = renderLines(component, 80) + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' ⣷ Working...')).toBe(true); + }); + + it('keeps a two-cell placeholder after the AgentDynamicWorkflow tool call ends', () => { + const component = createComponent(); + + registerSubagents(component, 1); + startSubagents(component, 1); + component.setActivitySpinnerText(() => '⣷'); + component.markToolCallEnded(); + component.setActivitySpinnerText(() => '⣯'); + + const statusLine = renderLines(component, 80) + .find((line) => line.includes('Working...')); + + expect(statusLine).toBeDefined(); + expect(statusLine?.startsWith(' Working...')).toBe(true); + expect(statusLine).not.toContain('⣷'); + expect(statusLine).not.toContain('⣯'); + }); + + it('renders terminal total status lines after the tool call ends', () => { + const completed = createComponent(); + registerSubagents(completed, 1); + completed.markInputComplete(); + completed.markCompleted('agent-1', 'Imports are stable'); + completed.markToolCallEnded(); + + expect(renderLines(completed, 80).some((line) => line.startsWith(' ✓ Completed.'))).toBe(true); + + const failed = createComponent(); + registerSubagents(failed, 1); + failed.markInputComplete(); + failed.markFailed('agent-1', 'Agent timed out'); + failed.markToolCallEnded(); + + expect(renderLines(failed, 80).some((line) => line.startsWith(' ✗ Failed.'))).toBe(true); + + const aborted = createComponent(); + registerSubagents(aborted, 1); + aborted.markInputComplete(); + aborted.markStarted('agent-1'); + aborted.markActiveCancelled(); + aborted.markToolCallEnded(); + + const abortedOutput = renderText(aborted, 80); + expect(abortedOutput).toContain('⊘ Aborted.'); + expect(abortedOutput).not.toContain('Cancelled.'); + }); + + it('reserves one trailing cell for prompting streaming text', () => { + const prompting = createComponent({ + description: '', + }); + prompting.updateArgs({}, { + streamingArguments: '{"prompt_template":"Review every changed TypeScript file and summarize regressions carefully before reporting', + }); + + const promptLine = renderLines(prompting, 50) + .find((line) => line.includes('Prompting...')); + + expect(promptLine).toBeDefined(); + expect(visibleWidth(promptLine ?? '')).toBeLessThan(50); + }); + + it('renders boosted fractional progress ticks without leaking undefined cells', () => { + vi.useFakeTimers(); + const component = createComponent(); + + vi.setSystemTime(0); + registerSubagents(component, 1); + component.markStarted('agent-1'); + for (let index = 0; index < 10; index += 1) { + vi.setSystemTime(1_000 + index * 1_000); + component.recordToolCall({ agentId: 'agent-1', toolCallId: `done-${index}` }); + } + vi.setSystemTime(40_000); + component.markCompleted('agent-1'); + + component.registerSubagent({ + agentId: 'agent-2', + description: `${DEFAULT_DESCRIPTION} #2 (coder)`, + }); + component.markStarted('agent-2'); + for (let index = 0; index < 3; index += 1) { + vi.setSystemTime(45_000 + index * 5_000); + component.recordToolCall({ agentId: 'agent-2', toolCallId: `running-${index}` }); + } + + vi.setSystemTime(60_000); + component.render(100); + vi.setSystemTime(61_000); + const output = renderText(component); + + expect(output).toContain('002 ['); + expect(output).not.toContain('undefined'); + }); + + it('creates pending rows from streamed args items', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({ + description: 'Review changed files', + items: ['src/a.ts', 'src/b.ts'], + }); + const output = renderText(component); + + expect(output).toContain('Agent DynamicWorkflow'); + expect(output).toContain('Review changed files'); + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b.ts'); + }); + + it('creates pending rows from resume_agent_ids before streamed args items', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({ + description: 'Review changed files', + resume_agent_ids: { + 'agent-old-1': 'continue', + 'agent-old-2': 'continue', + }, + items: ['src/a.ts'], + }); + const output = renderText(component); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).toContain('003 src/a.ts'); + expect(output).not.toContain('001 ['); + }); + + it('counts partial items before each string is complete', () => { + expect( + agentDynamicWorkflowPartialItemsCountFromArguments('{"items":["src/a.ts","src/b'), + ).toBe(2); + expect( + agentDynamicWorkflowPartialItemsCountFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toBe(3); + expect( + agentDynamicWorkflowPartialItemsFromArguments('{"items":["src/a.ts","src/\\"b.ts","src/c'), + ).toEqual(['src/a.ts', 'src/"b.ts', 'src/c']); + }); + + it('creates pending rows from partial streaming arguments', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({}, { + streamingArguments: '{"description":"Review changed files","items":["src/a.ts","src/b', + }); + const output = renderText(component); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 src/b'); + }); + + it('creates pending rows from partial streaming resume_agent_ids', () => { + const component = createComponent({ + description: '', + }); + + component.updateArgs({}, { + streamingArguments: + '{"description":"Resume reviews","resume_agent_ids":{"agent-old-1":"continue","agent-old-2":"cont', + }); + const output = renderText(component); + + expect(output).toContain('001 (resumed)'); + expect(output).toContain('002 (resumed)'); + expect(output).not.toContain('003'); + }); + + it('adds subagent rows incrementally as spawn events arrive', () => { + const component = createComponent(); + + registerSubagents(component, 1); + let output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002'); + + component.registerSubagent({ + agentId: 'agent-2', + description: `${DEFAULT_DESCRIPTION} #2 (coder)`, + }); + output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + expect(output).not.toContain('002 ['); + + component.markInputComplete(); + output = renderText(component); + expect(output).toContain('001 Queued...'); + expect(output).toContain('002 Queued...'); + expect(output).not.toContain('001 ['); + }); + + it('maps subagents by structured dynamic_workflow indexes when descriptions include issue references', () => { + const component = createComponent({ + description: 'Fix #123', + }); + + component.updateArgs({ + description: 'Fix #123', + items: ['src/a.ts', 'src/b.ts'], + }); + component.registerSubagent({ + agentId: 'agent-2', + description: 'Fix #123 #2 (coder)', + dynamicWorkflowIndex: 2, + }); + component.markStarted('agent-2'); + + const output = renderText(component); + + expect(output).toContain('001 src/a.ts'); + expect(output).toContain('002 ['); + expect(output).not.toContain('123 ['); + }); + + it('extracts description and item list from AgentDynamicWorkflow args', () => { + const args = { + description: 'Review changed files', + items: ['src/a.ts', 123], + }; + + expect(agentDynamicWorkflowDescriptionFromArgs(args)).toBe('Review changed files'); + expect(agentDynamicWorkflowItemsFromArgs(args)).toEqual(['src/a.ts', '123']); + }); +}); + +describe('AgentDynamicWorkflowProgressEstimator', () => { + it('counts a started subagent as one progress tick before tool calls arrive', () => { + const estimator = new AgentDynamicWorkflowProgressEstimator(); + + estimator.markStarted('001', 0); + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 1_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + }); + + it('keeps raw tool-call ticks without completed samples and deduplicates calls', () => { + const estimator = new AgentDynamicWorkflowProgressEstimator(); + + estimator.markStarted('001', 0); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 1_000 }), + ).toEqual({ accepted: true, rawTicks: 2 }); + expect( + estimator.recordToolCall({ memberKey: '001', toolCallId: 'read', nowMs: 2_000 }), + ).toEqual({ accepted: false, rawTicks: 2 }); + + const estimate = estimator.estimate({ + memberKey: '001', + phase: 'running', + capacityTicks: 56, + nowMs: 3_000, + }); + + expect(estimate.rawTicks).toBe(2); + expect(estimate.displayTicks).toBe(2); + expect(estimate.estimatedTotalToolCalls).toBeUndefined(); + expect(estimate.boosted).toBe(false); + }); + + it('does not catch up progress using queued wait before start', () => { + const estimator = new AgentDynamicWorkflowProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.ensureMember('002', 0); + estimator.estimate({ + memberKey: '002', + phase: 'queued', + capacityTicks: 56, + nowMs: 0, + }); + estimator.markStarted('002', 60_000); + + const estimate = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 60_000, + }); + + expect(estimate.rawTicks).toBe(1); + expect(estimate.displayTicks).toBe(1); + expect(estimate.targetTicks).toBeGreaterThan(1); + expect(estimate.boosted).toBe(false); + }); + + it('smoothly catches up toward completed-agent estimates without jumping to them', () => { + const estimator = new AgentDynamicWorkflowProgressEstimator({ + catchupTimeMs: 1_000, + maxCatchupTicksPerSecond: 100, + }); + + estimator.markStarted('001', 0); + for (let index = 0; index < 10; index += 1) { + estimator.recordToolCall({ + memberKey: '001', + toolCallId: `done-${index}`, + nowMs: 1_000 + index * 1_000, + }); + } + estimator.markCompleted('001', 40_000); + + estimator.markStarted('002', 0); + for (let index = 0; index < 3; index += 1) { + estimator.recordToolCall({ + memberKey: '002', + toolCallId: `running-${index}`, + nowMs: 5_000 + index * 5_000, + }); + } + + const first = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 20_000, + }); + + expect(first.rawTicks).toBe(4); + expect(first.displayTicks).toBe(4); + expect(first.estimatedTotalToolCalls).toBeGreaterThan(4); + expect(first.targetTicks).toBeGreaterThan(4); + expect(estimator.hasPendingCatchup()).toBe(true); + + const second = estimator.estimate({ + memberKey: '002', + phase: 'running', + capacityTicks: 56, + nowMs: 21_000, + }); + + expect(second.displayTicks).toBeGreaterThan(4); + expect(second.displayTicks).toBeLessThan(second.targetTicks ?? 0); + expect(second.boosted).toBe(true); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/messages/agent-group.test.ts b/apps/pythinker-code/test/tui/components/messages/agent-group.test.ts index 5652405a..98f4773e 100644 --- a/apps/pythinker-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/agent-group.test.ts @@ -1,11 +1,8 @@ -import type { TUI } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import type { TUI } from '@pymodel/pi-tui'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; -import { formatThinkingSpinnerLabel } from '#/tui/constant/rendering'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; -import { darkColors } from '#/tui/theme/colors'; const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -94,60 +91,71 @@ describe('AgentGroupComponent', () => { waiting.dispose(); }); - it('uses verb-spinner fallback for running agents without recent activity', () => { + it('shows the bound model in the row stats once reported', () => { vi.useFakeTimers(); vi.setSystemTime(0); const ui = stubTui(); const group = new AgentGroupComponent(ui); const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); - const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); - startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); - group.attach('call_agent_2', waiting); + expect(renderText(group)).toContain('explore · inspect project · 0 tools'); - const output = renderText(group); - expect(output).toContain(formatThinkingSpinnerLabel()); - expect(output).toContain('Waiting to start…'); - expect(output).not.toContain('Initializing…'); + running.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + // Non-phase updates are throttled; flush the pending refresh. + vi.runOnlyPendingTimers(); + expect(renderText(group)).toContain('explore · inspect project · Kimi K2.5 · 0 tools'); group.dispose(); running.dispose(); - waiting.dispose(); }); - it('uses textStrong names and a one-row Markdown activity tail', () => { + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + a.markBackgrounded(); + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + b.markBackgrounded(); + expect(renderText(group)).not.toContain('Press Ctrl+B to run in background'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); + + it('uses still-working fallback for running agents without recent activity', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const previousLevel = chalk.level; - chalk.level = 3; const ui = stubTui(); const group = new AgentGroupComponent(ui); const running = createAgent('call_agent_1', 'inspect project', 'explore', ui); const waiting = createAgent('call_agent_2', 'write tests', 'coder', ui); - try { - startAgent(running, 'call_agent_1', 'explore'); - running.appendSubagentText( - 'activity prefix that wraps away before the **final check**', - 'text', - ); - group.attach('call_agent_1', running); - group.attach('call_agent_2', waiting); - - const rendered = group.render(40).join('\n'); - const output = strip(rendered); - expect(rendered).toContain(chalk.hex(darkColors.textStrong)('explore')); - expect(rendered).not.toContain(chalk.hex(darkColors.primary)('explore')); - expect(output).toContain('final check'); - expect(output).not.toContain('**'); - expect(output).not.toContain('activity prefix'); - } finally { - chalk.level = previousLevel; - group.dispose(); - running.dispose(); - waiting.dispose(); - } + startAgent(running, 'call_agent_1', 'explore'); + group.attach('call_agent_1', running); + group.attach('call_agent_2', waiting); + + const output = renderText(group); + expect(output).toContain('Still working…'); + expect(output).toContain('Waiting to start…'); + expect(output).not.toContain('Initializing…'); + + group.dispose(); + running.dispose(); + waiting.dispose(); }); it('refreshes grouped elapsed time from child subagent timers', () => { @@ -204,10 +212,42 @@ describe('AgentGroupComponent', () => { expect(terminal).toContain('2 agents finished · 15s'); expect(terminal).toContain('✗ Failed'); expect(terminal).toContain('Error: review failed'); - expect(terminal).not.toContain(formatThinkingSpinnerLabel()); + expect(terminal).not.toContain('Still working…'); group.dispose(); done.dispose(); running.dispose(); }); + + it('renders a detached foreground subagent as backgrounded in the group, even after its ToolResult lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + // Detach `a` (Ctrl+B), then its spawn-success ToolResult lands. + a.markBackgrounded(); + a.setResult({ + tool_call_id: 'call_agent_1', + output: 'agent_id: sub_call_agent_1\nactual_subagent_type: explore\n', + is_error: false, + }); + + const out = renderText(group); + // `a` must show as backgrounded, NOT completed. + expect(out).toContain('◐ backgrounded'); + expect(out).not.toContain('✓ Completed'); + // `b` is still running. + expect(out).toContain('Running'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); }); diff --git a/apps/pythinker-code/test/tui/components/messages/assistant-message.test.ts b/apps/pythinker-code/test/tui/components/messages/assistant-message.test.ts index 8bbf6bc7..a2d18007 100644 --- a/apps/pythinker-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/assistant-message.test.ts @@ -1,16 +1,26 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { Markdown, visibleWidth } from '@pymodel/pi-tui'; +import * as cliHighlight from 'cli-highlight'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { createPythinkerMarkdownTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { setMarkdownRenderLatex } from '#/tui/utils/markdown-options'; import { captureProcessWrite } from '../../../helpers/process'; -const ESC = String.fromCodePoint(27); +vi.mock('cli-highlight', async () => { + const actual = await vi.importActual<typeof import('cli-highlight')>('cli-highlight'); + return { + ...actual, + highlight: vi.fn(actual.highlight), + }; +}); function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('AssistantMessageComponent', () => { @@ -43,7 +53,7 @@ describe('AssistantMessageComponent', () => { it('renders unknown markdown fence languages as plain text without stderr noise', () => { const stderr = captureProcessWrite('stderr'); try { - const theme = createPythinkerMarkdownTheme(); + const theme = createMarkdownTheme(); expect(theme.highlightCode?.('hello\nworld', 'abcxyz')).toEqual(['hello', 'world']); expect(stderr.text()).not.toContain('Could not find the language'); } finally { @@ -51,15 +61,6 @@ describe('AssistantMessageComponent', () => { } }); - it('does not use red syntax highlighting in markdown code blocks', () => { - const theme = createPythinkerMarkdownTheme(); - const highlighted = theme - .highlightCode?.("const string = 'value';\nconst regexp = /value+/g;", 'javascript') - .join('\n'); - - expect(highlighted).not.toContain(`${ESC}[31m`); - }); - it('preserves literal hook result XML in normal assistant text', () => { const component = new AssistantMessageComponent(); @@ -71,4 +72,87 @@ describe('AssistantMessageComponent', () => { expect(text).toContain('</hook_result>'); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('reuses the same Markdown child across streaming text updates', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello world'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + expect(strip(component.render(80).join('\n'))).toContain('hello world'); + }); + + it('does not recreate the Markdown child when the text is unchanged', () => { + const component = new AssistantMessageComponent(); + + component.updateContent('hello'); + const first = (component as any).contentContainer.children[0]; + expect(first).toBeInstanceOf(Markdown); + + component.updateContent('hello'); + const second = (component as any).contentContainer.children[0]; + + expect(second).toBe(first); + }); + + it('rebuilds the Markdown child when transient changes so final render can highlight code', () => { + const component = new AssistantMessageComponent(); + const code = '```ts\nconst x = 1\n```'; + + component.updateContent(code, { transient: true }); + const streaming = (component as any).contentContainer.children[0]; + expect(streaming).toBeInstanceOf(Markdown); + + component.updateContent(code, { transient: false }); + const finalized = (component as any).contentContainer.children[0]; + expect(finalized).toBeInstanceOf(Markdown); + + expect(finalized).not.toBe(streaming); + }); + + it('skips synchronous syntax highlighting in transient markdown themes', () => { + const highlightSpy = vi.mocked(cliHighlight.highlight); + highlightSpy.mockClear(); + const streamingTheme = createMarkdownTheme({ transient: true }); + const finalTheme = createMarkdownTheme(); + const code = 'const x = 1'; + + expect(streamingTheme.highlightCode?.(code, 'typescript')).toEqual([code]); + expect(highlightSpy).not.toHaveBeenCalled(); + + finalTheme.highlightCode?.(code, 'typescript'); + expect(highlightSpy).toHaveBeenCalled(); + }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + const component = new AssistantMessageComponent(); + component.updateContent('hello'); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); + }); + + it('renders LaTeX math by default and keeps raw source when disabled', () => { + const component = new AssistantMessageComponent(); + try { + setMarkdownRenderLatex(true); + component.updateContent('\u80FD\u91CF\u516C\u5F0F $E = mc^2$'); + expect(strip(component.render(80).join('\n'))).toContain('E = mc²'); + + setMarkdownRenderLatex(false); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('$E = mc^2$'); + } finally { + setMarkdownRenderLatex(true); + } + }); }); diff --git a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts index 22ed9f0f..98a42708 100644 --- a/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,10 +1,8 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { currentTheme } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -47,46 +45,6 @@ describe('BackgroundAgentStatusComponent', () => { ); }); - it('colours only the bullet by phase and keeps the wording dim', () => { - const started = new BackgroundAgentStatusComponent({ - phase: 'started', - headline: 'bash task started in background', - detail: 'E2E: contained brand mark', - }); - const completed = new BackgroundAgentStatusComponent({ - phase: 'completed', - headline: 'bash task completed in background', - detail: 'E2E: contained brand mark · exit 0', - }); - - // Colours are off by default under vitest, which would make every - // assertion below compare bare strings and pass for the wrong reason. - const previousLevel = chalk.level; - chalk.level = 3; - try { - const startedLine = started.render(120).join('\n'); - const completedLine = completed.render(120).join('\n'); - - // A running task is ambient: dim dot, dim wording, no accent colour. - expect(startedLine).toContain(currentTheme.fg('textDim', STATUS_BULLET)); - expect(startedLine).toContain(currentTheme.fg('textDim', 'bash task started in background')); - expect(startedLine).not.toContain( - currentTheme.fg('primary', 'bash task started in background'), - ); - - // Completion turns the dot green — and only the dot. - expect(completedLine).toContain(currentTheme.fg('success', STATUS_BULLET)); - expect(completedLine).toContain( - currentTheme.fg('textDim', 'bash task completed in background'), - ); - expect(completedLine).not.toContain( - currentTheme.fg('success', 'bash task completed in background'), - ); - } finally { - chalk.level = previousLevel; - } - }); - it('keeps status lines within very narrow widths', () => { const component = new BackgroundAgentStatusComponent({ phase: 'started', diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts deleted file mode 100644 index 0a655840..00000000 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ /dev/null @@ -1,1116 +0,0 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { - DynamicWorkflowMissionControlComponent, - type DynamicWorkflowMissionControlOptions, - dynamicWorkflowResultSummaryFromOutput, -} from '#/tui/components/messages/dynamic-workflow-mission-control'; -import { - BRAILLE_SPINNER_FRAMES, - DYNAMIC_WORKFLOW_RENDERING, - BRAILLE_SPINNER_INTERVAL_MS, -} from '#/tui/constant/rendering'; -import { currentTheme, darkColors } from '#/tui/theme'; - -const DESCRIPTION = 'Review the interface'; - -function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -function renderText(component: DynamicWorkflowMissionControlComponent, width = 100): string { - return strip(component.render(width).join('\n')); -} - -/** Lifecycle progress glyph and label for a running row. */ -const RUNNING_GLYPH = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u; -const RUNNING_CELL = new RegExp(`${RUNNING_GLYPH.source}\\s+RUN`, 'u'); - -/** Head of a task cell that lost the preamble every row shared. */ -const TASK_ELISION_MARK = '…'; - -function memberLine(output: string, index: number): string { - const id = String(index).padStart(3, '0'); - const line = output.split('\n').find( - (candidate) => candidate.replace(/^│\s*/u, '').startsWith(id), - ); - if (line === undefined) throw new Error(`Missing Dynamic Workflow member ${id}`); - return line; -} - -function memberRowCount(output: string): number { - return output.split('\n').filter( - (candidate) => /^\d{3}\s/u.test(candidate.replace(/^│\s*/u, '')), - ).length; -} - - -function aggregateLine(output: string): string { - const line = output.split('\n').find((candidate) => - /\b(?:Orchestrating|Finalizing|Completed|Failed|Cancelled)\b/u.test(strip(candidate)) - ); - if (line === undefined) throw new Error('Missing Dynamic Workflow aggregate'); - return line; -} - -function createComponent( - options: Partial<DynamicWorkflowMissionControlOptions> = {}, -): DynamicWorkflowMissionControlComponent { - return new DynamicWorkflowMissionControlComponent({ - description: options.description ?? DESCRIPTION, - availableRows: options.availableRows, - }); -} - -function register( - component: DynamicWorkflowMissionControlComponent, - agentId: string, -): void { - component.registerSubagent({ agentId }); -} - -function prepareObservedWorkflow(): DynamicWorkflowMissionControlComponent { - const component = createComponent(); - component.updateArgs({ - description: DESCRIPTION, - items: ['Layout hierarchy', 'Interaction audit', 'Visual regression audit'], - }); - component.markInputComplete(); - component.setActivitySpinnerText(() => '⠋'); - - register(component, 'agent-1'); - component.markStarted('agent-1'); - register(component, 'agent-2'); - component.markStarted('agent-2'); - component.markCompleted('agent-2', 'Interaction audit'); - register(component, 'agent-3'); - component.markStarted('agent-3'); - component.markCompleted('agent-3', 'Visual regression audit'); - return component; -} - -afterEach(() => { - vi.useRealTimers(); -}); - -describe('DynamicWorkflowMissionControlComponent', () => { - it('parses only the Dynamic Workflow XML envelope', () => { - const dynamicWorkflowResult = [ - '<dynamic_workflow_result>', - '<summary>completed: 1, failed: 1, aborted: 1</summary>', - '<subagent outcome="completed">Layout hierarchy</subagent>', - '<subagent outcome="failed">Interaction audit</subagent>', - '<subagent outcome="aborted">Visual regression audit</subagent>', - '</dynamic_workflow_result>', - ].join('\n'); - - expect(dynamicWorkflowResultSummaryFromOutput(dynamicWorkflowResult)).toEqual({ - completed: 1, - failed: 1, - aborted: 1, - parsed: true, - }); - expect(dynamicWorkflowResultSummaryFromOutput(`dynamic_workflow: ${dynamicWorkflowResult}`)).toMatchObject({ - completed: 1, - failed: 1, - aborted: 1, - parsed: true, - }); - - for (const unsupported of [ - dynamicWorkflowResult.replaceAll('dynamic_workflow', 'agent_swarm'), - 'agent_swarm: failed\n[agent 1]\nstatus: failed\nsubagent error: legacy failure', - 'dynamic_workflow: failed\n[agent 1]\nstatus: failed', - '[agent 1]\nstatus: completed\n\n[summary]\nlegacy completion', - ]) { - expect(dynamicWorkflowResultSummaryFromOutput(unsupported).parsed).toBe(false); - } - }); - - it('maps schema errors to failed rows without shifting later results', () => { - const result = [ - '<dynamic_workflow_result>', - '<subagent outcome="schema_error">Invalid structured output</subagent>', - '<subagent outcome="completed">Valid result</subagent>', - '</dynamic_workflow_result>', - ].join('\n'); - const component = createComponent(); - component.updateArgs({ items: ['Schema work', 'Normal work'] }); - component.markInputComplete(); - - expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({ - completed: 1, - failed: 1, - aborted: 0, - parsed: true, - }); - expect(component.applyResult(result)).toBe(true); - expect(memberLine(renderText(component, 120), 1)).toMatch(/×\s+FAIL\s+Schema work/u); - expect(memberLine(renderText(component, 120), 2)).toMatch(/✓\s+DONE\s+Normal work/u); - }); - - it('ignores blank items so no phantom row waits forever', () => { - const component = createComponent(); - // The engine drops the blank before launching anything, so counting it here - // would leave a third row queued for good and pin the header at 2/3. - component.updateArgs({ items: ['Layout hierarchy', 'Interaction audit', ' '] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.markCompleted('agent-1', 'Done one'); - register(component, 'agent-2'); - component.markStarted('agent-2'); - component.markCompleted('agent-2', 'Done two'); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); - expect(memberLine(output, 2)).toContain('✓ DONE'); - // memberRowCount also counts activity lines, so assert the row's absence. - expect(() => memberLine(output, 3)).toThrow(/Missing Dynamic Workflow member 003/u); - expect(aggregateLine(output)).toContain('2/2 complete'); - }); - - it('still parses a result that carries the dropped-items note', () => { - // agent-core appends this note when it ignores blank items. It used to be - // prepended, which made the envelope regex miss and rendered a successful - // run as "Unsupported Dynamic Workflow result". - const result = [ - '<dynamic_workflow_result>', - '<summary>completed: 1</summary>', - '<subagent outcome="completed">Layout hierarchy</subagent>', - '</dynamic_workflow_result>', - 'Note: 1 empty item was ignored; the workflow ran without them.', - ].join('\n'); - - expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({ - completed: 1, - failed: 0, - aborted: 0, - parsed: true, - }); - - const component = createComponent(); - component.updateArgs({ items: ['Layout hierarchy'] }); - component.markInputComplete(); - expect(component.applyResult(result)).toBe(true); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); - expect(output).not.toContain('Unsupported'); - }); - - it('decodes escaped XML fields and preserves literal closing-tag text', () => { - const result = [ - '<dynamic_workflow_result>', - '<summary>completed: 1</summary>', - '<subagent item="a&b "quoted" 😀" outcome="completed">before </subagent> & after</subagent>', - '</dynamic_workflow_result>', - ].join('\n'); - const component = createComponent(); - component.updateArgs({}); - component.markInputComplete(); - - expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({ - completed: 1, - failed: 0, - aborted: 0, - parsed: true, - }); - expect(component.applyResult(result)).toBe(true); - - const output = renderText(component, 160); - expect(memberLine(output, 1)).toContain('a&b "quoted" 😀'); - expect(output).toContain('before </subagent> & after'); - expect(output).not.toContain('&'); - expect(output).not.toContain('</subagent>'); - }); - - it('rejects invalid or duplicate explicit result indexes instead of remapping them', () => { - const result = [ - '<dynamic_workflow_result>', - '<subagent index="129" outcome="failed">Out-of-range result</subagent>', - '<subagent index="1" outcome="completed">Accepted result</subagent>', - '<subagent index="1" outcome="failed">Duplicate result</subagent>', - '</dynamic_workflow_result>', - ].join('\n'); - const component = createComponent(); - component.updateArgs({}); - component.markInputComplete(); - - expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({ - completed: 1, - failed: 0, - aborted: 0, - parsed: true, - }); - expect(component.applyResult(result)).toBe(true); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toContain('✓ DONE'); - expect(output).toContain('Accepted result'); - expect(output).not.toContain('Out-of-range result'); - expect(output).not.toContain('Duplicate result'); - }); - - it('renders a bounded frame with a coral Dynamic Workflow title', () => { - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const lines = prepareObservedWorkflow().render(80); - const plainLines = lines.map(strip); - - expect(lines[0]).toContain( - chalk.hex(darkColors.workflowTitle).bold('Dynamic Workflow'), - ); - expect(plainLines[0]).toMatch(/^╭─ Dynamic Workflow · Review the interface ─+╮$/u); - expect(plainLines[1]).toBe(`│${' '.repeat(78)}│`); - expect(plainLines[2]).toContain('Orchestrating'); - expect(plainLines[3]).toBe(`│${' '.repeat(78)}│`); - expect(plainLines.at(-1)).toBe(`╰${'─'.repeat(78)}╯`); - for (const line of plainLines.slice(1, -1)) { - expect(line).toMatch(/^│ .* │$/u); - } - for (const line of lines) { - expect(visibleWidth(line)).toBe(80); - } - } finally { - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('normalizes multiline titles and tasks before framing them', () => { - const component = createComponent({ description: '' }); - component.updateArgs({ - description: 'Review\nthe interface', - items: ['Inspect\nlayout'], - }); - component.markInputComplete(); - - const lines = component.render(80); - const output = strip(lines.join('\n')); - expect(output).toContain('╭─ Dynamic Workflow · Review the interface'); - expect(memberLine(output, 1)).toContain('Inspect layout'); - for (const line of lines) { - expect(visibleWidth(line)).toBe(80); - } - }); - - it('uses the unframed fallback below the minimum frame width', () => { - const component = prepareObservedWorkflow(); - const narrow = component.render(20).map(strip); - const framed = component.render(21).map(strip); - - expect(narrow[0]).toMatch(/^Dynamic Workflow/u); - for (const line of narrow) { - expect(visibleWidth(line)).toBeLessThanOrEqual(20); - } - expect(framed[0]).toBe('╭─ Dynamic Workflow ╮'); - expect(framed.at(-1)).toBe(`╰${'─'.repeat(19)}╯`); - for (const line of framed) { - expect(visibleWidth(line)).toBe(21); - } - }); - - it('renders the observed aggregate and stable vertical member rows', () => { - const component = prepareObservedWorkflow(); - - const output = renderText(component, 100); - expect(output).toContain('Dynamic Workflow'); - expect(output).toContain('Orchestrating'); - expect(aggregateLine(output)).toContain('2/3 complete'); - expect(aggregateLine(output)).not.toMatch(/\b\d+%/u); - expect(aggregateLine(output)).not.toContain('━'); - expect(memberLine(output, 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN\s+Layout hierarchy/u); - expect(memberLine(output, 2)).toMatch(/✓\s+DONE\s+Interaction audit/u); - expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); - }); - - it('shows row cubes but no aggregate percentage or bar before the input count is known', () => { - const component = createComponent({ description: '' }); - component.setActivitySpinnerText(() => '⠋'); - component.updateArgs({}, { - streamingArguments: '{"description":"Review the interface","items":["Layout hierarchy","Inter', - }); - - const output = renderText(component, 100); - expect(output).toContain('Dynamic Workflow'); - expect(output).toContain('Waiting for delegated agents'); - expect(aggregateLine(output)).toMatch(/\b\d+s elapsed\b/); - expect(aggregateLine(output)).not.toMatch(/\b\d+%/); - expect(memberLine(output, 1)).toMatch(/○\s+PEND/u); - expect(output).not.toMatch(/[⣿⣷⣯⣟⡿⢿⣻⣽]{4,}/u); - expect(aggregateLine(output)).not.toContain('━'); - }); - - it('uses only the host loader timer while a workflow is active', () => { - vi.useFakeTimers(); - const timerCount = vi.getTimerCount(); - const component = createComponent(); - component.updateArgs({ items: ['Layout hierarchy'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.render(100); - - expect(vi.getTimerCount()).toBe(timerCount); - }); - - it('animates the braille header and shimmers Orchestrating without creating a timer', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = createComponent(); - component.setActivitySpinnerText(() => '⠋'); - const timerCount = vi.getTimerCount(); - const before = aggregateLine(component.render(100).join('\n')); - - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS); - const after = aggregateLine(component.render(100).join('\n')); - - expect(strip(after).replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u, '')).toBe( - strip(before).replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u, ''), - ); - expect(after).not.toBe(before); - expect(strip(after)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/); - expect(strip(after)).toContain('Orchestrating'); - expect(vi.getTimerCount()).toBe(timerCount); - } finally { - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('colours running progress and gives Orchestrating three shimmer tiers', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = createComponent(); - component.updateArgs({ items: ['Layout hierarchy'] }); - component.markInputComplete(); - component.setActivitySpinnerText(() => '⠋'); - register(component, 'agent-1'); - component.markStarted('agent-1'); - - const colouredMemberLine = (): string => { - const line = component.render(100).find( - (candidate) => strip(candidate).replace(/^│\s*/u, '').startsWith('001'), - ); - if (line === undefined) throw new Error('Missing Dynamic Workflow member 001'); - return line; - }; - - const first = colouredMemberLine(); - vi.setSystemTime(300); - const second = colouredMemberLine(); - - expect(first).toContain(chalk.hex(darkColors.primary)('⠋')); - expect(second).toContain(chalk.hex(darkColors.primary)('⠙')); - expect(first).toContain(chalk.hex(darkColors.primary)('RUN')); - vi.setSystemTime(BRAILLE_SPINNER_INTERVAL_MS * 1.5); - - const aggregate = aggregateLine(component.render(100).join('\n')); - expect(strip(aggregate)).toContain('Orchestrating'); - expect(aggregate).toContain(chalk.hex(darkColors.primaryShimmer).bold('O')); - expect(aggregate).toContain(chalk.hex(darkColors.primary)('r')); - expect(aggregate).toContain(chalk.hex(darkColors.textDim)('chestrating')); - } finally { - vi.useRealTimers(); - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('cancels the request without inventing terminal child states', () => { - const component = createComponent(); - component.updateArgs({ items: ['Running work', 'Queued work'] }); - component.markInputComplete(); - component.setActivitySpinnerText(() => '⠋'); - component.registerSubagent({ agentId: 'agent-running', dynamicWorkflowIndex: 1 }); - component.markStarted('agent-running'); - component.registerSubagent({ agentId: 'agent-queued', dynamicWorkflowIndex: 2 }); - - component.markActiveCancelled(); - - const output = renderText(component, 120); - expect(output).toContain('– Cancelled'); - expect(memberLine(output, 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN\s+Running work/u); - expect(memberLine(output, 2)).toMatch(/○\s+WAIT\s+Queued work/u); - expect(output).not.toContain('– STOP'); - expect(output).not.toContain('⠋ Orchestrating'); - }); - - it('keeps the first terminal lifecycle state when later events arrive out of order', () => { - const component = createComponent(); - component.updateArgs({ items: ['Layout hierarchy'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markCompleted('agent-1', 'Finished first'); - component.markFailed('agent-1', 'Late failure'); - - const output = renderText(component, 100); - expect(memberLine(output, 1)).toMatch(/✓\s+DONE\s+Layout hierarchy/u); - expect(output).toContain('Finished first'); - expect(output).not.toContain('Late failure'); - }); - - it('lets a structured result fill only nonterminal children', () => { - const component = createComponent(); - component.updateArgs({ items: ['Observed first', 'Result-only second'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); - component.markCompleted('agent-1', 'Observed completion'); - - component.applyResult([ - '<dynamic_workflow_result>', - '<subagent index="1" outcome="failed">Late result failure</subagent>', - '<subagent index="2" outcome="failed">Result failure</subagent>', - '</dynamic_workflow_result>', - ].join('\n')); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/✓\s+DONE\s+Observed first/u); - expect(output).toContain('Observed completion'); - expect(output).not.toContain('Late result failure'); - expect(memberLine(output, 2)).toMatch(/×\s+FAIL\s+Result-only second/u); - expect(output).toContain('Result failure'); - }); - - it('ignores result rows beyond the accepted input total', () => { - const component = createComponent(); - component.updateArgs({ items: ['Known work'] }); - component.markInputComplete(); - - component.applyResult([ - '<dynamic_workflow_result>', - '<subagent index="1" outcome="completed">Done</subagent>', - '<subagent index="2" outcome="failed">Phantom failure</subagent>', - '</dynamic_workflow_result>', - ].join('\n')); - - const output = renderText(component, 120); - expect(aggregateLine(output)).toContain('1/1 complete'); - expect(memberLine(output, 1)).toContain('✓ DONE'); - expect(output).not.toContain('002'); - expect(output).not.toContain('Phantom failure'); - expect(output).not.toMatch(/\d+%/u); - }); - - it('keeps late activity suspended until a lifecycle start resumes it', () => { - const component = createComponent(); - component.updateArgs({ items: ['Rate-limited work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' }); - - component.appendModelDelta({ agentId: 'agent-1', delta: 'Late output' }); - component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - expect(memberLine(renderText(component, 100), 1)).toMatch(/◑\s+HOLD/u); - expect(renderText(component, 100)).toContain('Rate limited'); - - component.markStarted('agent-1'); - expect(memberLine(renderText(component, 100), 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN/u); - }); - - it('prefers a suspension detail over stale model progress in the member row', () => { - const component = createComponent({ availableRows: () => 5 }); - component.updateArgs({ items: ['Throttle-sensitive work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - component.appendModelDelta({ agentId: 'agent-1', delta: 'Stale model progress' }); - component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' }); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/◑\s+HOLD\s+Throttle-sensitive work/); - expect(output).toContain('Rate limited'); - expect(output).not.toContain('Stale model progress'); - }); - - it('prefers a failure detail over stale model progress in the member row', () => { - const component = createComponent({ availableRows: () => 5 }); - component.updateArgs({ items: ['Failure-sensitive work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - component.appendModelDelta({ agentId: 'agent-1', delta: 'Stale model progress' }); - component.markFailed('agent-1', 'Provider exhausted'); - - const output = renderText(component, 120); - expect(memberLine(output, 1)).toMatch(/×\s+FAIL\s+Failure-sensitive work/); - expect(output).toContain('Provider exhausted'); - expect(output).not.toContain('Stale model progress'); - }); - - it.each([ - ['blank', {}, 'Discovered task'], - ['resumed', { resume_agent_ids: { 'agent-previous': true } }, 'Resumed task'], - ])('hydrates a %s row from a structured result item', (_kind, args, item) => { - const component = createComponent(); - component.updateArgs(args); - component.markInputComplete(); - component.applyResult([ - '<dynamic_workflow_result>', - `<subagent index="1" item="${item}" outcome="completed">Done</subagent>`, - '</dynamic_workflow_result>', - ].join('\n')); - - const output = renderText(component, 120); - expect(output).toContain(item); - expect(output).not.toContain('(resumed)'); - }); - - it('orders explicit Dynamic Workflow indexes before spawn-order fallbacks', () => { - const component = createComponent(); - component.updateArgs({ items: ['First item', 'Second item', 'Third item'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-3', dynamicWorkflowIndex: 3 }); - component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); - component.registerSubagent({ agentId: 'agent-fallback' }); - component.markStarted('agent-3'); - component.markStarted('agent-1'); - component.markStarted('agent-fallback'); - - const output = renderText(component, 100); - expect(output.indexOf(memberLine(output, 1))).toBeLessThan(output.indexOf(memberLine(output, 2))); - expect(output.indexOf(memberLine(output, 2))).toBeLessThan(output.indexOf(memberLine(output, 3))); - }); - - it('renders every observed member and request phase without inventing lifecycle events', () => { - const pending = createComponent(); - pending.updateArgs({}, { streamingArguments: '{"items":["Pending work"' }); - expect(memberLine(renderText(pending, 100), 1)).toMatch(/○\s+PEND\s+Pending work/); - - const component = createComponent(); - component.updateArgs({ - items: ['Queued work', 'Running work', 'Suspended work', 'Complete work', 'Failed work', 'Stopped work'], - }); - component.markInputComplete(); - for (let index = 1; index <= 6; index += 1) { - component.registerSubagent({ agentId: `agent-${String(index)}`, dynamicWorkflowIndex: index }); - } - component.markStarted('agent-2'); - component.markSuspended({ agentId: 'agent-3', reason: 'Rate limited' }); - component.markCompleted('agent-4', 'Done'); - component.markFailed('agent-5', 'Failed'); - component.markCancelled('agent-6'); - - const output = renderText(component, 140); - for (const token of ['○ WAIT', '◑ HOLD', '✓ DONE', '× FAIL', '– STOP']) { - expect(output).toContain(token); - } - // Running is the one animated phase, so its symbol varies by frame. - expect(output).toMatch(RUNNING_CELL); - expect(output).toContain('Orchestrating'); - - const failed = createComponent(); - failed.updateArgs({ items: ['One'] }); - failed.markInputComplete(); - failed.markRequestFailed('Provider failure'); - expect(renderText(failed, 100)).toContain('× Failed'); - - const cancelled = createComponent(); - cancelled.updateArgs({ items: ['One'] }); - cancelled.markInputComplete(); - cancelled.markActiveCancelled(); - expect(renderText(cancelled, 100)).toContain('– Cancelled'); - }); - - it.each([64, 79, 80, 100])( - 'never renders an estimated aggregate bar or percentage at width %i', - (width) => { - const aggregate = aggregateLine(renderText(prepareObservedWorkflow(), width)); - expect(aggregate).toContain('2/3 complete'); - expect(aggregate).not.toMatch(/\b\d+%/u); - expect(aggregate).not.toContain('━'); - }, - ); - - it('renders lifecycle progress without a percentage, work count, or idle age', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = createComponent(); - component.updateArgs({ items: ['Live work', 'Queued work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); - component.markStarted('agent-1'); - - const running = renderText(component, 100); - expect(running).toContain('PROGRESS'); - expect(running).not.toContain('WORK IDLE'); - expect(memberLine(running, 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN\s+Live work/u); - expect(memberLine(running, 2)).toMatch(/○\s+WAIT\s+Queued work/u); - expect(running).not.toMatch(/\b\d+%|⚒|━/u); - }); - - it('rotates the running indicators from the shared workflow clock and freezes completion', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = createComponent(); - component.updateArgs({ items: ['Live work'] }); - component.markInputComplete(); - component.setActivitySpinnerText(() => '⠋'); - component.registerSubagent({ agentId: 'agent-1' }); - component.markStarted('agent-1'); - - for (const [index, glyph] of BRAILLE_SPINNER_FRAMES.slice(0, 4).entries()) { - const time = index * DYNAMIC_WORKFLOW_RENDERING.progressFrameMs; - vi.setSystemTime(time); - const line = component.render(100).find((candidate) => strip(candidate).includes('001')); - expect(line).toContain(chalk.hex(darkColors.primary)(glyph)); - expect(strip(aggregateLine(component.render(100).join('\n')))).toMatch( - /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Orchestrating/, - ); - } - - component.markCompleted('agent-1', 'Done'); - const completed = component.render(100).find((line) => strip(line).includes('001')); - expect(completed).toContain(chalk.hex(darkColors.success)('✓')); - vi.setSystemTime(10_000); - expect(memberLine(renderText(component, 100), 1)).toMatch(/✓\s+DONE/u); - } finally { - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('renders lifecycle states without colour support', () => { - vi.useFakeTimers(); - vi.setSystemTime(160); - const previousLevel = chalk.level; - chalk.level = 0; - - try { - const component = createComponent(); - component.updateArgs({ items: ['Queued work', 'Live work', 'Done work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-live', dynamicWorkflowIndex: 2 }); - component.markStarted('agent-live'); - component.registerSubagent({ agentId: 'agent-done', dynamicWorkflowIndex: 3 }); - component.markCompleted('agent-done', 'Done'); - - const output = renderText(component, 100); - expect(memberLine(output, 1)).toMatch(/○\s+WAIT/u); - expect(memberLine(output, 2)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN/u); - expect(memberLine(output, 3)).toMatch(/✓\s+DONE/u); - } finally { - chalk.level = previousLevel; - } - }); - - it('centres lifecycle glyphs in one fixed progress column', () => { - const component = createComponent(); - component.updateArgs({ items: ['Queued work', 'Live work', 'Done work'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-live', dynamicWorkflowIndex: 2 }); - component.markStarted('agent-live'); - component.registerSubagent({ agentId: 'agent-done', dynamicWorkflowIndex: 3 }); - component.markCompleted('agent-done', 'Done'); - - const output = renderText(component, 100); - const glyphColumns = [ - memberLine(output, 1).indexOf('○'), - memberLine(output, 2).search(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u), - memberLine(output, 3).indexOf('✓'), - ]; - expect(glyphColumns[0]).toBeGreaterThan(0); - expect(new Set(glyphColumns).size).toBe(1); - }); - - it('reports aggregate completion counts without estimating overall progress', () => { - const component = createComponent(); - component.updateArgs({ - items: Array.from({ length: 11 }, (_, index) => `Review ${String(index + 1)}`), - }); - component.markInputComplete(); - - for (let index = 1; index <= 11; index += 1) { - const agentId = `agent-${String(index)}`; - component.registerSubagent({ agentId, dynamicWorkflowIndex: index }); - component.markStarted(agentId); - component.recordToolCall({ agentId, name: 'Read' }); - component.appendModelDelta({ agentId, delta: 'Checking results' }); - } - component.markCompleted('agent-3', 'Done'); - - const aggregate = aggregateLine(renderText(component, 120)); - expect(aggregate).toContain('1/11 complete'); - expect(aggregate).not.toMatch(/\b\d+%/u); - expect(aggregate).not.toContain('━'); - }); - - it('clips stable rows before activity and exposes a useful hidden-agent count', () => { - const component = createComponent({ availableRows: () => 6 }); - component.updateArgs({ items: ['One', 'Two', 'Three', 'Four', 'Five'] }); - component.markInputComplete(); - - const lines = renderText(component, 100).split('\n'); - expect(lines).toHaveLength(6); - expect(memberLine(lines.join('\n'), 1)).toMatch(/○\s+WAIT\s+One/u); - expect(lines.join('\n')).toContain('+ 4 more agents'); - expect(lines.join('\n')).not.toContain('Recent activity'); - }); - - it('drops the preamble every task repeats so the row keeps what names it', () => { - const preamble = 'You are auditing the pythinker-code monorepo at /Users/panda. Verify '; - const component = createComponent(); - component.updateArgs({ - items: [ - `${preamble}the permission glob`, - `${preamble}the concurrency cap`, - `${preamble}the resume path`, - ], - }); - component.markInputComplete(); - - const output = renderText(component, 100); - expect(output).not.toContain('You are auditing'); - // Greedy on purpose: the shared `the ` goes with the rest of the preamble. - expect(memberLine(output, 1)).toContain('…permission glob'); - expect(memberLine(output, 2)).toContain('…concurrency cap'); - expect(memberLine(output, 3)).toContain('…resume path'); - - // The mark is one column wide, so it never pushes a row past the frame. - for (const width of [20, 40, 63, 64, 79, 80, 100, 150]) { - expect(component.render(width).every((line) => visibleWidth(line) <= width)).toBe(true); - } - }); - - it.each([ - // Nothing shared: every row already names itself. - { name: 'no shared head', items: ['Audit the plan', 'Ship the release'] }, - // Shared but short: the mark would cost about what the elision frees. - { name: 'a short shared head', items: ['Audit the plan', 'Audit the release'] }, - // One row is the whole of what the other shares, and what is left over is - // one short word — below the floor, so the rows stay whole. - { name: 'a row that is the whole shared head', items: ['Audit the plan appendix', 'Audit the plan'] }, - // A prefix with no space in it can only be cut mid-word. - { name: 'an unbroken shared head', items: ['aaaaaaaaaaaaaaaaaaaa-one', 'aaaaaaaaaaaaaaaaaaaa-two'] }, - ])('keeps whole tasks when there is $name', ({ items }) => { - const component = createComponent(); - component.updateArgs({ items }); - component.markInputComplete(); - - const output = renderText(component, 200); - items.forEach((item, index) => { - expect(memberLine(output, index + 1)).toContain(item); - expect(memberLine(output, index + 1)).not.toContain(TASK_ELISION_MARK); - }); - }); - - it('leaves a row whose whole task is the shared head with the word the cut skipped', () => { - const component = createComponent(); - component.updateArgs({ - items: [ - 'Audit the pythinker-code monorepo', - 'Audit the pythinker-code monorepo plan', - ], - }); - component.markInputComplete(); - - // The cut lands before `monorepo`, not after it, so the shorter row keeps a - // word rather than collapsing to the mark on its own. - const output = renderText(component, 100); - expect(memberLine(output, 1)).toContain('…monorepo'); - expect(memberLine(output, 2)).toContain('…monorepo plan'); - expect(output).not.toContain('Audit the pythinker-code'); - }); - - it('holds the elision steady while rows are clipped away', () => { - const preamble = 'Audit the pythinker-code monorepo and report on '; - const items = ['the plan', 'the cap', 'the resume path', 'the glob'].map( - (tail) => `${preamble}${tail}`, - ); - const full = createComponent(); - full.updateArgs({ items }); - full.markInputComplete(); - // Two of the four rows are clipped, but the prefix is measured across every - // member, so the visible rows read exactly as they did before the clip. - const clipped = createComponent({ availableRows: () => 6 }); - clipped.updateArgs({ items }); - clipped.markInputComplete(); - - expect(memberLine(renderText(clipped, 100), 1)) - .toBe(memberLine(renderText(full, 100), 1)); - expect(memberLine(renderText(clipped, 100), 1)).toContain('…plan'); - }); - - it('keeps three workflow-relative activity entries with suspension and failure details', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = createComponent(); - component.updateArgs({ items: ['Review activity'] }); - component.markInputComplete(); - vi.setSystemTime(1_000); - component.registerSubagent({ agentId: 'agent-1' }); - vi.setSystemTime(2_000); - component.markStarted('agent-1'); - vi.setSystemTime(3_000); - component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' }); - vi.setSystemTime(4_000); - component.markStarted('agent-1'); - vi.setSystemTime(5_000); - component.markFailed('agent-1', 'Provider exhausted'); - - const output = renderText(component, 120); - expect(output).toContain('Recent activity'); - expect(output).toContain('001 +3s Suspended: Rate limited'); - expect(output).toContain('001 +4s Started'); - expect(output).toContain('001 +5s Failed: Provider exhausted'); - expect(output).not.toContain('001 +1s Agent spawned'); - }); - - it.each([ - [20, false, false], - [40, false, true], - [63, false, true], - [64, true, false], - [79, true, false], - [80, true, false], - [100, true, false], - ] as const)( - 'keeps progress and task columns aligned at width %i', - (width, expectedProgress, expectedStatus) => { - const component = prepareObservedWorkflow(); - const rendered = component.render(width); - const output = strip(rendered.join('\n')); - - expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); - expect(memberLine(output, 1)).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN/u); - expect(output.includes('PROGRESS')).toBe(expectedProgress); - expect(output.includes('STATUS')).toBe(expectedStatus); - expect(output).not.toContain('WORK IDLE'); - }, - ); - - it('keeps progress independent of tool calls and streamed text', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = createComponent(); - component.updateArgs({ items: ['Long streaming work'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - - vi.setSystemTime(30_000); - const before = memberLine(renderText(component, 100), 1).match(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u)?.[0]; - component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - for (let index = 0; index < 200; index += 1) { - component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); - } - component.recordToolCall({ agentId: 'agent-1', name: 'Bash' }); - - const output = renderText(component, 100); - const after = memberLine(output, 1).match(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u)?.[0]; - expect(before).toBeDefined(); - expect(after).toBe(before); - expect(output).not.toMatch(/\b\d+%|⚒/u); - }); - - it('starts a new line for model text after a tool label instead of fusing them', () => { - const component = createComponent(); - component.updateArgs({ items: ['Work'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - component.appendModelDelta({ agentId: 'agent-1', delta: "I've read the files" }); - - const line = memberLine(renderText(component, 200), 1); - expect(line).not.toContain("Using ReadI've"); - expect(line).toContain("I've read the files"); - }); - - it('records every line a provider packs into one delta', () => { - const component = createComponent(); - component.updateArgs({ items: ['Work'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - - // A provider that batches sends whole lines at once. Only the last one used - // to reach the activity list, so the same run showed less on that provider. - component.appendModelDelta({ - agentId: 'agent-1', - delta: 'First line\nSecond line\nThird line\n', - }); - - const output = renderText(component, 200); - expect(output).toContain('First line'); - expect(output).toContain('Second line'); - expect(output).toContain('Third line'); - }); - - it('does not report an unfinished line as an event of its own', () => { - const component = createComponent(); - component.updateArgs({ items: ['Work'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.appendModelDelta({ agentId: 'agent-1', delta: 'Closed line\nstill wri' }); - - // The row shows the tail as it arrives, but the activity list only carries - // the line the delta actually closed. - expect(memberLine(renderText(component, 200), 1)).toContain('still wri'); - const activity = renderText(component, 200).split('\n').filter( - (line) => /^│\s*001 \+/u.test(line), - ); - expect(activity.join('\n')).toContain('Closed line'); - expect(activity.join('\n')).not.toContain('still wri'); - }); - - it.each([64, 70, 80, 100, 200])( - 'keeps the task readable beside a long agent summary at width %i', - (width) => { - const component = createComponent(); - component.updateArgs({ items: ['Cluster B: verify the plan appendix'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - component.markCompleted( - 'agent-1', - 'Verification complete. All six Phase-1 items checked against the current tree. '.repeat(4), - ); - - // The task names the row. A long summary may be clipped; the identity may - // not — it used to collapse to a single character once a detail arrived. - const rendered = component.render(width); - expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); - const line = memberLine(strip(rendered.join('\n')), 1); - expect(line).toContain('Cluster B: v'); - expect(line).toContain('Verific'); - expect(line).toMatch(/\b0s\s*│?\s*$/u); - }, - ); - - it('closes a streamed line at its newline instead of fusing the whole message', () => { - const component = createComponent(); - component.updateArgs({ items: ['Stream a report'] }); - component.markInputComplete(); - register(component, 'agent-1'); - component.markStarted('agent-1'); - for (const delta of ['First line\n', 'Second line\n', 'Third line\n']) { - component.appendModelDelta({ agentId: 'agent-1', delta }); - } - - const output = renderText(component, 200); - expect(output).not.toContain('First lineSecond line'); - expect(memberLine(output, 1)).toContain('Third line'); - expect(memberLine(output, 1)).not.toContain('First line'); - // Each closed line is its own activity entry, not three copies of one prefix. - expect(output).toContain('First line'); - expect(output).toContain('Second line'); - }); - - it('renders object items by their prompt field and drops streamed phantom rows', () => { - const component = createComponent(); - const streamingArguments = - '{"items": [{"prompt": "Explore records", "description": "Records"},' - + ' {"prompt": "Explore events", "description": "Events"}'; - component.updateArgs({}, { streamingArguments }); - // Object keys and nested values are not items: two members, not eight. - expect(memberRowCount(renderText(component, 200))).toBe(2); - - component.updateArgs({ - items: [ - { prompt: 'Explore records', description: 'Records' }, - { prompt: 'Explore events', description: 'Events' }, - ], - }); - component.markInputComplete(); - - const output = renderText(component, 200); - expect(memberRowCount(output)).toBe(2); - expect(memberLine(output, 1)).toContain('Explore records'); - expect(memberLine(output, 1)).not.toContain('[object Object]'); - expect(memberLine(output, 2)).toContain('Explore events'); - }); - - it('shimmers Finalizing once every member is terminal but the result has not arrived', () => { - const component = createComponent(); - component.updateArgs({ items: ['One', 'Two'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); - component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 }); - component.markStarted('agent-1'); - component.markStarted('agent-2'); - component.markCompleted('agent-1', 'Done'); - - const running = renderText(component, 100); - expect(running).toContain('Orchestrating'); - expect(running).not.toContain('Finalizing'); - - component.markCompleted('agent-2', 'Done'); - const finalizing = renderText(component, 100); - expect(finalizing).toContain('Finalizing'); - expect(finalizing).not.toContain('Orchestrating'); - - component.applyResult([ - '<dynamic_workflow_result>', - '<subagent index="1" outcome="completed">Done</subagent>', - '<subagent index="2" outcome="completed">Done</subagent>', - '</dynamic_workflow_result>', - ].join('\n')); - const done = renderText(component, 100); - expect(done).toContain('✓ Completed'); - expect(done).not.toContain('Finalizing'); - }); - - it('keeps Orchestrating while an out-of-band member beyond knownTotal still runs', () => { - const component = createComponent(); - component.updateArgs({ items: ['One', 'Two'] }); - component.markInputComplete(); - component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 }); - component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 }); - component.registerSubagent({ agentId: 'agent-3', dynamicWorkflowIndex: 3 }); - component.markStarted('agent-3'); - component.markCompleted('agent-1', 'Done'); - component.markCompleted('agent-2', 'Done'); - - const output = renderText(component, 100); - expect(output).toMatch(RUNNING_CELL); - expect(output).toContain('Orchestrating'); - expect(output).not.toContain('Finalizing'); - - component.markCompleted('agent-3', 'Done'); - expect(renderText(component, 100)).toContain('Finalizing'); - }); - - it('aligns narrow member rows and the header on the same task column', () => { - const component = prepareObservedWorkflow(); - const output = renderText(component, 50); - const unframe = (line: string) => line.replace(/^│ /u, ''); - const running = unframe(memberLine(output, 1)); - const completed = unframe(memberLine(output, 2)); - const headerLine = output.split('\n').find((line) => line.includes('STATUS')); - if (headerLine === undefined) throw new Error('Missing Dynamic Workflow table header'); - const header = unframe(headerLine); - - const taskColumn = running.indexOf('Layout hierarchy'); - expect(taskColumn).toBeGreaterThan(0); - expect(completed.indexOf('Interaction audit')).toBe(taskColumn); - expect(header.indexOf('TASK')).toBe(taskColumn); - }); -}); diff --git a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts index 638c3a4c..35310665 100644 --- a/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/goal-markers.test.ts @@ -1,11 +1,11 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { DynamicWorkflowModeMarkerComponent } from '#/tui/components/messages/dynamic-workflow-markers'; import { buildGoalMarker, GoalMarkerComponent } from '#/tui/components/messages/goal-markers'; import type { GoalChange } from '@pymodel/pythinker-code-sdk'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const ANSI_SGR = /\[[0-9;]*m/g; function strip(lines: string[]): string { return lines.join('\n').replaceAll(ANSI_SGR, ''); } diff --git a/apps/pythinker-code/test/tui/components/messages/goal-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/goal-panel.test.ts index d3329727..de841c24 100644 --- a/apps/pythinker-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/goal-panel.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -167,7 +167,7 @@ describe('GoalStatusMessageComponent', () => { }); it('keeps the status box within narrow widths', () => { - const rendered = new GoalStatusMessageComponent(goal({ objective: 'Manage the Lark calendar skill description '.repeat(4).trim() })); + const rendered = new GoalStatusMessageComponent(goal({ objective: '\u7BA1\u7406\u98DE\u4E66\u65E5\u5386\u7684\u6280\u80FD\u63CF\u8FF0 '.repeat(4).trim() })); for (const width of [39, 24, 20, 10]) { for (const line of rendered.render(width)) { diff --git a/apps/pythinker-code/test/tui/components/messages/notice.test.ts b/apps/pythinker-code/test/tui/components/messages/notice.test.ts index 63c60a12..ca5ad5ad 100644 --- a/apps/pythinker-code/test/tui/components/messages/notice.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/notice.test.ts @@ -1,8 +1,11 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { CronMessageComponent } from '#/tui/components/messages/cron-message'; -import { NoticeMessageComponent } from '#/tui/components/messages/status-message'; +import { + NoticeMessageComponent, + StatusMessageComponent, +} from '#/tui/components/messages/status-message'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -39,3 +42,19 @@ describe('CronMessageComponent', () => { } }); }); + +describe('StatusMessageComponent', () => { + it('strips carriage returns so a CRLF line does not render blank', () => { + // A trailing `\r` (e.g. from a CRLF server error page) is zero-width for + // the line wrapper, so padding spaces appended after it would otherwise + // overwrite the visible content. The status component strips `\r`. + const component = new StatusMessageComponent('Error: boom\r\nmore\r', 'error'); + const text = component + .render(120) + .map((line) => strip(line)) + .join('\n'); + expect(text).toContain('Error: boom'); + expect(text).toContain('more'); + expect(text).not.toContain('\r'); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts b/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts index 128aa2cd..bb501c05 100644 --- a/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/shell-execution.test.ts @@ -112,7 +112,7 @@ describe('ShellExecutionComponent', () => { describe('shellExecutionResultRenderer', () => { const longCmd = `echo ${'a'.repeat(200)}\necho done`; - it('omits the command preview when collapsed', () => { + it('renders only the result and leaves the command to the call preview', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -131,11 +131,14 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(100)) .map(strip) .join('\n'); + // Command is owned by ToolCallComponent.buildCallPreview, not the + // renderer — rendering it here too would duplicate it once the result + // lands. expect(rendered).not.toContain('$ echo'); expect(rendered).toContain('ok'); }); - it('reveals the full multi-line command when expanded', () => { + it('still renders only the result when expanded', () => { const components = shellExecutionResultRenderer( { id: 'call_1', @@ -144,7 +147,7 @@ describe('ShellExecutionComponent', () => { }, { tool_call_id: 'call_1', - output: 'ok', + output: ['line1', 'line2', 'line3', 'line4', 'line5'].join('\n'), is_error: false, }, { expanded: true }, @@ -154,9 +157,9 @@ describe('ShellExecutionComponent', () => { .flatMap((c) => c.render(300)) .map(strip) .join('\n'); - expect(rendered).toContain(`$ echo ${'a'.repeat(200)}`); - expect(rendered).toContain('echo done'); - expect(rendered).toContain('ok'); + expect(rendered).not.toContain('$ echo'); + expect(rendered).toContain('line4'); + expect(rendered).toContain('line5'); }); }); }); diff --git a/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts b/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts new file mode 100644 index 00000000..510da06b --- /dev/null +++ b/apps/pythinker-code/test/tui/components/messages/shell-run.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { ShellRunComponent } from '#/tui/components/messages/shell-run'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('ShellRunComponent hardening', () => { + let component: ShellRunComponent | undefined; + + afterEach(() => { + // Always clear the 1s timer so it can't keep the test process alive or + // fire requestRender after the test ends. + component?.dispose(); + component = undefined; + }); + + function create(): ShellRunComponent { + component = new ShellRunComponent(() => {}); + return component; + } + + it('caps the running buffer and never throws on huge streaming output', () => { + const c = create(); + const chunk = 'x'.repeat(50_000); + expect(() => { + for (let i = 0; i < 20; i++) c.append(chunk); + c.render(100); + }).not.toThrow(); + }); + + it('finish switches to the final view and ignores later appends', () => { + const c = create(); + c.finish('final output', '', false); + c.append('should be ignored'); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('final output'); + expect(rendered).not.toContain('should be ignored'); + }); + + it('finishBackgrounded renders the background hint', () => { + const c = create(); + c.finishBackgrounded(); + const rendered = stripTheme(c.render(100).join('\n')); + expect(rendered).toContain('Moved to background.'); + }); + + it('append / finish are no-ops after dispose', () => { + const c = create(); + c.dispose(); + expect(() => { + c.append('late'); + c.finish('late', '', false); + c.finishBackgrounded(); + c.render(100); + }).not.toThrow(); + }); + + it('does not throw when the render callback throws', () => { + const c = new ShellRunComponent(() => { + throw new Error('render failed'); + }); + component = c; + expect(() => { + c.append('output'); + c.render(100); + }).not.toThrow(); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts index 4950addb..053b7ed7 100644 --- a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts @@ -14,7 +14,7 @@ describe('status panel report lines', () => { workDir: '/tmp/project', sessionId: 'ses-1', sessionTitle: 'Implement status', - thinkingLevel: 'high', + thinkingEffort: 'on', permissionMode: 'manual', planMode: false, contextUsage: 0.25, @@ -22,20 +22,17 @@ describe('status panel report lines', () => { maxContextTokens: 10000, availableModels: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 10000, displayName: 'Kimi K2', }, }, status: { model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, - dynamicWorkflowMode: false, - fastMode: true, - fastModeSupported: true, contextTokens: 3000, maxContextTokens: 12000, contextUsage: 0.25, @@ -44,34 +41,72 @@ describe('status panel report lines', () => { summary: null, limits: [ { - label: '5h limit', + window: { duration: 5, unit: 'hour' }, used: 8, limit: 100, - resetHint: 'resets in 1h', + resetAt: new Date(Date.now() + 3600_000).toISOString(), }, ], }, }).map(strip); const output = lines.join('\n'); - expect(output).toContain('>_ Pythinker (v1.2.3)'); + expect(output).toContain('>_ Pythinker Code (v1.2.3)'); expect(output).toContain('Model Kimi K2 (thinking high)'); expect(output).toContain('Directory /tmp/project'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); - expect(output).toContain('Fast mode on'); expect(output).toContain('Session ses-1'); expect(output).toContain('Title Implement status'); expect(output).toContain('Context window'); expect(output).toContain('25%'); expect(output).toContain('(2.9k / 11.7k)'); expect(output).toContain('Plan usage'); + expect(output).toContain('5h limit'); expect(output).toContain('8% used'); expect(output).not.toContain('Account'); expect(output).not.toContain('AGENTS.md'); expect(output).not.toContain('Runtime'); }); + it('formats extra usage section in status report', () => { + const lines = buildStatusReportLines({ + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: null, + thinkingEffort: 'off', + permissionMode: 'manual', + planMode: false, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + availableModels: {}, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 15000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('150.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + it('falls back to app state and shows status load errors as warnings', () => { const lines = buildStatusReportLines({ version: '1.2.3', @@ -79,7 +114,7 @@ describe('status panel report lines', () => { workDir: '/tmp/project', sessionId: '', sessionTitle: null, - thinkingLevel: 'off', + thinkingEffort: 'off', permissionMode: 'manual', planMode: false, contextUsage: 0, @@ -91,7 +126,6 @@ describe('status panel report lines', () => { const output = lines.join('\n'); expect(output).toContain('Model not set'); - expect(output).toContain('Fast mode unavailable'); expect(output).toContain('Session none'); expect(output).toContain('Warning No active session'); expect(output).toContain('No context window data available.'); diff --git a/apps/pythinker-code/test/tui/components/messages/step-summary.test.ts b/apps/pythinker-code/test/tui/components/messages/step-summary.test.ts new file mode 100644 index 00000000..85626c8a --- /dev/null +++ b/apps/pythinker-code/test/tui/components/messages/step-summary.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('StepSummaryComponent', () => { + it('renders nothing when empty', () => { + const component = new StepSummaryComponent(); + expect(component.isEmpty).toBe(true); + expect(component.render(80)).toEqual([]); + }); + + it('renders thinking and tool counts without a message part', () => { + const component = new StepSummaryComponent(); + component.addCounts(5, 50); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('thinking 5 times'); + expect(out).toContain('call 50 tools'); + expect(out).not.toContain('messages'); + }); + + it('renders folded assistant message counts and accumulates', () => { + const component = new StepSummaryComponent(); + component.addCounts(0, 0, 3); + component.addCounts(2, 4, 5); + const out = strip(component.render(80).join('\n')); + expect(component.isEmpty).toBe(false); + expect(out).toContain('thinking 2 times'); + expect(out).toContain('call 4 tools'); + expect(out).toContain('8 messages'); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts index df94678b..f6e38f31 100644 --- a/apps/pythinker-code/test/tui/components/messages/thinking.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/thinking.test.ts @@ -1,18 +1,9 @@ -import chalk from 'chalk'; -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@pymodel/pi-tui'; import { describe, expect, it, vi } from 'vitest'; import { ThinkingComponent } from '#/tui/components/messages/thinking'; -import { - BRAILLE_SPINNER_FRAMES, - BRAILLE_SPINNER_INTERVAL_MS, - formatThinkingSpinnerLabel, - getThinkingSpinnerLabel, - THINKING_SPINNER_LABEL_INTERVAL_MS, - THINKING_SPINNER_LABELS, -} from '#/tui/constant/rendering'; +import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { currentTheme, darkColors } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -20,43 +11,17 @@ function strip(text: string): string { const longThinking = ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'); -describe('getThinkingSpinnerLabel', () => { - it('rotates labels at the configured interval', () => { - expect(getThinkingSpinnerLabel(0)).toBe('thinking'); - expect(getThinkingSpinnerLabel(THINKING_SPINNER_LABEL_INTERVAL_MS - 1)).toBe('thinking'); - expect(getThinkingSpinnerLabel(THINKING_SPINNER_LABEL_INTERVAL_MS)).toBe('reasoning'); - expect( - getThinkingSpinnerLabel(THINKING_SPINNER_LABELS.length * THINKING_SPINNER_LABEL_INTERVAL_MS), - ).toBe('thinking'); - }); -}); - describe('ThinkingComponent', () => { it('shows only the live spinner header while collapsed', () => { const component = new ThinkingComponent('working it out', true, 'live'); const out = strip(component.render(80).join('\n')); - const label = formatThinkingSpinnerLabel(); - expect(out).toContain(`⠋ ${label}`); - expect(out).not.toContain(` ⠋ ${label}`); - expect(out).not.toContain(`${STATUS_BULLET}⠋`); + expect(out).toContain('⣷ thinking...'); + expect(out).not.toContain(' ⣷ thinking...'); + expect(out).not.toContain(`${STATUS_BULLET}⣷`); expect(out).not.toContain('working it out'); }); - it('uses the primary activity color while thinking is live', () => { - const previousLevel = chalk.level; - chalk.level = 3; - currentTheme.setPalette(darkColors); - const component = new ThinkingComponent('working it out', true, 'live'); - - try { - expect(component.render(80)[1]?.startsWith(currentTheme.fg('primary', '⠋ '))).toBe(true); - } finally { - component.dispose(); - chalk.level = previousLevel; - } - }); - it('keeps expanded live thinking height-limited to the tail', () => { const component = new ThinkingComponent(longThinking, true, 'live'); component.setExpanded(true); @@ -70,51 +35,24 @@ describe('ThinkingComponent', () => { expect(out).not.toContain('ctrl+o to expand'); }); - it('animates the live spinner shimmer and stops on finalize', () => { + it('refreshes the live indicator and stops on finalize', () => { vi.useFakeTimers(); - vi.setSystemTime(0); - const previousLevel = chalk.level; - chalk.level = 3; const requestRender = vi.fn(); const component = new ThinkingComponent('step', true, 'live', { requestRender, } as unknown as TUI); - try { - const firstHeader = component.render(80)[1]; - expect(strip(firstHeader ?? '')).toBe(`⠋ ${formatThinkingSpinnerLabel()}`); - - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); - expect(requestRender).toHaveBeenCalled(); - const secondHeader = component.render(80)[1]; - expect(strip(secondHeader ?? '')).toBe(`⠙ ${formatThinkingSpinnerLabel()}`); - - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * (BRAILLE_SPINNER_FRAMES.length - 1)); - const fullCycleHeader = component.render(80)[1]; - expect(fullCycleHeader).toBeDefined(); - expect(firstHeader).toBeDefined(); - expect(strip(fullCycleHeader as string)).toBe(strip(firstHeader as string)); - - const shimmerHeaders = [firstHeader, fullCycleHeader]; - for (let sample = 0; sample < 3; sample++) { - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * BRAILLE_SPINNER_FRAMES.length); - shimmerHeaders.push(component.render(80)[1]); - } - for (const header of shimmerHeaders) expect(header).toBeDefined(); - expect(shimmerHeaders.map((header) => strip(header as string))).toEqual( - shimmerHeaders.map(() => strip(firstHeader as string)), - ); - expect(new Set(shimmerHeaders).size).toBeGreaterThan(1); - - component.finalize(); - requestRender.mockClear(); - vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * 2); - expect(requestRender).not.toHaveBeenCalled(); - } finally { - component.dispose(); - chalk.level = previousLevel; - vi.useRealTimers(); - } + expect(strip(component.render(80).join('\n'))).toContain('⣷ thinking...'); + + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); + expect(requestRender).toHaveBeenCalled(); + expect(strip(component.render(80).join('\n'))).toContain(`${BRAILLE_SPINNER_FRAMES[1]} thinking...`); + + component.finalize(); + requestRender.mockClear(); + vi.advanceTimersByTime(160); + expect(requestRender).not.toHaveBeenCalled(); + vi.useRealTimers(); }); it('finalizes in place into nothing while collapsed', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts index e6052c2f..d1016b90 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-call.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth, type TUI } from '@earendil-works/pi-tui'; +import { visibleWidth, type TUI } from '@pymodel/pi-tui'; import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -49,35 +49,36 @@ describe('ToolCallComponent', () => { expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); }); - it('tints the tool card for pending, successful, and failed states', () => { + it('tints pending, successful, and failed tool cards', () => { const previousLevel = chalk.level; chalk.level = 3; const component = new ToolCallComponent( - { - id: 'call_tint', - name: 'Read', - args: { path: 'foo.ts' }, - }, + { id: 'call_tint', name: 'Read', args: { path: 'foo.ts' } }, undefined, ); try { const pending = component.render(40); - const pendingBody = pending.slice(1); expect(pending[0]).not.toContain('\u001B[48;2;29;33;41m'); - expect(pendingBody.length).toBeGreaterThan(0); - expect(pendingBody.every((line) => line.includes('\u001B[48;2;29;33;41m'))).toBe(true); + expect(pending.length).toBeGreaterThan(1); + expect(pending.slice(1).every((line) => line.includes('\u001B[48;2;29;33;41m'))).toBe( + true, + ); component.setResult({ tool_call_id: 'call_tint', output: 'content', is_error: false }); - const success = component.render(40); - const successBody = success.slice(1); - expect(successBody.length).toBeGreaterThan(0); - expect(successBody.every((line) => line.includes('\u001B[48;2;20;23;27m'))).toBe(true); + expect( + component + .render(40) + .slice(1) + .every((line) => line.includes('\u001B[48;2;20;23;27m')), + ).toBe(true); component.setResult({ tool_call_id: 'call_tint', output: 'failed', is_error: true }); - const error = component.render(40); - const errorBody = error.slice(1); - expect(errorBody.length).toBeGreaterThan(0); - expect(errorBody.every((line) => line.includes('\u001B[48;2;41;29;29m'))).toBe(true); + expect( + component + .render(40) + .slice(1) + .every((line) => line.includes('\u001B[48;2;41;29;29m')), + ).toBe(true); } finally { chalk.level = previousLevel; } @@ -101,175 +102,107 @@ describe('ToolCallComponent', () => { '\u001B[48;2;20;23;27m', '\u001B[48;2;41;29;29m', ]; - const lines = component.render(40); - expect(lines.length).toBeGreaterThan(0); expect( - lines.every((line) => backgrounds.every((code) => !line.includes(code))), + component + .render(40) + .every((line) => backgrounds.every((code) => !line.includes(code))), ).toBe(true); } finally { chalk.level = previousLevel; } }); - it('renders MCP resource tools with friendly labels, context, and counts', () => { - const list = new ToolCallComponent( - { - id: 'call_list_mcp_resources', - name: 'ListMcpResourcesTool', - args: { server: 'docs' }, - }, - { - tool_call_id: 'call_list_mcp_resources', - output: JSON.stringify([{ server: 'docs', uri: 'docs://guide', name: 'guide' }]), - is_error: false, - }, - ); - const read = new ToolCallComponent( - { - id: 'call_read_mcp_resource', - name: 'ReadMcpResourceTool', - args: { server: 'docs', uri: 'docs://guide' }, - }, - { - tool_call_id: 'call_read_mcp_resource', - output: JSON.stringify({ - contents: [{ uri: 'docs://guide', text: 'Guide' }], - }), - is_error: false, - }, - ); + describe('detach hint for long-running foreground Bash/Agent', () => { + it('shows the Ctrl+B hint after 10s for a running Bash call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_long', name: 'Bash', args: { command: 'sleep 30' } }, + undefined, + stubTui(30), + ); - expect(strip(list.render(100).join('\n'))).toContain( - 'Used List MCP resources (docs) · 1 resource', - ); - expect(strip(read.render(100).join('\n'))).toContain( - 'Used Read MCP resource (docs://guide) · 1 content', - ); - }); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); - it('keeps collapsed tool-call lines within very narrow widths', () => { - const component = new ToolCallComponent( - { - id: 'call_narrow_read', - name: 'Read', - args: { path: 'very/long/path/to/foo.ts' }, - }, - { - tool_call_id: 'call_narrow_read', - output: 'content', - is_error: false, - }, - ); + vi.advanceTimersByTime(10_000); + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); - for (const width of [1, 2, 4, 10, 39]) { - for (const line of component.render(width)) { - expect(visibleWidth(line)).toBeLessThanOrEqual(width); - } - } - }); + component.dispose(); + }); - it('keeps long tool headers on one terminal row', () => { - const component = new ToolCallComponent( - { - id: 'call_long_header', - name: 'Edit', - args: { - path: '/Users/example/projects/pythinker/packages/agent-core/src/tools/providers/local-fetch-url.ts', - }, - }, - undefined, - ); + it('shows the hint immediately for a running Agent call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_agent_long', name: 'Agent', args: { description: 'explore' } }, + undefined, + stubTui(30), + ); - const nonBlank = component - .render(48) - .map(strip) - .filter((line) => line.trim().length > 0); + // No timer advancement — Agents advertise Ctrl+B immediately. + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); - expect(nonBlank).toHaveLength(1); - expect(nonBlank[0]).toContain('Using Edit'); - }); + component.dispose(); + }); - it('renders tool command names with textStrong instead of primary', () => { - const previousLevel = chalk.level; - chalk.level = 3; - try { - const bash = new ToolCallComponent( - { - id: 'call_bash_header_color', - name: 'Bash', - args: { command: 'printf output' }, - }, + it('does not show the hint for non-detachable tools', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_read_long', name: 'Read', args: { path: 'foo.ts' } }, undefined, + stubTui(30), ); - const subagent = new ToolCallComponent( - { - id: 'call_subagent_tool_color', - name: 'Agent', - args: { description: 'inspect file' }, - }, + vi.advanceTimersByTime(15_000); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint when the result lands before 10s', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_short', name: 'Bash', args: { command: 'echo hi' } }, undefined, + stubTui(30), ); - subagent.onSubagentSpawned({ - agentId: 'sub_color', - agentName: 'explore', - runInBackground: false, - }); - subagent.appendSubToolCall({ - id: 'sub_color:read', - name: 'Read', - args: { path: 'foo.ts' }, - }); - const bashOut = bash.render(100).join('\n'); - expect(bashOut).toContain(chalk.hex(darkColors.textStrong).bold('Bash')); - expect(bashOut).not.toContain(chalk.hex(darkColors.primary).bold('Bash')); + vi.advanceTimersByTime(5_000); + component.setResult({ tool_call_id: 'call_bash_short', output: 'hi', is_error: false }); + vi.advanceTimersByTime(10_000); - const subagentOut = subagent.render(100).join('\n'); - expect(subagentOut).toContain(chalk.hex(darkColors.textStrong)('Read')); - expect(subagentOut).not.toContain(chalk.hex(darkColors.primary)('Read')); - expect(subagentOut).toContain(chalk.hex(darkColors.textStrong).bold('Explore Agent')); - expect(subagentOut).not.toContain(chalk.hex(darkColors.primary).bold('Explore Agent')); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); - subagent.onSubagentCompleted({ resultSummary: 'done' }); - subagent.setResult({ - tool_call_id: 'call_subagent_tool_color', - output: 'done', - is_error: false, - }); - const completedOut = subagent.render(100).join('\n'); - expect(completedOut).toContain(chalk.hex(darkColors.textStrong).bold('Explore Agent')); - expect(completedOut).not.toContain(chalk.hex(darkColors.success).bold('Explore Agent')); - expect(completedOut).toContain(chalk.hex(darkColors.success)('Completed')); - } finally { - chalk.level = previousLevel; - } + component.dispose(); + }); }); - it('keeps Edit diff rows structural instead of wrapping their content', () => { - const oldText = - '// Successful responses are cached per URL in-process with insertion-order eviction.'; - const newText = - '// Successful responses are cached per URL in-process with TTL + insertion-order eviction.'; + it('keeps collapsed tool-call lines within very narrow widths', () => { const component = new ToolCallComponent( { - id: 'call_long_edit', - name: 'Edit', - args: { - path: '/Users/example/projects/pythinker/packages/agent-core/src/tools/providers/local-fetch-url.ts', - old_string: oldText, - new_string: newText, - }, + id: 'call_narrow_read', + name: 'Read', + args: { path: 'very/long/path/to/foo.ts' }, + }, + { + tool_call_id: 'call_narrow_read', + output: 'content', + is_error: false, }, - undefined, ); - const lines = component.render(48); - - expect(lines).toHaveLength(5); - expect(lines.map(strip).some((line) => line.trimStart() === 'eviction.')).toBe(false); - for (const line of lines) { - expect(visibleWidth(line)).toBeLessThanOrEqual(48); + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } } }); @@ -316,62 +249,11 @@ describe('ToolCallComponent', () => { component.appendLiveOutput('line2\n'); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Using Bash'); + expect(out).toContain('Running a command'); expect(out).toContain('line1'); expect(out).toContain('line2'); }); - it('slow-pulses the running Bash bullet and keeps the completed bullet green', () => { - vi.useFakeTimers(); - const previousLevel = chalk.level; - chalk.level = 3; - const ui = { requestRender: vi.fn() }; - const component = new ToolCallComponent( - { - id: 'call_shell_pulse', - name: 'Bash', - args: { command: 'sleep 2' }, - }, - undefined, - ui as never, - ); - - try { - expect(strip(component.render(100).join('\n'))).toContain( - `${STATUS_BULLET}Using Bash`, - ); - - vi.advanceTimersByTime(800); - const hidden = strip(component.render(100).join('\n')); - expect(hidden).toContain(' Using Bash'); - expect(hidden).not.toContain(`${STATUS_BULLET}Using Bash`); - expect(ui.requestRender).toHaveBeenCalledOnce(); - - vi.advanceTimersByTime(800); - expect(strip(component.render(100).join('\n'))).toContain( - `${STATUS_BULLET}Using Bash`, - ); - - component.setResult({ - tool_call_id: 'call_shell_pulse', - output: 'done', - is_error: false, - }); - ui.requestRender.mockClear(); - vi.advanceTimersByTime(1_600); - - const completed = component.render(100).join('\n'); - expect(completed).toContain( - chalk.hex(darkColors.success)(STATUS_BULLET), - ); - expect(strip(completed)).toContain(`${STATUS_BULLET}Used Bash`); - expect(ui.requestRender).not.toHaveBeenCalled(); - } finally { - component.dispose(); - chalk.level = previousLevel; - } - }); - it('clears live Bash output when the final result arrives', () => { const component = new ToolCallComponent( { @@ -390,12 +272,81 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Bash'); + expect(out).toContain('Ran a command'); expect(out).toContain('final-only'); expect(out).not.toContain('streamed-only'); }); - it('hides tool output bodies that start with a <system tag', () => { + describe('Bash command preview', () => { + const longCommand = Array.from({ length: 15 }, (_, i) => `echo step${String(i + 1)}`).join( + '\n', + ); + + it('shows the truncated command while running and reveals the rest when expanded', () => { + const component = new ToolCallComponent( + { id: 'call_bash_running', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + const collapsed = strip(component.render(100).join('\n')); + expect(collapsed).toContain('Running a command'); + expect(collapsed).toContain('echo step1'); + expect(collapsed).toContain('echo step10'); + expect(collapsed).not.toContain('echo step11'); + + component.setExpanded(true); + + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview after the result lands to avoid a height collapse', () => { + const component = new ToolCallComponent( + { id: 'call_bash_done', name: 'Bash', args: { command: longCommand } }, + undefined, + ); + + // Sanity: while running, the in-flight preview shows the command. + expect(strip(component.render(100).join('\n'))).toContain('$ echo step1'); + + component.setResult({ tool_call_id: 'call_bash_done', output: 'done', is_error: false }); + + // Collapsed result view still shows the command preview (capped at + // COMMAND_PREVIEW_LINES) so a multi-line command with short output does + // not collapse the card. The command is owned by buildCallPreview, so it + // must appear exactly once — the result renderer no longer renders it. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ echo step1'); + expect(out).toContain('echo step10'); + expect(out).not.toContain('echo step11'); + expect(out).toContain('done'); + expect(out.split('$ echo step1').length - 1).toBe(1); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('echo step11'); + expect(expanded).toContain('echo step15'); + }); + + it('keeps the command preview when the command produces no output', () => { + const component = new ToolCallComponent( + { id: 'call_bash_empty', name: 'Bash', args: { command: 'mkdir -p a/b/c\necho done' } }, + { tool_call_id: 'call_bash_empty', output: '', is_error: false }, + ); + + // buildContent early-returns on empty output, but the command preview + // (owned by buildCallPreview) must still render so the card does not + // collapse to just the header. + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Ran a command'); + expect(out).toContain('$ mkdir -p a/b/c'); + expect(out).toContain('echo done'); + }); + }); + + it('hides tool output bodies that start with a <system-reminder tag', () => { const reminderOutput = '<system-reminder>\nThe task tools have not been used recently.\n</system-reminder>'; const component = new ToolCallComponent( @@ -412,7 +363,7 @@ describe('ToolCallComponent', () => { ); const collapsed = strip(component.render(100).join('\n')); - expect(collapsed).toContain(`${STATUS_BULLET}Used Bash`); + expect(collapsed).toContain(`${STATUS_BULLET}Ran a command`); expect(collapsed).not.toContain('system-reminder'); expect(collapsed).not.toContain('task tools'); @@ -422,7 +373,7 @@ describe('ToolCallComponent', () => { expect(expanded).not.toContain('task tools'); }); - it('hides <system-prefixed output even when the tool result is an error', () => { + it('hides <system-reminder-prefixed output even when the tool result is an error', () => { const component = new ToolCallComponent( { id: 'call_hidden_err', @@ -441,26 +392,49 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('do not show'); }); - it('renders DynamicWorkflow results as a one-line summary without raw XML', () => { + it('renders output that merely starts with a literal <system> tag', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so real output starting with the literal tag — + // a file that contains it, an MCP tool's text — must stay visible. + const component = new ToolCallComponent( + { + id: 'call_literal', + name: 'Bash', + args: { command: 'cat notes.txt' }, + }, + { + tool_call_id: 'call_literal', + output: '<system>literal text from a user file</system>\nsecond line', + is_error: false, + }, + ); + + component.setExpanded(true); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('second line'); + }); + + it('renders AgentDynamicWorkflow results as a one-line summary without raw XML', () => { const output = [ - '<dynamic_workflow_result>', + '<agent_dynamic_workflow_result>', '<summary>completed: 1, failed: 1, aborted: 1</summary>', '<subagent index="1" outcome="completed">Reviewed src/a.ts.</subagent>', '<subagent index="2" outcome="failed">Agent timed out.</subagent>', '<subagent index="3" outcome="aborted">User aborted.</subagent>', - '</dynamic_workflow_result>', + '</agent_dynamic_workflow_result>', ].join('\n'); const component = new ToolCallComponent( { - id: 'call_swarm', - name: 'DynamicWorkflow', + id: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', args: { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], }, }, { - tool_call_id: 'call_swarm', + tool_call_id: 'call_dynamic_workflow', output, is_error: false, }, @@ -468,21 +442,21 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); - expect(out).toContain('Dynamic Workflow: ✓ 1 completed · ✗ 1 failed · ⊘ 1 aborted'); - expect(out).not.toContain('<dynamic_workflow_result>'); + expect(out).toContain('Agent dynamic_workflow: ✓ 1 completed · ✗ 1 failed · ⊘ 1 aborted'); + expect(out).not.toContain('<agent_dynamic_workflow_result>'); expect(out).not.toContain('Reviewed src/a.ts.'); expect(out).not.toContain('Agent timed out.'); }); - it('keeps unstructured DynamicWorkflow output on the generic result path', () => { + it('renders an AgentDynamicWorkflow fallback summary when the result is not structured', () => { const component = new ToolCallComponent( { - id: 'call_swarm_failed', - name: 'DynamicWorkflow', + id: 'call_dynamic_workflow_failed', + name: 'AgentDynamicWorkflow', args: { description: 'Review changed files' }, }, { - tool_call_id: 'call_swarm_failed', + tool_call_id: 'call_dynamic_workflow_failed', output: 'provider request failed', is_error: true, }, @@ -490,34 +464,8 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); - expect(out).toContain('Used DynamicWorkflow'); - expect(out).toContain('provider request failed'); - expect(out).not.toContain('Dynamic Workflow:'); - }); - - it.each([ - ['AgentSwarm', '<agent_swarm_result><summary>completed: 1</summary></agent_swarm_result>'], - ['DynamicWorkflow', '<agent_swarm_result><summary>completed: 1</summary></agent_swarm_result>'], - ['DynamicWorkflow', 'agent_swarm: completed'], - ])('keeps %s and legacy workflow results generic', (name, output) => { - const component = new ToolCallComponent( - { - id: 'call_removed_swarm', - name, - args: { description: 'Review changed files' }, - }, - { - tool_call_id: 'call_removed_swarm', - output, - is_error: false, - }, - ); - - const out = strip(component.render(120).join('\n')); - - expect(out).toContain(`Used ${name}`); - expect(out).toContain(output); - expect(out).not.toContain('Dynamic Workflow:'); + expect(out).toContain('Agent dynamic_workflow: ✗ Failed.'); + expect(out).not.toContain('provider request failed'); }); it('still renders tool output when the body merely contains <system later on', () => { @@ -685,6 +633,34 @@ describe('ToolCallComponent', () => { expect(header).toContain('Current plan · Approved: Pragmatic refactor'); }); + it('header chips Auto-approved when ExitPlanMode was auto-approved without user review', () => { + const component = new ToolCallComponent( + { + id: 'call_exit_auto', + name: 'ExitPlanMode', + args: {}, + }, + { + tool_call_id: 'call_exit_auto', + output: + 'Exited plan mode. Plan mode deactivated. All tools are now available.\n' + + 'Note: this plan was auto-approved without user review — the user has NOT explicitly approved it.\n' + + 'Plan saved to: /tmp/plan.md\n\n' + + '## Plan (auto-approved, not user-reviewed):\n# Auto Plan\n\n1. Do the thing.', + is_error: false, + }, + ); + + const out = strip(component.render(100).join('\n')); + const header = out.split('\n')[1] ?? ''; + expect(header).toMatch(/Current plan · Auto-approved\s*$/); + // The plan body renders from the auto-approved marker; the engine-side + // note above the marker must not leak into the rendered plan box. + expect(out).toContain('Auto Plan'); + expect(out).toContain('1. Do the thing.'); + expect(out).not.toContain('Note: this plan was auto-approved'); + }); + it('renders Rejected in the plan box title and keeps revise feedback visible', () => { const component = new ToolCallComponent( { @@ -793,95 +769,12 @@ describe('ToolCallComponent', () => { ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('✓ Answered'); - expect(out).toContain('┌ Q Favorite editor?'); - expect(out).toContain('└ ✓ Vim'); - expect(out).not.toContain('Collected your answers'); + expect(out).toContain('Collected your answers'); + expect(out).toContain('Favorite editor?'); + expect(out).toContain('Vim'); expect(out).not.toContain('AskUserQuestion'); }); - it('renders multiple Markdown answers with hanging rails at narrow widths', () => { - const component = new ToolCallComponent( - { - id: 'call_question_markdown', - name: 'AskUserQuestion', - args: {}, - }, - { - tool_call_id: 'call_question_markdown', - output: JSON.stringify({ - answers: { - '**Which approach** should we use for the extended migration plan?': - 'Use **the safe path** with `checks` before release.', - 'Retry count?': 3, - 'Enabled flags?': ['alpha', 'beta'], - }, - }), - is_error: false, - }, - ); - - const lines = component.render(34); - const plainLines = lines.map((line) => strip(line).trimEnd()); - const out = plainLines.join('\n'); - expect(out).toContain('✓ Answered'); - expect([...out.matchAll(/┌ Q/g)]).toHaveLength(3); - expect([...out.matchAll(/└ ✓/g)]).toHaveLength(3); - expect(out).toContain('Which approach'); - expect(out).toContain('Use the safe path'); - expect(out).toContain('3'); - expect(out).toContain('["alpha","beta"]'); - expect(out).not.toContain('**'); - expect(out).not.toContain('`checks`'); - expect(plainLines.some((line) => line.startsWith(' │ '))).toBe(true); - expect(plainLines.some((line) => line.startsWith(' ') && line.includes('release.'))).toBe(true); - for (const line of lines) { - expect(visibleWidth(line)).toBeLessThanOrEqual(34); - } - }); - - it('keeps dismissal, error, and malformed AskUserQuestion results visible', () => { - const dismissed = new ToolCallComponent( - { id: 'call_question_dismissed', name: 'AskUserQuestion', args: {} }, - { - tool_call_id: 'call_question_dismissed', - output: JSON.stringify({ answers: {}, note: 'User dismissed the question.' }), - is_error: false, - }, - ); - const failed = new ToolCallComponent( - { id: 'call_question_failed', name: 'AskUserQuestion', args: {} }, - { - tool_call_id: 'call_question_failed', - output: 'Question service unavailable', - is_error: true, - }, - ); - const malformed = new ToolCallComponent( - { id: 'call_question_malformed', name: 'AskUserQuestion', args: {} }, - { - tool_call_id: 'call_question_malformed', - output: '{not valid JSON', - is_error: false, - }, - ); - - const dismissedOut = strip(dismissed.render(100).join('\n')); - expect(dismissedOut).toContain('⊘ Dismissed'); - expect(dismissedOut).not.toContain('✓ Answered'); - expect(dismissedOut).toContain('User dismissed the question.'); - expect(dismissedOut).not.toContain('┌ Q'); - - const failedOut = strip(failed.render(100).join('\n')); - expect(failedOut).toContain('Could not collect your input'); - expect(failedOut).toContain('Question service unavailable'); - expect(failedOut).not.toContain('✓ Answered'); - - const malformedOut = strip(malformed.render(100).join('\n')); - expect(malformedOut).toContain('✓ Answered'); - expect(malformedOut).toContain('{not valid JSON'); - }); - it('renders background AskUserQuestion as a started task', () => { const component = new ToolCallComponent( { @@ -903,7 +796,7 @@ describe('ToolCallComponent', () => { const out = strip(component.render(100).join('\n')); expect(out).toContain('Started background question'); expect(out).toContain('question-aaaaaaaa'); - expect(out).not.toContain('✓ Answered'); + expect(out).not.toContain('Collected your answers'); }); it('renders GetGoal as a goal check without raw JSON', () => { @@ -1101,9 +994,11 @@ describe('ToolCallComponent', () => { ); const out = strip(component.render(100).join('\n')); - expect(out).toContain('Used Read (apps/pythinker-code/src/main.ts)'); + const expectedReadPath = + process.platform === 'win32' ? 'apps\\pythinker-code\\src\\main.ts' : 'apps/pythinker-code/src/main.ts'; + expect(out).toContain(`Used Read (${expectedReadPath})`); expect(out).not.toContain('/tmp/proj-a/apps'); - expect(component.getReadSnapshot().filePath).toBe('apps/pythinker-code/src/main.ts'); + expect(component.getReadSnapshot().filePath).toBe(expectedReadPath); }); it('keeps Read paths outside the active workspace absolute', () => { @@ -1173,14 +1068,15 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (explore project xxx) · 1 tool · 10s'); expect(out).toContain('Using Read (apps/pythinker-code/src/tui/utils/background-agent-status.ts)'); + // Thinking and text are mutually exclusive in the active window: the most + // recently streamed (text) wins, so thinking is hidden entirely. expect(out).not.toContain('think1'); - expect(out).toContain('think2'); - expect(out).toContain('think3'); - expect(out).toContain('◌ think2'); + expect(out).not.toContain('think2'); + expect(out).not.toContain('think3'); expect(out).not.toContain('answer1'); - expect(out).not.toContain('answer2'); + expect(out).toContain('answer2'); expect(out).toContain('answer3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); vi.setSystemTime(22_000); component.onSubagentCompleted({ resultSummary: 'summary fallback' }); @@ -1194,55 +1090,85 @@ describe('ToolCallComponent', () => { out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Completed (explore project xxx) · 1 tool · 12s'); expect(out).not.toContain('think3'); - expect(out).toContain('└ answer3'); + expect(out).toContain('│ answer3'); expect(out).not.toContain('Used Agent'); expect(out).not.toContain('parent duplicate result'); expect(out).not.toContain('summary fallback'); }); - it('marks worktree-isolated subagents in the existing single-agent header', () => { + it('shows the bound model in the subagent header and group snapshot once reported', () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); const component = new ToolCallComponent( { - id: 'call_agent_worktree', + id: 'call_agent_model', name: 'Agent', - args: { description: 'implement safely', isolation: 'worktree' }, + args: { description: 'explore project' }, }, undefined, ); component.onSubagentSpawned({ - agentId: 'sub_worktree', - agentName: 'coder', + agentId: 'sub_model_1', + agentName: 'explore', runInBackground: false, }); - const out = strip(component.render(120).join('\n')); - expect(out).toContain('Coder Agent Queued (implement safely) · worktree · 0 tools'); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · 0 tools'); + expect(out).not.toContain('Kimi K2.5'); + + component.updateSubagentMetrics({ modelDisplay: 'Kimi K2.5' }); + + out = strip(component.render(120).join('\n')); + expect(out).toContain('Explore Agent Queued (explore project) · Kimi K2.5 · 0 tools'); + expect(component.getSubagentSnapshot().model).toBe('Kimi K2.5'); }); - it('uses a named teammate identity in the existing single-agent header', () => { + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); const component = new ToolCallComponent( { - id: 'call_agent_teammate', + id: 'call_agent_detach', name: 'Agent', - args: { - description: 'port runtime', - name: 'runtime', - team_name: 'porters', - }, + args: { description: 'long task' }, }, undefined, + stubTui(30), ); component.onSubagentSpawned({ - agentId: 'sub_runtime', - agentName: 'runtime', - runInBackground: true, + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + component.onSubagentStarted({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, }); - const out = strip(component.render(120).join('\n')); - expect(out).toContain('Runtime Agent Backgrounded (port runtime)'); + // Sanity: running before detach. + expect(strip(component.render(120).join('\n'))).toContain('Running'); + + component.markBackgrounded(); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + // The spawn-success ToolResult landing must NOT flip the card to Completed. + component.setResult({ + tool_call_id: 'call_agent_detach', + output: 'agent_id: sub_detach_1\nactual_subagent_type: explore\n', + is_error: false, + }); + out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + component.dispose(); }); - it('keeps the single subagent tool area to the latest four activities', () => { + it('summarizes subagent tools as a count plus the current tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1272,16 +1198,17 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s'); - expect(out).not.toContain('file1.ts'); - expect(out).toContain('Used Read (file2.ts)'); - expect(out).toContain('Used Read (file3.ts)'); - expect(out).toContain('Used Read (file4.ts)'); - expect(out).not.toContain('… Using Grep (auth)'); - expect(out).toContain('• Using Grep (auth)'); + // Only the current (most recent ongoing) tool appears in the summary line. expect(out).toContain('Using Grep (auth)'); + // No per-tool activity rows are rendered. + expect(out).not.toContain('file1.ts'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + expect(out).not.toContain('Used Read'); }); - it('keeps the single subagent tool window stable when older tools update', () => { + it('keeps the subagent tool summary pinned to the most recent tool', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1317,14 +1244,16 @@ describe('ToolCallComponent', () => { }); const out = strip(component.render(120).join('\n')); + // The updated/finished older tool must not surface in the summary. expect(out).not.toContain('file1-updated.ts'); - expect(out).toContain('Using Read (file2.ts)'); - expect(out).toContain('Using Read (file3.ts)'); - expect(out).toContain('Using Read (file4.ts)'); + expect(out).not.toContain('file2.ts'); + expect(out).not.toContain('file3.ts'); + expect(out).not.toContain('file4.ts'); + // Only the most recent ongoing tool is shown. expect(out).toContain('Using Read (file5.ts)'); }); - it('wraps single subagent thinking and output with hanging indentation', () => { + it('wraps the single subagent active window with a hanging gutter', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1340,46 +1269,17 @@ describe('ToolCallComponent', () => { agentName: 'explore', runInBackground: false, }); - component.appendSubagentText( - 'thinking words that should wrap with a clean hanging indent', - 'thinking', - ); component.appendSubagentText( 'output words that should also wrap with a clean hanging indent', 'text', ); - const lines = strip(component.render(34).join('\n')).split('\n'); - // Thinking is scrolled to its last two display rows, so the head of the - // wrapped paragraph drops and the ◌ marker hangs on the first kept row. - expect(lines.some((l) => l.includes('◌ wrap with a clean hanging'))).toBe(true); - expect(lines.join('\n')).not.toContain('thinking words that should'); - expect(lines).toContain(' indent '); - // Output keeps its full hanging-indent wrap (unchanged behavior). - expect(lines).toContain(' └ output words that should also '); - expect(lines).toContain(' wrap with a clean hanging '); - }); - - it('renders single subagent thinking as Markdown', () => { - const component = new ToolCallComponent( - { - id: 'call_agent_markdown_thinking', - name: 'Agent', - args: { description: 'inspect Markdown' }, - }, - undefined, - ); - component.onSubagentSpawned({ - agentId: 'sub_markdown_thinking', - agentName: 'explore', - runInBackground: false, - }); - component.appendSubagentText('Planning **the change** with `checks`', 'thinking'); - - const out = strip(component.render(80).join('\n')); - expect(out).toContain('Planning the change with checks'); - expect(out).not.toContain('**'); - expect(out).not.toContain('`checks`'); + const joined = strip(component.render(34).join('\n')); + // The two-row window drops the head of the wrapped paragraph. + expect(joined).not.toContain('output words that should'); + // Every kept row carries the `│` gutter as a hanging indent. + expect(joined).toContain('│ wrap with a clean hanging'); + expect(joined).toContain('│ indent'); }); it('scrolls single subagent thinking to the last two display rows', () => { @@ -1410,7 +1310,7 @@ describe('ToolCallComponent', () => { expect(lines.join('\n')).not.toContain('seg00'); }); - it('shows and truncates a single subagent Bash tool output', () => { + it('shows a two-row tail of an ongoing subagent Bash output', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1432,25 +1332,25 @@ describe('ToolCallComponent', () => { args: { command: 'ls -la' }, }); const output = Array.from({ length: 10 }, (_, i) => `bash-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_bash:cmd', output, is_error: false }); + component.appendSubToolLiveOutput('sub_bash:cmd', output); let out = strip(component.render(120).join('\n')); - expect(out).toContain('Used Bash (ls -la)'); - expect(out).toContain('bash-line-0'); - expect(out).toContain('bash-line-2'); - expect(out).not.toContain('bash-line-3'); - expect(out).toContain('... (7 more lines)'); - // Subagent output is fixed-truncated: no ctrl+o promise. + expect(out).toContain('Using Bash (ls -la)'); + // The active window keeps only the last two rows of live output. + expect(out).toContain('bash-line-8'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); + // No ctrl+o promise for the subagent window. expect(out).not.toContain('ctrl+o'); - // The global ctrl+o expand toggle must NOT expand subagent output. + // The global ctrl+o expand toggle must NOT expand the window. component.setExpanded(true); out = strip(component.render(120).join('\n')); - expect(out).not.toContain('bash-line-9'); - expect(out).toContain('... (7 more lines)'); + expect(out).toContain('bash-line-9'); + expect(out).not.toContain('bash-line-7'); }); - it('truncates unknown subagent tool output but leaves recognized tools as rows', () => { + it('shows live output for generic subagent tools but not for recognized ones', () => { vi.useFakeTimers(); vi.setSystemTime(0); const component = new ToolCallComponent( @@ -1466,6 +1366,7 @@ describe('ToolCallComponent', () => { agentName: 'explore', runInBackground: false, }); + // A finished recognized tool: its output body never reaches the window. component.appendSubToolCall({ id: 'sub_mixed:read', name: 'Read', @@ -1476,23 +1377,22 @@ describe('ToolCallComponent', () => { output: 'recognized-read-body\nhidden-read-line', is_error: false, }); + // An ongoing generic (MCP) tool: its live output is the active stream. component.appendSubToolCall({ id: 'sub_mixed:mcp', name: 'mcp__server__do', args: {}, }); const mcpOut = Array.from({ length: 5 }, (_, i) => `mcp-line-${String(i)}`).join('\n'); - component.finishSubToolCall({ tool_call_id: 'sub_mixed:mcp', output: mcpOut, is_error: false }); + component.appendSubToolLiveOutput('sub_mixed:mcp', mcpOut); const out = strip(component.render(120).join('\n')); - // Recognized tool: activity row only, no output body. - expect(out).toContain('Used Read (foo.ts)'); + // Recognized tool output never appears. expect(out).not.toContain('recognized-read-body'); - // Unknown/MCP tool: truncated output body, no ctrl+o promise. - expect(out).toContain('mcp-line-0'); - expect(out).toContain('mcp-line-2'); - expect(out).not.toContain('mcp-line-3'); - expect(out).toContain('... (2 more lines)'); + // Generic tool output shows as the two-row active window tail. + expect(out).toContain('mcp-line-3'); + expect(out).toContain('mcp-line-4'); + expect(out).not.toContain('mcp-line-2'); expect(out).not.toContain('ctrl+o'); }); @@ -1518,11 +1418,40 @@ describe('ToolCallComponent', () => { const out = strip(component.render(120).join('\n')); expect(out).toContain('Explore Agent Failed (check failure) · 0 tools · 3s'); - expect(out).toContain('└ subagent exceeded max_steps'); + expect(out).toContain('│ subagent exceeded max_steps'); expect(out).not.toContain('Using Agent'); expect(out).not.toContain('Used Agent'); }); + it('keeps the same card height between running and done', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_height', + name: 'Agent', + args: { description: 'height stable' }, + }, + undefined, + ); + component.onSubagentSpawned({ + agentId: 'sub_height', + agentName: 'explore', + runInBackground: false, + }); + component.appendSubToolCall({ id: 'sub_height:read', name: 'Read', args: { path: 'a.ts' } }); + component.appendSubagentText('short answer', 'text'); + + const runningLines = strip(component.render(120).join('\n')).split('\n').length; + + component.onSubagentCompleted({ resultSummary: 'short answer' }); + component.setResult({ tool_call_id: 'call_agent_height', output: 'done', is_error: false }); + + const doneLines = strip(component.render(120).join('\n')).split('\n').length; + + expect(doneLines).toBe(runningLines); + }); + describe('background agent terminal state vs spawn-success ToolResult', () => { // The Agent tool returns a "task spawned" result the moment a // run_in_background=true call lands. That result is not an error and its diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts index 2134d356..25ef9e49 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/chip.test.ts @@ -8,7 +8,7 @@ import { import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text.replaceAll(/\[[0-9;]*m/g, ''); } function call(name: string, args: Record<string, unknown> = {}): ToolCallBlockData { @@ -45,16 +45,6 @@ describe('chip registry', () => { ); }); - it('NotebookEdit chip shows one edited cell', () => { - expect( - chipFor( - 'NotebookEdit', - { notebook_path: 'a.ipynb', cell_id: 'cell-0', new_source: 'print("ok")' }, - result('Updated notebook cell cell-0'), - ), - ).toBe('1 cell'); - }); - it('Read chip shows line count', () => { expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo\n2\tbar\n3\tbaz'))).toBe('3 lines'); }); @@ -63,19 +53,6 @@ describe('chip registry', () => { expect(chipFor('Read', { path: 'a.ts' }, result('1\tfoo'))).toBe('1 line'); }); - it('Read chip shows notebook cell count for text and media results', () => { - const notebookText = - '<cell id="cell-0">a</cell id="cell-0">\n<cell id="cell-1">b</cell id="cell-1">'; - expect(chipFor('Read', { path: 'a.ipynb' }, result(notebookText))).toBe('2 cells'); - expect( - chipFor( - 'Read', - { path: 'a.ipynb' }, - result(JSON.stringify([{ type: 'text', text: notebookText }, { type: 'image_url' }])), - ), - ).toBe('2 cells'); - }); - it('Grep chip shows match count', () => { expect(chipFor('Grep', { pattern: 'foo' }, result('a.ts\nb.ts\nc.ts'))).toBe('3 matches'); }); @@ -99,99 +76,6 @@ describe('chip registry', () => { ); }); - it('MCP resource chips summarize list and read results', () => { - expect( - chipFor( - 'ListMcpResourcesTool', - {}, - result(JSON.stringify([{ uri: 'a' }, { uri: 'b' }])), - ), - ).toBe('2 resources'); - expect( - chipFor( - 'ReadMcpResourceTool', - {}, - result(JSON.stringify({ contents: [{ text: 'a' }] })), - ), - ).toBe('1 content'); - }); - - it('project task chips summarize task ids and list size', () => { - expect( - chipFor( - 'TaskCreate', - { subject: 'Port task graph', description: 'Match project task behavior' }, - result('Task #7 created successfully: Port task graph'), - ), - ).toBe('task #7'); - expect(chipFor('TaskGet', { taskId: '7' }, result('Task #7: Port task graph'))).toBe( - 'task #7', - ); - expect( - chipFor('TaskUpdate', { taskId: '7', status: 'completed' }, result('Task #7 updated: status')), - ).toBe('task #7'); - expect( - chipFor( - 'TaskList', - {}, - result('#1 [completed] Audit\n#7 [in_progress] Port task graph'), - ), - ).toBe('2 tasks'); - }); - - it('team chips summarize the team and message destination', () => { - expect( - chipFor( - 'TeamCreate', - { team_name: 'porters' }, - result('{"team_name":"porters","lead_agent_id":"main"}'), - ), - ).toBe('porters'); - expect( - chipFor( - 'SendMessage', - { to: 'runtime', summary: 'Start task', message: 'Claim task #1.' }, - result('{"success":true}'), - ), - ).toBe('@runtime'); - expect( - chipFor( - 'SendMessage', - { to: '*', summary: 'Status', message: 'Runtime is ready.' }, - result('{"success":true,"recipients":["runtime","tests"]}'), - ), - ).toBe('2 teammates'); - expect( - chipFor('TeamDelete', {}, result('{"success":true,"team_name":"porters"}')), - ).toBe('deleted'); - }); - - it('worktree chips summarize enter and exit outcomes', () => { - expect( - chipFor( - 'EnterWorktree', - { name: 'feature' }, - result( - 'Created worktree at /home/example/.pythinker-code/worktrees/example-feature on branch pythinker-worktree-feature.', - ), - ), - ).toBe('feature'); - expect( - chipFor( - 'ExitWorktree', - { action: 'keep' }, - result('Exited worktree. Work is preserved at /tmp/example-feature.'), - ), - ).toBe('kept'); - expect( - chipFor( - 'ExitWorktree', - { action: 'remove' }, - result('Exited and removed worktree at /tmp/example-feature.'), - ), - ).toBe('removed'); - }); - it('Think tool has no chip', () => { expect(pickChip('Think')).toBeUndefined(); }); diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts index 8a13aeca..0ddd2bad 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/media.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -10,7 +10,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text.replaceAll(/\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { @@ -38,7 +38,6 @@ function imageOutput(path: string, b64 = PNG_B64, mime = 'image/png'): string { { type: 'text', text: `<image path="${path}">` }, { type: 'image_url', imageUrl: { url: `data:${mime};base64,${b64}` } }, { type: 'text', text: '</image>' }, - { type: 'text', text: `Loaded image file "${path}" (${mime}, 70 bytes, original size 1x1px).` }, ]); } @@ -58,7 +57,6 @@ describe('parseReadMediaOutput', () => { expect(m?.path).toBe('/tmp/a.png'); expect(m?.mimeType).toBe('image/png'); expect(m?.bytes).toBeGreaterThan(0); - expect(m?.originalSize).toBe('1x1px'); }); it('extracts video kind and mime', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts index 70c13d35..ee45944a 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/registry.test.ts @@ -1,4 +1,4 @@ -import type { Component } from '@earendil-works/pi-tui'; +import type { Component } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { @@ -9,7 +9,7 @@ import { darkColors } from '#/tui/theme/colors'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text.replaceAll(/\[[0-9;]*m/g, ''); } function joinRender(components: Component[], width = 100): string { diff --git a/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts b/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts index bcab35a4..5f248ecf 100644 --- a/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/tool-renderers/truncated.test.ts @@ -1,4 +1,4 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { TruncatedOutputComponent } from '#/tui/components/messages/tool-renderers/truncated'; @@ -74,4 +74,17 @@ describe('TruncatedOutputComponent', () => { expect(visibleWidth(line)).toBeLessThanOrEqual(37); } }); + + it('renders output verbatim, including literal <system> text in file content', () => { + // Tool metadata no longer travels inside `output` (it rides the result's + // `note` side channel), so the renderer must not eat user data that + // merely contains the literal tag. + const component = new TruncatedOutputComponent( + '<system>literal text from a user file</system>\n<image path="/tmp/x.png">', + { expanded: true, isError: false }, + ); + const out = strip(component.render(80).join('\n')); + expect(out).toContain('<system>literal text from a user file</system>'); + expect(out).toContain('<image path="/tmp/x.png">'); + }); }); diff --git a/apps/pythinker-code/test/tui/components/messages/usage-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/usage-panel.test.ts index 2c9f6904..6b50e0dd 100644 --- a/apps/pythinker-code/test/tui/components/messages/usage-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/usage-panel.test.ts @@ -1,12 +1,7 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { - buildContextUsageReportLines, - buildCostReportLines, - buildUsageReportLines, - UsagePanelComponent, -} from '#/tui/components/messages/usage-panel'; +import { visibleWidth } from '@pymodel/pi-tui'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { buildUsageReportLines, UsagePanelComponent } from '#/tui/components/messages/usage-panel'; import { currentTheme, darkColors, lightColors } from '#/tui/theme'; afterEach(() => { @@ -18,98 +13,230 @@ function strip(text: string): string { } describe('UsagePanelComponent', () => { - it('formats the model-visible context breakdown and active tools', () => { - const lines = buildContextUsageReportLines({ - model: 'mock-model', - estimatedTokens: 2_500, - maxTokens: 10_000, - percentage: 25, - messageCount: 4, - categories: [ - { name: 'System prompt', tokens: 1_000, percentage: 10 }, - { name: 'Tools', tokens: 500, percentage: 5 }, - { name: 'User messages', tokens: 250, percentage: 2.5 }, - { name: 'Free space', tokens: 7_500, percentage: 75 }, - ], - tools: [ - { name: 'Read', source: 'builtin', tokens: 300 }, - { name: 'mcp__docs__search', source: 'mcp', tokens: 200 }, - ], + it('formats session, context, and managed usage sections', () => { + // Freeze the clock so the resetAt fixture is an exact hour out — with a + // live clock the elapsed milliseconds floor the diff down to 59m. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-28T00:00:00Z')); + try { + const lines = buildUsageReportLines({ + sessionUsage: { + byModel: { + pythinker: { + inputOther: 1000, + inputCacheRead: 500, + inputCacheCreation: 500, + output: 250, + }, + }, + }, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + managedUsage: { + summary: { + name: 'daily', + used: 20, + limit: 100, + resetAt: new Date(Date.now() + 3600_000).toISOString(), + }, + limits: [], + }, + }).map(strip); + + expect(lines).toContain('Session usage'); + expect(lines).toContain(' pythinker input 2k output 250 total 2.2k'); + expect(lines).toContain('Context window'); + expect(lines.join('\n')).toContain('25%'); + expect(lines).toContain('Plan usage'); + expect(lines.join('\n')).toContain('daily'); + expect(lines.join('\n')).toContain('20% used'); + expect(lines.join('\n')).toContain('resets in 1h'); + } finally { + vi.useRealTimers(); + } + }); + + it('derives plan usage labels from the window and falls back to name / Limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: { window: { duration: 1, unit: 'week' }, used: 1, limit: 10 }, + limits: [ + { window: { duration: 5, unit: 'hour' }, used: 2, limit: 10 }, + { name: 'Custom cap', used: 3, limit: 10 }, + { used: 4, limit: 10 }, + ], + }, }).map(strip); - expect(lines[0]).toBe('mock-model 2.4k / 9.8k tokens (25%)'); - expect(lines).toContain('Estimated usage by category'); - expect(lines.join('\n')).toContain('System prompt'); - expect(lines.join('\n')).toContain('Read'); - expect(lines.join('\n')).toContain('mcp__docs__search'); + const output = lines.join('\n'); + expect(output).toContain('Weekly limit'); + expect(output).toContain('5h limit'); + expect(output).toContain('Custom cap'); + expect(output).toContain('Limit'); }); - it('formats session spend and current model token rates', () => { - const text = buildCostReportLines({ - model: 'priced-model', - totalCostUsd: 0.125, - modelCostRates: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, + it('shows "reset" when the reset timestamp is already in the past', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [ + { + name: 'daily', + used: 1, + limit: 10, + resetAt: new Date(Date.now() - 60_000).toISOString(), + }, + ], }, - }) - .map(strip) - .join('\n'); - - expect(text).toContain('Session spend $0.125'); - expect(text).toContain('Current model priced-model'); - expect(text).toContain('Rates per 1M tokens'); - expect(text).toContain('$3 / 1M tokens'); - expect(text).toContain('$15 / 1M tokens'); - expect(text).toContain('$0.3 / 1M tokens'); - expect(text).toContain('$3.75 / 1M tokens'); + }).map(strip); + + expect(lines.join('\n')).toContain('reset'); + expect(lines.join('\n')).not.toContain('resets in'); }); - it('reports unavailable spend and pricing without inventing zero values', () => { - const text = buildCostReportLines({ model: 'unpriced-model' }) - .map(strip) - .join('\n'); + it('formats extra usage with a monthly limit', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'USD', + }, + }, + }).map(strip); - expect(text).toContain('Session spend unavailable'); - expect(text).toContain('Pricing unavailable for this model.'); - expect(text).not.toContain('$0'); + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + // bar row contains block glyphs but no percentage text + expect(output).toContain('░'); }); - it('formats session, context, and managed usage sections', () => { + it('formats extra usage without a monthly limit and omits the progress bar', () => { const lines = buildUsageReportLines({ - sessionUsage: { - byModel: { - pythinker: { - inputOther: 1000, - inputCacheRead: 500, - inputCacheCreation: 500, - output: 250, - }, + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, + limits: [], + extraUsage: { + balanceCents: 18208, + totalCents: 40000, + monthlyChargeLimitEnabled: false, + monthlyChargeLimitCents: 0, + monthlyUsedCents: 21792, + currency: 'CNY', }, - } as never, - contextUsage: 0.25, - contextTokens: 2500, - maxContextTokens: 10000, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(lines).toContain('Extra Usage'); + expect(output).toContain('Balance'); + expect(output).toContain('¥182.08'); + expect(output).toContain('Used this month'); + expect(output).toContain('¥217.92'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('Unlimited'); + expect(output).not.toContain('░'); + expect(output).not.toContain('█'); + }); + + it('omits the extra usage section when extraUsage is omitted or null', () => { + for (const extraUsage of [undefined, null]) { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { summary: null, limits: [], extraUsage }, + }).map(strip); + + expect(lines).not.toContain('Extra Usage'); + } + }); + + it('formats extra usage with CNY currency', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, managedUsage: { - summary: { - label: 'daily', - used: 20, - limit: 100, - resetHint: 'resets tomorrow', + summary: null, + limits: [], + extraUsage: { + balanceCents: 10000, + totalCents: 20000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 20000, + monthlyUsedCents: 5000, + currency: 'CNY', }, + }, + }).map(strip); + + const output = lines.join('\n'); + expect(output).toContain('Balance'); + expect(output).toContain('100.00'); + expect(output).toContain('Used this month'); + expect(output).toContain('50.00'); + expect(output).toContain('Monthly limit'); + expect(output).toContain('200.00'); + }); + + it('aligns the currency symbol and decimal point across extra usage rows', () => { + const lines = buildUsageReportLines({ + sessionUsage: { byModel: {} }, + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + managedUsage: { + summary: null, limits: [], + extraUsage: { + balanceCents: 15901, + totalCents: 300000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 300000, + monthlyUsedCents: 24099, + currency: 'CNY', + }, }, }).map(strip); - expect(lines).toContain('Session usage'); - expect(lines).toContain(' pythinker input 2k output 250 total 2.2k'); - expect(lines).toContain('Context window'); - expect(lines.join('\n')).toContain('25%'); - expect(lines).toContain('Plan usage'); - expect(lines.join('\n')).toContain('20% used'); - expect(lines.join('\n')).toContain('resets tomorrow'); + const extraRows = lines.filter((line) => line.includes('¥')); + expect(extraRows).toHaveLength(3); + // The currency symbol stays in one column... + expect(new Set(extraRows.map((line) => line.indexOf('¥'))).size).toBe(1); + // ...and the right-aligned numeric parts end in the same column, so the + // decimal points line up across rows. + expect(new Set(extraRows.map((line) => line.length)).size).toBe(1); }); it('wraps preformatted usage lines in a bordered panel', () => { diff --git a/apps/pythinker-code/test/tui/components/messages/user-message.test.ts b/apps/pythinker-code/test/tui/components/messages/user-message.test.ts index b908768c..14b90a86 100644 --- a/apps/pythinker-code/test/tui/components/messages/user-message.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/user-message.test.ts @@ -1,16 +1,23 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { describe, expect, it } from 'vitest'; +import { resetCapabilitiesCache, setCapabilities, visibleWidth } from '@pymodel/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { UserMessageComponent } from '#/tui/components/messages/user-message'; -import { darkColors } from '#/tui/theme/colors'; +import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('UserMessageComponent', () => { + afterEach(() => { + resetCapabilitiesCache(); + }); + it('renders video placeholders as plain text, not inline image escapes', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent( 'please inspect [video #1 sample.mov]', [], @@ -24,6 +31,8 @@ describe('UserMessageComponent', () => { }); it('keeps user lines within very narrow widths', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent('please inspect the attached output', []); for (const width of [1, 2, 4, 10, 39]) { @@ -33,19 +42,80 @@ describe('UserMessageComponent', () => { } }); - it('renders user rows with strong neutral text on the highlighted surface', () => { - const previousLevel = chalk.level; - chalk.level = 3; + it('does not truncate inline image escape sequences', () => { + setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); - try { - const component = new UserMessageComponent('please inspect the attached output', []); - const out = component.render(60).join('\n'); + // Minimal 2000x1302 PNG bytes so the inline Kitty sequence is long enough + // to exceed a typical terminal width if treated as visible text. + const pngSignature = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdrLength = new Uint8Array([0x00, 0x00, 0x00, 0x0d]); + const ihdrType = new Uint8Array([0x49, 0x48, 0x44, 0x52]); + const widthBytes = new Uint8Array([ + (2000 >> 24) & 0xff, + (2000 >> 16) & 0xff, + (2000 >> 8) & 0xff, + 2000 & 0xff, + ]); + const heightBytes = new Uint8Array([ + (1302 >> 24) & 0xff, + (1302 >> 16) & 0xff, + (1302 >> 8) & 0xff, + 1302 & 0xff, + ]); + const rest = new Uint8Array([0x08, 0x02, 0x00, 0x00, 0x00]); + const bytes = new Uint8Array([ + ...pngSignature, + ...ihdrLength, + ...ihdrType, + ...widthBytes, + ...heightBytes, + ...rest, + ]); - expect(out).toContain(chalk.hex(darkColors.textStrong)('please inspect the attached output')); - expect(out).toContain(`\u001B[48;2;28;34;56m`); - expect(out).not.toContain(chalk.hex(darkColors.roleUser)('please inspect the attached output')); - } finally { - chalk.level = previousLevel; - } + const attachment: ImageAttachment = { + id: 1, + kind: 'image', + bytes, + mime: 'image/png', + width: 2000, + height: 1302, + placeholder: '[image #1 (2000×1302)]', + }; + + const component = new UserMessageComponent('', [attachment]); + const lines = component.render(80); + + const imageLine = lines.find((l) => l.includes('\u001B_G')); + expect(imageLine).toBeDefined(); + expect(imageLine).not.toContain('\u001B[0m'); + expect(imageLine).not.toContain('…'); + expect(imageLine).toContain('\u001B\\'); // intact Kitty terminator + }); + + it('omits the sparkles bullet when an empty bullet is provided', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + + const withBullet = stripAnsi(new UserMessageComponent('hello', []).render(80).join('\n')); + expect(withBullet).toContain('✨'); + expect(withBullet).toContain('hello'); + + const lines = new UserMessageComponent('$ ls', [], '').render(80).map(stripAnsi); + const contentLine = lines.find((l) => l.includes('$ ls')); + expect(contentLine).toBeDefined(); + expect(stripAnsi(lines.join('\n'))).not.toContain('✨'); + // The `$` sits at the leading column where the bullet used to be. + expect(contentLine?.startsWith('$ ls')).toBe(true); + }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent('hello', []); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); }); }); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts index b8363f41..101cd191 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - reduceFooterState, - selectStatusBarExtras, -} from '#/tui/runtime/footer/footer-model'; import type { AppState } from '#/tui/types'; +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, -thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -28,47 +28,40 @@ thinkingLevel: 'off', version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, ...overrides, } as AppState; } -function backgroundExtras(bashTasks: number, agentTasks: number): string { - const state = reduceFooterState(createFooterState(), { - type: 'background-counts.updated', - counts: { bashTasks, agentTasks }, - }); - return selectStatusBarExtras(state, Date.now(), DEFAULT_STATUS_LINE_CONFIG).join(' '); -} - describe('FooterComponent — background task / agent badges', () => { it('omits both badges when counts are 0', () => { const footer = new FooterComponent(baseState()); - expect(footer.actionItems()).toEqual([]); - expect(backgroundExtras(0, 0)).toBe('▱▱▱▱▱▱▱▱ 0%'); + const [line1] = footer.render(120); + expect(line1).toBeDefined(); + expect(strip(line1!)).not.toMatch(/tasks? running/); + expect(strip(line1!)).not.toMatch(/agents? running/); }); it('renders the task badge alone when only bash tasks are running', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); - const out = backgroundExtras(1, 0); - expect(out).toMatch(/\[1 task running\]/u); - expect(out).not.toMatch(/agents? running/u); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[1 task running\]/); + expect(out).not.toMatch(/agents? running/); }); it('renders the agent badge alone when only agent tasks are running', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); - const out = backgroundExtras(0, 1); - expect(out).toMatch(/\[1 agent running\]/u); - expect(out).not.toMatch(/tasks? running/u); + const out = strip(footer.render(120)[0]!); + expect(out).toMatch(/\[1 agent running\]/); + expect(out).not.toMatch(/tasks? running/); }); it('renders both badges side by side when both are non-zero', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 3 }); - const out = backgroundExtras(2, 3); + const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[2 tasks running\]/); expect(out).toMatch(/\[3 agents running\]/); // Task badge appears before agent badge in the line. @@ -78,7 +71,7 @@ describe('FooterComponent — background task / agent badges', () => { it('pluralizes correctly across both badges', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - const out = backgroundExtras(1, 1); + const out = strip(footer.render(120)[0]!); expect(out).toMatch(/\[1 task running\]/); expect(out).toMatch(/\[1 agent running\]/); }); @@ -86,31 +79,28 @@ describe('FooterComponent — background task / agent badges', () => { it('updates badges live via setBackgroundCounts', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 2, agentTasks: 1 }); - expect(footer.actionItems().map((item) => item.id)).toEqual(['shell-tasks', 'agents']); + expect(strip(footer.render(120)[0]!)).toMatch(/\[2 tasks running\]/); footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); - expect(footer.actionItems()).toEqual([]); - }); - - it('clears selection when the selected task badge disappears', () => { - const footer = new FooterComponent(baseState()); - footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - footer.selectFirst(); - expect(footer.selectedActionId()).toBe('shell-tasks'); - - footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 1 }); - - expect(footer.selectedActionId()).toBeNull(); + const after = strip(footer.render(120)[0]!); + expect(after).not.toMatch(/tasks? running/); + expect(after).not.toMatch(/agents? running/); }); it('clamps negative counts to 0', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: -5, agentTasks: -2 }); - expect(footer.actionItems()).toEqual([]); + const out = strip(footer.render(120)[0]!); + expect(out).not.toMatch(/tasks? running/); + expect(out).not.toMatch(/agents? running/); }); - it('does not render status badges in the footer at any width', () => { + it('drops the badges when terminal is too narrow to fit them', () => { const footer = new FooterComponent(baseState()); footer.setBackgroundCounts({ bashTasks: 4, agentTasks: 3 }); - expect(footer.render(20)).toEqual([]); + // Extremely narrow width: footer primary content fills the line, so leftLine wins. + const [line1] = footer.render(20); + expect(line1).toBeDefined(); + expect(strip(line1!)).not.toMatch(/\[4 tasks running\]/); + expect(strip(line1!)).not.toMatch(/\[3 agents running\]/); }); }); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts index 6ad5c017..100f5f6a 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-context.test.ts @@ -1,16 +1,7 @@ import { describe, it, expect } from 'vitest'; import chalk from 'chalk'; -import { - FooterComponent, - footerStatusFromAppState, - formatFooterGitBadge, -} from '#/tui/components/chrome/footer'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - selectStatusBarExtras, -} from '#/tui/runtime/footer/footer-model'; +import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -31,10 +22,11 @@ function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, - thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -46,64 +38,96 @@ function baseState(overrides: Partial<AppState> = {}): AppState { version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, ...overrides, } as AppState; } -function statusExtras(state: AppState): string { - return selectStatusBarExtras( - createFooterState(footerStatusFromAppState(state, null)), - Date.now(), - state.statusLine, - ).join(''); -} - -describe('FooterComponent — quiet context status', () => { +describe('FooterComponent — context NaN resilience', () => { it('NaN usage → renders 0% (never literal "NaN%")', () => { - const out = statusExtras(baseState({ contextUsage: Number.NaN })); + const fc = new FooterComponent(baseState({ contextUsage: Number.NaN })); + const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); - expect(out).toContain('▱▱▱▱▱▱▱▱ 0%'); + expect(out).toMatch(/context: 0%/); }); it('undefined-ish (coerced) usage → renders 0%', () => { - const out = statusExtras( + const fc = new FooterComponent( baseState({ contextUsage: undefined as unknown as number }), ); + const out = strip(fc.render(120).join('')); expect(out).not.toMatch(/NaN/); - expect(out).toContain('▱▱▱▱▱▱▱▱ 0%'); + expect(out).toMatch(/context: 0%/); }); it('clamps ratios above 1.0 → renders 100%', () => { - const out = statusExtras(baseState({ contextUsage: 1.5 })); - expect(out).toContain('▰▰▰▰▰▰▰▰ 100%'); + const fc = new FooterComponent(baseState({ contextUsage: 1.5 })); + const out = strip(fc.render(120).join('')); + expect(out).toMatch(/context: 100%/); + }); + + it('ratio 0.427 → renders 43% (ceiled whole percent)', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.427 })); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 43%/); }); - it('ratio 0.427 → renders 43%', () => { - const out = statusExtras(baseState({ contextUsage: 0.427 })); - expect(out).toContain('▰▰▰▱▱▱▱▱ 43%'); + it('tiny non-zero usage → renders 1% (ceil floor)', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.0004 })); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 1%/); + }); + + it('valid tokens/maxTokens → percent from tokens, counts in 1024 units', () => { + const fc = new FooterComponent( + baseState({ + contextUsage: 0.427, + contextTokens: 430_080, + maxContextTokens: 1_048_576, + }), + ); + const out = strip(fc.render(200).join('')); + expect(out).toMatch(/context: 42% \(420k\/1M\)/); }); - it('tokens provided but max=0 → falls back to contextUsage without division-by-zero artefacts', () => { - const out = statusExtras( + it('tokens provided but max=0 → falls back to percent-only, no division-by-zero artefact', () => { + const fc = new FooterComponent( baseState({ contextUsage: 0, contextTokens: 500, maxContextTokens: 0 }), ); + const out = strip(fc.render(200).join('')); expect(out).not.toMatch(/Infinity|NaN/); - expect(out).toMatch(/[▰▱]{8} \d+%/u); + expect(out).toMatch(/context: 0%/); // With maxTokens=0, token-count annotation is suppressed. - expect(out).not.toMatch(/500\//); + expect(out).not.toMatch(/\(500\//); + }); + + it('setState updates visible model and context values', () => { + const footer = new FooterComponent(baseState({ model: 'k2', contextUsage: 0 })); + + footer.setState(baseState({ model: 'kimi-k2-5', contextUsage: 0.5 })); + + const out = strip(footer.render(200).join('')); + expect(out).toContain('kimi-k2-5'); + expect(out).not.toContain(' k2 '); + expect(out).toMatch(/context: 50%/); + }); + + it('shows "thinking" label when thinking is enabled, hides it when disabled', () => { + const on = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'on' })); + const off = new FooterComponent(baseState({ model: 'k2', thinkingEffort: 'off' })); + + expect(strip(on.render(120)[0]!)).toContain('thinking'); + expect(strip(off.render(120)[0]!)).not.toContain('thinking'); }); - it('renders transient hints without the suppressed status row', () => { + it('renders transient hints on the context line', () => { const footer = new FooterComponent(baseState()); footer.setTransientHint('Press Ctrl-C again to exit'); - const output = strip(footer.render(120).join('\n')); - expect(output).toContain('Press Ctrl-C again to exit'); - expect(output).not.toContain('▱▱▱▱▱▱▱▱ 0%'); - expect(output).not.toContain('shift+tab: plan mode'); + const [, line2] = footer.render(120); + expect(strip(line2 ?? '')).toContain('Press Ctrl-C again to exit'); + expect(strip(line2 ?? '')).toContain('context: 0%'); }); it('highlights the pull request badge separately from git status text', () => { @@ -139,3 +163,47 @@ describe('FooterComponent — quiet context status', () => { } }); }); + +describe('buildWeightedTips — weighted rotation', () => { + it('repeats higher-priority tips more often (length = sum of weights)', () => { + const seq = buildWeightedTips([ + { text: 'a' }, // weight 1 (default) + { text: 'b', priority: 3 }, + { text: 'c', priority: 2 }, + ]); + + const count = (t: string) => seq.filter((x) => x.text === t).length; + expect(seq).toHaveLength(6); + expect(count('a')).toBe(1); + expect(count('b')).toBe(3); + expect(count('c')).toBe(2); + expect(count('b')).toBeGreaterThan(count('a')); + }); + + it('keeps duplicates spread out — no tip sits next to itself', () => { + const seq = buildWeightedTips([ + { text: 'a' }, + { text: 'b', priority: 3 }, + { text: 'c', priority: 2 }, + ]); + + for (let i = 1; i < seq.length; i++) { + expect(seq[i]!.text).not.toBe(seq[i - 1]!.text); + } + }); + + it('preserves array order when all weights are the default (1)', () => { + const seq = buildWeightedTips([{ text: 'x' }, { text: 'y' }, { text: 'z' }]); + expect(seq.map((t) => t.text)).toEqual(['x', 'y', 'z']); + }); + + it('clamps non-positive / fractional priorities to a weight of at least 1', () => { + const seq = buildWeightedTips([ + { text: 'a', priority: 0 }, + { text: 'b', priority: -5 }, + { text: 'c', priority: 1.9 }, + ]); + expect(seq).toHaveLength(3); + expect(seq.map((t) => t.text).toSorted()).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts index 834a4797..a6535225 100644 --- a/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -1,23 +1,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { FooterComponent, footerStatusFromAppState } from '#/tui/components/chrome/footer'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - reduceFooterState, - selectStatusBarExtras, -} from '#/tui/runtime/footer/footer-model'; +import { FooterComponent } from '#/tui/components/chrome/footer'; import type { GoalSnapshot } from '@pymodel/pythinker-code-sdk'; import type { AppState } from '#/tui/types'; +const ANSI_SGR = /\[[0-9;]*m/g; +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + function baseState(overrides: Partial<AppState> = {}): AppState { return { model: 'k2', workDir: '/tmp/proj', + additionalDirs: [], sessionId: 'sess_1', permissionMode: 'manual', planMode: false, -thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 200_000, @@ -29,7 +29,6 @@ thinkingLevel: 'off', version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, ...overrides, } as AppState; @@ -52,42 +51,19 @@ function goal(overrides: Partial<GoalSnapshot> = {}): GoalSnapshot { } as GoalSnapshot; } -function goalExtras( - state: AppState, - observedAtMs = Date.now(), - clockMs = Date.now(), -): string { - const snapshot = state.goal; - const footerState = reduceFooterState( - createFooterState(footerStatusFromAppState(state, null)), - { - type: 'goal.updated', - goal: - snapshot === null || snapshot === undefined - ? null - : { - status: snapshot.status, - turnsUsed: snapshot.turnsUsed, - turnBudget: snapshot.budget.turnBudget, - wallClockMs: snapshot.wallClockMs, - observedAtMs, - }, - }, - ); - return selectStatusBarExtras(footerState, clockMs, state.statusLine).join(' '); -} - describe('FooterComponent — goal badge', () => { afterEach(() => { vi.useRealTimers(); }); it('omits the badge when there is no goal', () => { - expect(goalExtras(baseState({ goal: null }))).not.toMatch(/goal/); + const footer = new FooterComponent(baseState({ goal: null })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('shows status, elapsed, and a raw turn count for an unbounded active goal', () => { - const out = goalExtras(baseState({ goal: goal() })); + const footer = new FooterComponent(baseState({ goal: goal() })); + const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('active'); expect(out).toContain('4m'); @@ -100,10 +76,13 @@ describe('FooterComponent — goal badge', () => { vi.useFakeTimers(); vi.setSystemTime(0); - const state = baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }); - expect(goalExtras(state, 0, 0)).toContain('0s'); + const footer = new FooterComponent( + baseState({ goal: goal({ wallClockMs: 0, turnsUsed: 0 }) }), + ); + + expect(strip(footer.render(160)[0]!)).toContain('0s'); vi.setSystemTime(2_500); - expect(goalExtras(state, 0, 2_500)).toContain('3s'); + expect(strip(footer.render(160)[0]!)).toContain('3s'); }); it('requests a repaint while an active goal timer is visible', () => { @@ -117,38 +96,32 @@ describe('FooterComponent — goal badge', () => { }); it('shows used/limit turns only when a turn budget is set', () => { - const out = goalExtras( + const footer = new FooterComponent( baseState({ goal: goal({ budget: { turnBudget: 20, tokenBudget: null, wallClockBudgetMs: null } } as Partial<GoalSnapshot>) }), ); - expect(out).toContain('7/20 turns'); + expect(strip(footer.render(160)[0]!)).toContain('7/20 turns'); }); it('shows a paused badge', () => { - expect(goalExtras(baseState({ goal: goal({ status: 'paused' }) }))).toContain('paused'); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'paused' }) })); + expect(strip(footer.render(160)[0]!)).toContain('paused'); }); it('shows a blocked badge (resumable, still present)', () => { - const out = goalExtras(baseState({ goal: goal({ status: 'blocked' }) })); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'blocked' }) })); + const out = strip(footer.render(160)[0]!); expect(out).toContain('[goal'); expect(out).toContain('blocked'); }); it('hides the badge for a completed goal', () => { - expect(goalExtras(baseState({ goal: goal({ status: 'complete' }) }))).not.toMatch(/goal/); - }); - - it('clears selection when the selected goal disappears', () => { - const footer = new FooterComponent(baseState({ goal: goal() })); - footer.selectFirst(); - expect(footer.selectedActionId()).toBe('goal'); - - footer.setState(baseState({ goal: null })); - - expect(footer.selectedActionId()).toBeNull(); + const footer = new FooterComponent(baseState({ goal: goal({ status: 'complete' }) })); + expect(strip(footer.render(160)[0]!)).not.toContain('[goal'); }); it('singularizes a single turn', () => { - const out = goalExtras(baseState({ goal: goal({ turnsUsed: 1 }) })); + const footer = new FooterComponent(baseState({ goal: goal({ turnsUsed: 1 }) })); + const out = strip(footer.render(160)[0]!); expect(out).toContain('1 turn'); expect(out).not.toContain('1 turns'); }); diff --git a/apps/pythinker-code/test/tui/components/panels/help-panel.test.ts b/apps/pythinker-code/test/tui/components/panels/help-panel.test.ts index 703fe1ed..46b1bace 100644 --- a/apps/pythinker-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/help-panel.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import type { PythinkerSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; -import { parseKeybindingBlocks } from '#/tui/keybindings'; function cmd(name: string, description: string, aliases: string[] = []): PythinkerSlashCommand { return { @@ -25,7 +24,7 @@ describe('HelpPanelComponent', () => { const out = strip(panel.render(80).join('\n')); expect(out).toMatch(/help/); expect(out).toMatch(/Keyboard shortcuts/); - expect(out).toMatch(/Shift-Tab \/ Ctrl-T\s+Cycle thinking effort \(see \/plan for plan mode\)/); + expect(out).toMatch(/Shift-Tab/); expect(out).toMatch(/Ctrl-O/); expect(out).toMatch(/Shift-Enter \/ Ctrl-J/); expect(out).toMatch(/Slash commands/); @@ -64,27 +63,6 @@ describe('HelpPanelComponent', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - it('uses a remapped dismissal binding and renders its effective shortcut', () => { - const onClose = vi.fn(); - const panel = new HelpPanelComponent({ - commands: [], - onClose, - }); - panel.setKeybindings( - parseKeybindingBlocks([ - { context: 'Help', bindings: { escape: null, 'alt+h': 'help:dismiss' } }, - ]), - ); - - panel.handleInput('\u001B'); - expect(onClose).not.toHaveBeenCalled(); - panel.handleInput('\u001Bh'); - expect(onClose).toHaveBeenCalledTimes(1); - const header = strip(panel.render(80).join('\n')).split('\n')[1]; - expect(header).toContain('alt+h'); - expect(header).not.toContain('Esc'); - }); - it('q / Enter also close the panel', () => { const onClose = vi.fn(); const panel = new HelpPanelComponent({ diff --git a/apps/pythinker-code/test/tui/components/panels/plan-box.test.ts b/apps/pythinker-code/test/tui/components/panels/plan-box.test.ts index 9d6e3d12..05dbb323 100644 --- a/apps/pythinker-code/test/tui/components/panels/plan-box.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/plan-box.test.ts @@ -1,9 +1,11 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; +import { pathToFileURL } from 'node:url'; + +import { visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { PlanBoxComponent } from '#/tui/components/messages/plan-box'; import { darkColors } from '#/tui/theme/colors'; -import { createPythinkerMarkdownTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -16,7 +18,7 @@ function strip(text: string): string { .replaceAll(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, 'g'), ''); } -const theme = createPythinkerMarkdownTheme(); +const theme = createMarkdownTheme(); describe('PlanBoxComponent', () => { it('falls back to bare " plan " title when no path is provided', () => { @@ -70,7 +72,7 @@ describe('PlanBoxComponent', () => { it('wraps the basename in an OSC 8 hyperlink targeting file://', () => { const box = new PlanBoxComponent('# Hello', theme, darkColors.success, '/tmp/plan.md'); const top = box.render(60)[0]!; - expect(top).toContain(`${ESC}]8;;file:///tmp/plan.md${BEL}plan.md${ESC}]8;;${BEL}`); + expect(top).toContain(`${ESC}]8;;${pathToFileURL('/tmp/plan.md').href}${BEL}plan.md${ESC}]8;;${BEL}`); // After stripping OSC + CSI, visible width must respect the requested render width. expect(strip(top).length).toBeLessThanOrEqual(60); }); diff --git a/apps/pythinker-code/test/tui/components/panels/todo-panel.test.ts b/apps/pythinker-code/test/tui/components/panels/todo-panel.test.ts index 6b6ab073..ee6dcbb1 100644 --- a/apps/pythinker-code/test/tui/components/panels/todo-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/panels/todo-panel.test.ts @@ -1,13 +1,11 @@ -import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; import { describe, it, expect } from 'vitest'; import { TodoPanelComponent, + formatHiddenCounts, selectVisibleTodos, type TodoItem, } from '#/tui/components/chrome/todo-panel'; -import { darkColors } from '#/tui/theme'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -20,51 +18,19 @@ describe('TodoPanelComponent', () => { expect(panel.isEmpty()).toBe(true); }); - it('renders a muted completion summary and a success-colored active marker', () => { - const previousLevel = chalk.level; - chalk.level = 3; - try { - const panel = new TodoPanelComponent(); - panel.setTodos([ - { title: 'Investigate parser', status: 'done' }, - { title: 'Add tests', status: 'in_progress' }, - { title: 'Open PR', status: 'pending' }, - ]); - const rendered = panel.render(80); - const lines = rendered.map(strip); - const joined = lines.join('\n'); - - expect(lines[1]).toBe(' Todo · 1/3 done'); - expect(rendered[1]).toContain(chalk.hex(darkColors.textMuted)(' · 1/3 done')); - expect(rendered.find((line) => strip(line).includes('Add tests'))) - .toContain(chalk.hex(darkColors.success).bold('●')); - expect(joined).toMatch(/✓ Investigate parser/); - expect(joined).toMatch(/● Add tests/); - expect(joined).toMatch(/○ Open PR/); - } finally { - chalk.level = previousLevel; - } - }); - - it('renders the active form only while a todo is in progress', () => { + it('renders a Todo header + one row per entry', () => { const panel = new TodoPanelComponent(); panel.setTodos([ - { - title: 'Run focused tests', - activeForm: 'Running focused tests', - status: 'in_progress', - }, - { - title: 'Update release notes', - activeForm: 'Updating release notes', - status: 'pending', - }, + { title: 'Investigate parser', status: 'done' }, + { title: 'Add tests', status: 'in_progress' }, + { title: 'Open PR', status: 'pending' }, ]); - - const out = strip(panel.render(80).join('\n')); - expect(out).toContain('Running focused tests'); - expect(out).toContain('Update release notes'); - expect(out).not.toContain('Updating release notes'); + const lines = panel.render(80).map(strip); + const joined = lines.join('\n'); + expect(joined).toMatch(/Todo/); + expect(joined).toMatch(/✓ Investigate parser/); + expect(joined).toMatch(/● Add tests/); + expect(joined).toMatch(/○ Open PR/); }); it('setTodos replaces the list (not appends)', () => { @@ -109,48 +75,123 @@ describe('TodoPanelComponent', () => { expect(out).not.toMatch(/\+\d+ more/); }); - it('reports all todos done in the header', () => { + it('appends "+N more" footer when count > 5', () => { const panel = new TodoPanelComponent(); panel.setTodos([ - { title: 'a', status: 'done' }, - { title: 'b', status: 'done' }, - { title: 'c', status: 'done' }, + { title: 't0', status: 'done' }, + { title: 't1', status: 'in_progress' }, + { title: 't2', status: 'pending' }, + { title: 't3', status: 'pending' }, + { title: 't4', status: 'pending' }, + { title: 't5', status: 'pending' }, + { title: 't6', status: 'pending' }, ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+2 more/); + }); - expect(strip(panel.render(80)[1] ?? '')).toBe(' Todo · 3/3 done'); + const many = (n: number): TodoItem[] => + Array.from({ length: n }, (_, i) => ({ title: `t${i}`, status: 'pending' as const })); + + it('hasOverflow() is false when count <= 5 and true when count > 5', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(5)); + expect(panel.hasOverflow()).toBe(false); + panel.setTodos(many(6)); + expect(panel.hasOverflow()).toBe(true); + }); + + it('collapsed footer advertises "ctrl+t to expand"', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+2 more/); + expect(out).toMatch(/ctrl\+t to expand/); }); - it('uses the full list for overflow progress and appends the hidden count', () => { + it('collapsed footer shows hidden status distribution', () => { const panel = new TodoPanelComponent(); panel.setTodos([ - { title: 't0', status: 'done' }, - { title: 't1', status: 'done' }, - { title: 't2', status: 'done' }, - { title: 't3', status: 'in_progress' }, - { title: 't4', status: 'pending' }, - { title: 't5', status: 'pending' }, - { title: 't6', status: 'pending' }, + ...Array.from({ length: 6 }, (_, i) => ({ + title: `ip${i}`, + status: 'in_progress' as const, + })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ...Array.from({ length: 3 }, (_, i) => ({ title: `p${i}`, status: 'pending' as const })), ]); const out = strip(panel.render(80).join('\n')); - expect(out).toContain('Todo · 3/7 done'); - expect(out).toMatch(/\+2 more/); + expect(out).toMatch(/\+7 more \(3 done · 1 in progress · 3 pending\)/); + expect(out).toMatch(/ctrl\+t to expand/); + }); + + it('collapsed footer omits zero-count statuses', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/\+3 more \(3 done\)/); + expect(out).not.toMatch(/0 in progress/); + expect(out).not.toMatch(/0 pending/); + }); + + it('expanded footer does not include status distribution', () => { + const panel = new TodoPanelComponent(); + panel.setTodos( + Array.from({ length: 8 }, (_, i) => ({ title: `d${i}`, status: 'done' as const })), + ); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/all 8 items · ctrl\+t to collapse/); + expect(out).not.toMatch(/\d+ done ·/); + }); + + it('renders every todo with a collapse hint when expanded', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/t0/); + expect(out).toMatch(/t6/); + expect(out).not.toMatch(/\+\d+ more/); + expect(out).toMatch(/ctrl\+t to collapse/); }); - it('safely truncates the header and rows at narrow widths', () => { + it('toggleExpanded() flips between collapsed and expanded', () => { const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/ctrl\+t to collapse/); + panel.toggleExpanded(); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); + }); + + it('setTodos() keeps the expanded state across list updates', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); panel.setTodos([ - { title: 'Investigate the parser', status: 'done' }, - { title: 'Add focused regression tests', status: 'done' }, - { title: 'Open the pull request', status: 'pending' }, + { title: 'u0', status: 'pending' }, + { title: 'u1', status: 'pending' }, + { title: 'u2', status: 'pending' }, + { title: 'u3', status: 'pending' }, + { title: 'u4', status: 'pending' }, + { title: 'u5', status: 'pending' }, + { title: 'u6', status: 'pending' }, ]); + const out = strip(panel.render(80).join('\n')); + expect(out).toMatch(/u6/); + expect(out).toMatch(/ctrl\+t to collapse/); + }); - for (const width of [1, 4, 8, 12, 18]) { - const lines = panel.render(width); - expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true); - expect(strip(lines[1] ?? '')).toBe( - strip(truncateToWidth(' Todo · 2/3 done', width)), - ); - } + it('clear() resets the expanded state', () => { + const panel = new TodoPanelComponent(); + panel.setTodos(many(7)); + panel.setExpanded(true); + panel.clear(); + panel.setTodos(many(7)); + expect(strip(panel.render(80).join('\n'))).toMatch(/\+2 more/); }); }); @@ -302,4 +343,41 @@ describe('selectVisibleTodos', () => { expect(rows.map((r) => r.title)).toEqual(['ip0', 'ip1', 'ip2', 'ip3', 'ip4']); expect(hidden).toBe(2); }); + + it('returns hiddenCounts reflecting the hidden items', () => { + const todos: TodoItem[] = [ + ...Array.from({ length: 6 }, (_, i) => T(`ip${i}`, 'in_progress')), + ...Array.from({ length: 3 }, (_, i) => T(`d${i}`, 'done')), + ...Array.from({ length: 3 }, (_, i) => T(`p${i}`, 'pending')), + ]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(7); + expect(hiddenCounts).toEqual({ done: 3, in_progress: 1, pending: 3 }); + }); + + it('returns zero hiddenCounts when count <= 5', () => { + const todos: TodoItem[] = [T('a', 'done'), T('b', 'in_progress'), T('c', 'pending')]; + const { hidden, hiddenCounts } = selectVisibleTodos(todos); + expect(hidden).toBe(0); + expect(hiddenCounts).toEqual({ done: 0, in_progress: 0, pending: 0 }); + }); +}); + +describe('formatHiddenCounts', () => { + it('formats all three statuses in done / in progress / pending order', () => { + expect(formatHiddenCounts({ done: 2, in_progress: 1, pending: 3 })).toBe( + '2 done · 1 in progress · 3 pending', + ); + }); + + it('omits zero-count statuses', () => { + expect(formatHiddenCounts({ done: 5, in_progress: 0, pending: 0 })).toBe('5 done'); + expect(formatHiddenCounts({ done: 0, in_progress: 2, pending: 3 })).toBe( + '2 in progress · 3 pending', + ); + }); + + it('returns empty string when all counts are zero', () => { + expect(formatHiddenCounts({ done: 0, in_progress: 0, pending: 0 })).toBe(''); + }); }); diff --git a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts index 383c4369..146b2c52 100644 --- a/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts +++ b/apps/pythinker-code/test/tui/components/panes/activity-pane.test.ts @@ -1,29 +1,113 @@ -import { Text } from '@earendil-works/pi-tui'; +import { Text, visibleWidth } from '@pymodel/pi-tui'; import { describe, expect, it } from 'vitest'; import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +function createMockSpinner(initialText = 'working') { + const spinner = new Text(initialText, 0, 0); + let tip = ''; + let availableWidth = 0; + const update = () => { + const fullText = initialText + tip; + spinner.setText(availableWidth > 0 && visibleWidth(fullText) > availableWidth ? initialText : fullText); + }; + return { + spinner: Object.assign(spinner, { + setTip(value: string) { + tip = value; + update(); + }, + setAvailableWidth(width: number) { + availableWidth = width; + update(); + }, + }) as unknown as import('#/tui/components/chrome/moon-loader').MoonLoader, + getTip: () => tip, + }; +} + describe('ActivityPaneComponent', () => { it('renders waiting loader after a spacer', () => { + const { spinner } = createMockSpinner('loading'); const component = new ActivityPaneComponent({ mode: 'waiting', - spinner: new Text('loading', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'loading']); }); it('renders composing spinner after a spacer', () => { + const { spinner } = createMockSpinner('working'); const component = new ActivityPaneComponent({ mode: 'composing', - spinner: new Text('working', 0, 0) as never, + spinner, }); expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); }); + it('renders the detail line under the waiting spinner', () => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode: 'waiting', + spinner, + detail: '429 · rate limited', + }); + + const lines = component + .render(80) + .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '').trimEnd()); + expect(lines).toEqual(['', 'working', ' 429 · rate limited']); + }); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'renders %s spinner with tip after a spacer', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual([ + '', + 'working · Tip: ctrl+s: steer mid-turn', + ]); + }, + ); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'does not render a tip for %s when none is provided', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + }); + + expect(component.render(80).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); + it('renders nothing for hidden and thinking modes', () => { expect(new ActivityPaneComponent({ mode: 'hidden' }).render(80)).toEqual([]); expect(new ActivityPaneComponent({ mode: 'thinking' }).render(80)).toEqual([]); }); + + it.each(['waiting', 'tool', 'composing'] as const)( + 'hides the tip for %s when the terminal is too narrow', + (mode) => { + const { spinner } = createMockSpinner('working'); + const component = new ActivityPaneComponent({ + mode, + spinner, + tip: 'ctrl+s: steer mid-turn', + }); + + // Width 8 is exactly the width of "working" (no spinner frame in the mock). + expect(component.render(8).map((line) => line.trimEnd())).toEqual(['', 'working']); + }, + ); }); diff --git a/apps/pythinker-code/test/tui/components/panes/queue-pane.test.ts b/apps/pythinker-code/test/tui/components/panes/queue-pane.test.ts index ca276a13..0f9a0117 100644 --- a/apps/pythinker-code/test/tui/components/panes/queue-pane.test.ts +++ b/apps/pythinker-code/test/tui/components/panes/queue-pane.test.ts @@ -82,4 +82,41 @@ describe('QueuePaneComponent', () => { expect(messageLine).toContain('line one line two line three'); expect(messageLine).not.toContain('\n'); }); + + it('renders bash queued items with a $ prompt to distinguish them from text', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: false, + messages: [{ text: 'ls -la', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('❯ $ ls -la'); + }); + + it('omits the steer hint when every queued item is a bash command', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).not.toContain('ctrl-s to steer immediately'); + expect(output).toContain('will send after current task'); + }); + + it('keeps the steer hint when at least one queued item is steerable', () => { + const component = new QueuePaneComponent({ + isCompacting: false, + isStreaming: true, + canSteerImmediately: true, + messages: [{ text: 'ls', mode: 'bash' }, { text: 'focus on tests' }], + }); + + const output = stripAnsi(component.render(120).join('\n')); + expect(output).toContain('ctrl-s to steer immediately'); + }); }); diff --git a/apps/pythinker-code/test/tui/components/status-bar.test.ts b/apps/pythinker-code/test/tui/components/status-bar.test.ts deleted file mode 100644 index 3a6b462e..00000000 --- a/apps/pythinker-code/test/tui/components/status-bar.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { visibleWidth } from '@earendil-works/pi-tui'; -import chalk from 'chalk'; -import { describe, expect, it } from 'vitest'; - -import { - StatusBarComponent, - type StatusBarStatus, -} from '#/tui/components/chrome/status-bar'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { currentTheme, darkColors } from '#/tui/theme'; - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); -} - -function renderRow(component: StatusBarComponent, width: number): string { - const rows = component.render(width); - expect(rows.length).toBeGreaterThan(0); - return rows[0] as string; -} - -function status(overrides: Partial<StatusBarStatus> = {}): StatusBarStatus { - return { - model: 'Model Alpha', - thinkingLevel: 'high', - cwd: '/Users/test/project', - homeDir: '/Users/test', - permissionMode: 'auto', - planMode: true, - fastMode: false, - dynamicWorkflowMode: true, - tokenSpeed: null, - tokenSpeedEstimated: false, - extras: [], - sessionKey: 'session-alpha', - statusLine: DEFAULT_STATUS_LINE_CONFIG, - ...overrides, - }; -} - -describe('StatusBarComponent', () => { - it('renders one line with the model and effort label', () => { - const component = new StatusBarComponent(); - component.update(status()); - - const lines = component.render(80); - - expect(lines).toHaveLength(1); - expect(stripAnsi(lines[0] as string)).toContain('Model Alpha · high'); - }); - - it('omits the effort suffix when thinking is off', () => { - const component = new StatusBarComponent(); - component.update(status({ thinkingLevel: 'off' })); - - const line = stripAnsi(renderRow(component, 80)); - - expect(line).toContain('Model Alpha'); - expect(line).not.toContain('· off'); - }); - - it('hides the model chip when showModel is false', () => { - const component = new StatusBarComponent(); - component.update(status({ - statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModel: false }, - })); - expect(stripAnsi(renderRow(component, 80))).not.toContain('Model Alpha'); - }); - - it('hides the modes chip when showModes is false', () => { - const component = new StatusBarComponent(); - component.update(status({ - statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showModes: false }, - })); - const line = stripAnsi(renderRow(component, 80)); - - expect(line).not.toContain('plan'); - expect(line).not.toContain('auto'); - expect(line).not.toContain('workflow'); - }); - - it('hides only the effort suffix when showEffort is false', () => { - const component = new StatusBarComponent(); - component.update(status({ - statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showEffort: false }, - })); - - const line = stripAnsi(renderRow(component, 80)); - - expect(line).toContain('Model Alpha'); - expect(line).not.toContain('· high'); - }); - - it('renders yolo with the error colour', () => { - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = new StatusBarComponent(); - component.update(status({ permissionMode: 'yolo' })); - - expect(renderRow(component, 80)).toContain(chalk.hex(darkColors.error)('yolo')); - } finally { - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('paints the update extra in warning and leaves other extras dim', () => { - const previousLevel = chalk.level; - const previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - - try { - const component = new StatusBarComponent(); - component.update( - status({ - extras: ['6% · 55.6k/1M', '↑ v0.18.0 restart to apply'], - updateExtra: '↑ v0.18.0 restart to apply', - }), - ); - - const line = renderRow(component, 160); - - expect(line).toContain(chalk.hex(darkColors.warning)('↑ v0.18.0 restart to apply')); - expect(line).toContain(chalk.hex(darkColors.textDim)('6% · 55.6k/1M')); - } finally { - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - } - }); - - it('drops the gap, modes, and cwd in that order as width shrinks', () => { - const component = new StatusBarComponent(); - component.update(status()); - - const wide = stripAnsi(renderRow(component, 60)); - const withoutGap = stripAnsi(renderRow(component, 53)); - const withoutModes = stripAnsi(renderRow(component, 45)); - const modelOnly = stripAnsi(renderRow(component, 25)); - - expect(wide).toContain('─'); - expect(withoutGap).not.toContain('─'); - expect(withoutGap).toContain('workflow'); - expect(withoutGap).toContain('~/project'); - expect(withoutModes).not.toContain('workflow'); - expect(withoutModes).toContain('~/project'); - expect(modelOnly).toContain('Model Alpha'); - expect(modelOnly).not.toContain('~/project'); - }); - - it('never renders past the available width', () => { - const component = new StatusBarComponent(); - component.update(status()); - - for (const width of [0, 1, 10, 25, 45, 53, 80]) { - const lines = component.render(width); - expect(lines).toHaveLength(1); - expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); - } - }); - - it('renders fast mode', () => { - const previousLevel = chalk.level; - chalk.level = 3; - const component = new StatusBarComponent(); - component.update(status({ fastMode: true })); - - try { - expect(stripAnsi(renderRow(component, 80))).toContain('↯ fast'); - } finally { - chalk.level = previousLevel; - } - }); - - it('renders token speed at the end of the model chip', () => { - const component = new StatusBarComponent(); - component.update(status({ - fastMode: true, - tokenSpeed: 75.7, - tokenSpeedEstimated: true, - })); - - const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); - - expect(modelChip).toBe('Model Alpha · high · ↯ fast · ~75.7 t/s'); - }); - - it('hides token speed when showTokenSpeed is false', () => { - const component = new StatusBarComponent(); - component.update(status({ - tokenSpeed: 75.7, - statusLine: { ...DEFAULT_STATUS_LINE_CONFIG, showTokenSpeed: false }, - })); - - const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); - - expect(modelChip).toBe('Model Alpha · high'); - }); - - it('does not leave a separator when token speed is null', () => { - const component = new StatusBarComponent(); - component.update(status({ fastMode: true, tokenSpeed: null })); - - const modelChip = stripAnsi(renderRow(component, 120)).split(' ')[0]?.trim(); - - expect(modelChip).toBe('Model Alpha · high · ↯ fast'); - }); - - it('renders extras in order between modes and cwd', () => { - const component = new StatusBarComponent(); - component.update(status({ extras: ['6% · 55.6k/1M', 'main ± [PR#1]'] })); - - const line = stripAnsi(renderRow(component, 160)); - - expect(line.indexOf('workflow')).toBeLessThan(line.indexOf('6% · 55.6k/1M')); - expect(line.indexOf('6% · 55.6k/1M')).toBeLessThan(line.indexOf('main ± [PR#1]')); - expect(line.indexOf('main ± [PR#1]')).toBeLessThan(line.indexOf('~/project')); - }); - - it('drops extras from the tail before the modes and cwd chips', () => { - const component = new StatusBarComponent(); - component.update(status({ extras: ['first', 'second'] })); - - const line = stripAnsi(renderRow(component, 62)); - - expect(line).toContain('Model Alpha'); - expect(line).toContain('first'); - expect(line).not.toContain('second'); - expect(line).toContain('workflow'); - expect(line).toContain('~/project'); - }); - - it.each([ - [ - '/Users/test/Projects/active/pythinker-code-tsc/apps/pythinker-code', - '/Users/test', - '…/apps/pythinker-code', - ], - ['/Users/test/Projects/active', '/Users/test', '~/Projects/active'], - ['/Users/test', '/Users/test', '~'], - ['/a/b/c/d', '/Users/test', '…/c/d'], - ])('shortens cwd %s to %s', (cwd, homeDir, expected) => { - const component = new StatusBarComponent(); - component.update(status({ cwd, homeDir })); - - expect(stripAnsi(renderRow(component, 240))).toContain(expected); - }); -}); diff --git a/apps/pythinker-code/test/tui/config.test.ts b/apps/pythinker-code/test/tui/config.test.ts index 451d2f6e..2024e3ab 100644 --- a/apps/pythinker-code/test/tui/config.test.ts +++ b/apps/pythinker-code/test/tui/config.test.ts @@ -5,7 +5,6 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { - DEFAULT_STATUS_LINE_CONFIG, DEFAULT_TUI_CONFIG, INVALID_TUI_CONFIG_MESSAGE, loadTuiConfig, @@ -35,33 +34,18 @@ describe('TUI config', () => { const text = readFileSync(filePath, 'utf-8'); expect(text).toContain('Client preferences for pythinker-code.'); expect(text).toContain('theme = "auto"'); - expect(text).toContain('copy_full_response = false'); + expect(text).toContain('cache_expiry_hint = true'); expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); expect(text).toContain('[notifications]'); expect(text).toContain('enabled = true'); expect(text).toContain('notification_condition = "unfocused"'); - expect(text.match(/^\[status_line\]$/gmu)).toHaveLength(1); - for (const key of [ - 'show_model', - 'show_effort', - 'show_token_speed', - 'show_context_bar', - 'show_git', - 'show_modes', - 'show_elapsed', - 'show_goal', - 'show_background_tasks', - ]) { - expect(text).toContain(`${key} = true`); - } }); it('parses valid TOML', () => { const config = parseTuiConfig(` theme = "light" -layout = "inline" [editor] command = "code --wait" @@ -76,15 +60,44 @@ auto_install = false expect(config).toEqual({ theme: 'light', - layout: 'inline', - copyFullResponse: false, + renderLatex: true, + disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, + statusLine: { items: null, command: null }, }); }); + it('parses disable_paste_burst', () => { + const config = parseTuiConfig(` +theme = "dark" +disable_paste_burst = true +`); + + expect(config.disablePasteBurst).toBe(true); + }); + + it('defaults render_latex to true and parses false', () => { + expect(parseTuiConfig('').renderLatex).toBe(true); + + const config = parseTuiConfig(` +render_latex = false +`); + + expect(config.renderLatex).toBe(false); + }); + + it('parses cache_expiry_hint', () => { + const config = parseTuiConfig(` +theme = "dark" +cache_expiry_hint = false +`); + + expect(config.cacheExpiryHint).toBe(false); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -93,12 +106,13 @@ command = " " expect(config).toEqual({ theme: 'auto', - layout: 'fixed', - copyFullResponse: false, + renderLatex: true, + disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, + statusLine: { items: null, command: null }, }); }); @@ -107,50 +121,6 @@ command = " " expect(config.notifications).toEqual({ enabled: true, condition: 'unfocused' }); expect(config.upgrade).toEqual({ autoInstall: true }); - expect(config.statusLine).toEqual(DEFAULT_STATUS_LINE_CONFIG); - }); - - it('normalizes partial and complete status-line tables', () => { - expect( - parseTuiConfig(` -[status_line] -show_model = false -show_git = false -`).statusLine, - ).toEqual({ - ...DEFAULT_STATUS_LINE_CONFIG, - showModel: false, - showGit: false, - }); - - expect( - parseTuiConfig(` -[status_line] -show_model = false -show_effort = true -show_token_speed = false -show_context_bar = true -show_git = false -show_modes = true -show_elapsed = false -show_goal = true -show_background_tasks = false -`).statusLine, - ).toEqual({ - showModel: false, - showEffort: true, - showTokenSpeed: false, - showContextBar: true, - showGit: false, - showModes: true, - showElapsed: false, - showGoal: true, - showBackgroundTasks: false, - }); - }); - - it('parses the full-response copy preference', () => { - expect(parseTuiConfig('copy_full_response = true').copyFullResponse).toBe(true); }); it('throws TuiConfigParseError with fallback when parsing fails, leaving the file untouched', async () => { @@ -171,32 +141,25 @@ show_background_tasks = false await saveTuiConfig( { theme: 'light', - layout: 'inline', - copyFullResponse: true, + disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, - statusLine: { - ...DEFAULT_STATUS_LINE_CONFIG, - showContextBar: false, - showModes: false, - }, + statusLine: { items: null, command: null }, }, filePath, ); expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', - layout: 'inline', - copyFullResponse: true, + renderLatex: true, + disablePasteBurst: false, + cacheExpiryHint: true, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, - statusLine: { - ...DEFAULT_STATUS_LINE_CONFIG, - showContextBar: false, - showModes: false, - }, + statusLine: { items: null, command: null }, }); }); @@ -205,8 +168,8 @@ show_background_tasks = false await saveTuiConfig( { theme, - layout: DEFAULT_TUI_CONFIG.layout, - copyFullResponse: DEFAULT_TUI_CONFIG.copyFullResponse, + disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, + cacheExpiryHint: DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, @@ -218,3 +181,92 @@ show_background_tasks = false expect((await loadTuiConfig(filePath)).theme).toBe(theme); }); }); + +describe('TUI config status_line', () => { + it('defaults to null when the section is omitted', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.statusLine).toEqual({ items: null, command: null }); + }); + + it('parses items and command', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "git", "cwd"] +command = "~/.pythinker-code/statusline.sh" +`); + + expect(config.statusLine).toEqual({ + items: ['model', 'git', 'cwd'], + command: '~/.pythinker-code/statusline.sh', + }); + }); + + it('skips unknown items with a warning instead of failing the whole file', () => { + const config = parseTuiConfig(` +[status_line] +items = ["model", "wat", "git"] +`); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + }); + + it('routes unknown-item warnings through the provided callback instead of stderr', () => { + const warnings: string[] = []; + const config = parseTuiConfig( + ` +[status_line] +items = ["model", "wat", "git"] +`, + (message) => warnings.push(message), + ); + + expect(config.statusLine?.items).toEqual(['model', 'git']); + expect(warnings).toEqual(['[tui.toml] ignoring unknown status_line item: wat']); + }); + + it('normalizes an empty command to null', () => { + const config = parseTuiConfig(` +[status_line] +command = " " +`); + + expect(config.statusLine?.command).toBeNull(); + }); + + it('documents status_line in the rendered template', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('[status_line]'); + expect(text).toContain('items'); + expect(text).toContain('command'); + }); +}); + +describe('TUI config status_line round-trip', () => { + it('preserves an active status_line across save and reload', async () => { + await saveTuiConfig( + { + ...DEFAULT_TUI_CONFIG, + statusLine: { items: ['model', 'git'], command: '~/.pythinker-code/statusline.sh' }, + }, + filePath, + ); + + const reloaded = await loadTuiConfig(filePath); + expect(reloaded.statusLine).toEqual({ + items: ['model', 'git'], + command: '~/.pythinker-code/statusline.sh', + }); + }); + + it('keeps the status_line section commented out when unset', async () => { + await saveTuiConfig(DEFAULT_TUI_CONFIG, filePath); + + const text = readFileSync(filePath, 'utf-8'); + expect(text).toContain('# [status_line]'); + expect(text).toContain('# items ='); + expect(text).toContain('# command ='); + }); +}); diff --git a/apps/pythinker-code/test/tui/constant/tips.test.ts b/apps/pythinker-code/test/tui/constant/tips.test.ts new file mode 100644 index 00000000..de0f69ef --- /dev/null +++ b/apps/pythinker-code/test/tui/constant/tips.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { ALL_TIPS, WORKING_TIPS } from '#/tui/constant/tips'; + +describe('tips constants', () => { + it('ALL_TIPS is non-empty', () => { + expect(ALL_TIPS.length).toBeGreaterThan(0); + }); + + it('tip texts are unique across ALL_TIPS', () => { + const texts = ALL_TIPS.map((tip) => tip.text); + expect(new Set(texts).size).toBe(texts.length); + }); + + it('every tip has a non-empty text', () => { + for (const tip of ALL_TIPS) { + expect(tip.text.length).toBeGreaterThan(0); + } + }); + + it('every tip has valid optional properties', () => { + for (const tip of ALL_TIPS) { + if (tip.priority !== undefined) { + expect(tip.priority).toBeGreaterThan(0); + } + if (tip.solo !== undefined) { + expect(typeof tip.solo).toBe('boolean'); + } + } + }); + + it('WORKING_TIPS is non-empty', () => { + expect(WORKING_TIPS.length).toBeGreaterThan(0); + }); + + it('every working tip is included in ALL_TIPS', () => { + for (const workingTip of WORKING_TIPS) { + expect(ALL_TIPS.some((tip) => tip.text === workingTip.text)).toBe(true); + } + }); + + it('shared working tips match ALL_TIPS priority and solo values', () => { + for (const workingTip of WORKING_TIPS) { + const allTip = ALL_TIPS.find((tip) => tip.text === workingTip.text); + expect(allTip).toBeDefined(); + expect(allTip?.priority).toBe(workingTip.priority); + expect(allTip?.solo).toBe(workingTip.solo); + } + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts b/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts new file mode 100644 index 00000000..4900a344 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/cache-hint-controller.test.ts @@ -0,0 +1,793 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + CacheHintController, + type CacheHintHost, +} from '#/tui/controllers/cache-hint-controller'; +import type { CacheHintConfig } from '#/utils/cache-hint-config'; +import type { ExtractionResult } from '#/tui/utils/image-placeholder'; + +const peekMock = vi.fn<() => CacheHintConfig | undefined>(() => undefined); +const getMock = vi.fn(async (): Promise<CacheHintConfig | undefined> => undefined); + +vi.mock('#/utils/cache-hint-config', () => ({ + peekCacheHintConfig: () => peekMock(), + getCacheHintConfig: (...args: unknown[]) => getMock(...(args as [])), + refreshCacheHintConfigInBackground: () => undefined, + resetCacheHintConfigCache: () => undefined, +})); + +const CONFIG: CacheHintConfig = { + version: 1, + config: { 'kimi-k2': { min_tokens_to_hint: 100000, cache_duration: 600 } }, +}; + +function makeHost( + overrides: { + session?: unknown; + appState?: Record<string, unknown>; + createNewSessionFails?: boolean; + } = {}, +) { + const state = { + activeDialog: null as string | null, + appState: { + model: 'k2', + availableModels: { k2: { model: 'kimi-k2', provider: 'managed:pythinker-code' } }, + availableProviders: { 'managed:pythinker-code': { oauth: { key: 'pythinker-code' } } }, + sessionId: 's1', + streamingPhase: 'idle', + isCompacting: false, + contextTokens: 150000, + cacheExpiryHint: true, + ...overrides.appState, + }, + }; + const host: CacheHintHost = { + engineV2: true, + harness: { auth: { getCachedAccessToken: vi.fn(async () => 'tok') } } as never, + session: (overrides.session ?? { id: 's1' }) as never, + state: state as never, + track: vi.fn(), + setAppState: vi.fn((patch) => Object.assign(state.appState, patch)), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + recallStashedMedia: vi.fn(), + showError: vi.fn(), + createNewSession: vi.fn(async () => { + if (overrides.createNewSessionFails !== true) state.appState.sessionId = 's2'; + }), + sendNormalUserInput: vi.fn(async () => undefined), + sendInlineSkillUserInput: vi.fn(async () => undefined), + }; + return { host, state }; +} + +function resumeSession(replayTimes: number[], tokenCount: number, updatedAt = 0) { + return { + id: 's1', + summary: { updatedAt }, + getResumeState: () => ({ + agents: { + main: { + replay: replayTimes.map((time) => ({ type: 'message', time })), + context: { tokenCount }, + }, + }, + }), + }; +} + +async function flush(times = 20): Promise<void> { + for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r)); +} + +function uploadedExtraction(fileId: string, byte: number): ExtractionResult { + const path = `/tmp/${fileId}.png`; + return { + parts: [ + { type: 'text', text: `<image path="${path}"></image>` }, + { + type: 'image_url', + imageUrl: { url: `pythinker-file://${fileId}?path=${encodeURIComponent(path)}` }, + }, + ], + hasMedia: true, + imageAttachmentIds: [1], + videoAttachmentIds: [], + imageSnapshots: [{ bytes: new Uint8Array([byte]), mime: 'image/png', width: 640, height: 480 }], + stagingPaths: [path], + }; +} + +beforeEach(() => { + peekMock.mockReset().mockReturnValue(undefined); + getMock.mockReset().mockResolvedValue(undefined); + vi.useRealTimers(); +}); + +describe('CacheHintController scenario 2 (idle submit)', () => { + it('does not intercept a fresh submit (no activity baseline)', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + }); + + it('does not intercept when idle for less than the coarse floor', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + expect(peekMock).not.toHaveBeenCalled(); + }); + + it('does not intercept when the provider is not OAuth-managed', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost({ + appState: { + availableProviders: { 'managed:pythinker-code': {} }, // apiKey form: no oauth + }, + }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('does not cold-fetch for providers that can never match a rule', async () => { + // Config cache cold (peek returns undefined by default): without the + // applicability gate this submit would be swallowed for a fetch that can + // never produce a hint. + const { host } = makeHost({ + appState: { + availableProviders: { 'managed:pythinker-code': {} }, // apiKey form: no oauth + }, + }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + await flush(); + expect(getMock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('swallows a cold-cache submit, fetches, and releases when no rule matches', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + await flush(); + expect(getMock).toHaveBeenCalled(); + // Fetch resolved without a matching rule → the message is released. + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it('releases a stashed inline-skill submit through the inline-skill path', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + const activations = [{ skillName: 'review' }]; + expect(controller.maybeInterceptOnSubmit('check /skill:review', undefined, activations)).toBe( + true, + ); + await flush(); + vi.restoreAllMocks(); + + expect(host.sendInlineSkillUserInput).toHaveBeenCalledWith( + 'check /skill:review', + activations, + undefined, + ); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('fetches on a cold-cache submit and shows the dialog when a rule matches', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle' }), + ); + vi.restoreAllMocks(); + }); + + it('serializes cold-cache submits so they keep their order', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('world')).toBe(true); + await flush(); + vi.restoreAllMocks(); + + expect(host.sendNormalUserInput).toHaveBeenCalledTimes(2); + expect( + (host.sendNormalUserInput as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]), + ).toEqual(['hello', 'world']); + }); + + it('restores chained submits instead of sending when the dialog is dismissed', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('world')).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); // dismiss the first dialog + await flush(); + + // Nothing was sent; both inputs are back in the editor, newline-joined. + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenLastCalledWith('hello\nworld'); + }); + + it('releases stashed media with recall semantics when the dialog is dismissed', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + const extraction = uploadedExtraction('file-1', 1); + + expect(controller.maybeInterceptOnSubmit('describe [image #1 (1×1)]', extraction)).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); // dismiss + await flush(); + + // Nothing was sent; the draft is back in the editor and the stash's + // retains/staged copies go through recall — without this the retain count + // never returns to zero and the upload can never be lease-deleted. + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenCalledWith('describe [image #1 (1×1)]'); + expect(host.recallStashedMedia).toHaveBeenCalledWith('describe [image #1 (1×1)]', extraction); + }); + + it('hands the stashed input back when the session switched during the fetch', async () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + (host as unknown as { session: unknown }).session = { id: 's2' }; + await flush(); + vi.restoreAllMocks(); + + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('releases instead of mounting when a foreground operation started during the fetch', async () => { + getMock.mockResolvedValue(CONFIG); + const { host, state } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + // A foreground operation (turn / /compact) kicked off mid-fetch. + state.appState.streamingPhase = 'waiting'; + await flush(); + vi.restoreAllMocks(); + + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + + it('intercepts and shows the dialog when all conditions hold', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle', model: 'k2' }), + ); + vi.restoreAllMocks(); + }); + + it('does not advance the cache baseline at turn begin (the prompt may fail pre-model)', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + // A send begins a turn but fails before any model request — the expired + // baseline must survive, so the retry still intercepts. + controller.onTurnBegin(); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + vi.restoreAllMocks(); + }); + + it('does not intercept twice in the same idle cycle', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(controller.maybeInterceptOnSubmit('again')).toBe(false); + vi.restoreAllMocks(); + }); + + it('resends the stashed input on continue', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); // down → new + dialog.handleInput('\u001B[B'); // down → continue + dialog.handleInput('\r'); + await flush(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + expect(host.track).toHaveBeenCalledWith('cache_hint_action', { + action: 'continue', + scene: 'idle', + }); + }); + + it('restores the input on Esc without sending', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); + await flush(); + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('compacts then resends once compaction engages', async () => { + peekMock.mockReturnValue(CONFIG); + const compact = vi.fn(async () => undefined); + const { host, state } = makeHost({ session: { id: 's1', compact } }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\r'); // compact (default) + // The engine flips isCompacting asynchronously via the started event. + setTimeout(() => { + state.appState.isCompacting = true; + }, 10); + await vi.waitFor(() => { + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + expect(compact).toHaveBeenCalledWith({}); + }); + + it('starts a new session and resends', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); // down → new + dialog.handleInput('\r'); + await flush(); + expect(host.createNewSession).toHaveBeenCalled(); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('hello', undefined); + }); + + it('resends an uploaded image inline after starting a new session', async () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + const extraction = uploadedExtraction('file-1', 1); + + controller.maybeInterceptOnSubmit('describe [image #1 (1×1)]', extraction); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); + dialog.handleInput('\r'); + await flush(); + + const resend = vi.mocked(host.sendNormalUserInput).mock.calls[0]?.[1]; + expect(resend?.imageAttachmentIds).toEqual([]); + expect(resend?.parts).toContainEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQ==' }, + }); + }); + + it('resends every chained uploaded image inline after starting a new session', async () => { + getMock.mockResolvedValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + + expect(controller.maybeInterceptOnSubmit('first', uploadedExtraction('file-1', 1))).toBe(true); + expect(controller.maybeInterceptOnSubmit('second', uploadedExtraction('file-2', 2))).toBe(true); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); + dialog.handleInput('\r'); + await flush(); + + const sendCalls = ( + host.sendNormalUserInput as unknown as { + mock: { calls: Array<[string, ExtractionResult | undefined]> }; + } + ).mock.calls; + const imageUrls = sendCalls.map(([, extraction]) => { + const imagePart = extraction?.parts.find((part) => part.type === 'image_url'); + return imagePart?.type === 'image_url' ? imagePart.imageUrl.url : undefined; + }); + expect(imageUrls).toEqual([ + 'data:image/png;base64,AQ==', + 'data:image/png;base64,Ag==', + ]); + }); + + it('keeps the input when new-session creation fails', async () => { + peekMock.mockReturnValue(CONFIG); + const { host, state } = makeHost({ createNewSessionFails: true }); + const controller = new CacheHintController(host); + controller.recordActivity(); + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.maybeInterceptOnSubmit('hello'); + vi.restoreAllMocks(); + + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B[B'); + dialog.handleInput('\r'); + await flush(); + expect(state.appState.sessionId).toBe('s1'); + expect(host.restoreInputText).toHaveBeenCalledWith('hello'); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); +}); + +describe('CacheHintController cache-break detection', () => { + const u = (inputCacheRead: number) => ({ + inputOther: 100, + output: 50, + inputCacheRead, + inputCacheCreation: 0, + }); + + it('does not judge the first measured step', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + expect(host.track).not.toHaveBeenCalled(); + }); + + it('records cache activity on a completed step, even one without usage', () => { + peekMock.mockReturnValue(CONFIG); + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.recordActivity(); + // 20 min later a step completes — the provider round trip refreshed the + // server-side cache… + vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 1200_000); + controller.noteStepUsage(undefined); + // …so a submit right after is fresh and must not be intercepted. + expect(controller.maybeInterceptOnSubmit('hello')).toBe(false); + vi.restoreAllMocks(); + }); + + it('reports a drop beyond the ratio and token gates with both usages', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.noteStepUsage(u(7000)); + + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ + prev_model: 'k2', + curr_model: 'k2', + prev_input_cache_read: 10000, + curr_input_cache_read: 7000, + cache_read_drop_ratio: 0.3, + }), + ); + }); + + it('stays quiet within the ratio gate or under the token threshold', () => { + const a = makeHost(); + const controllerA = new CacheHintController(a.host); + controllerA.noteStepUsage(u(100000)); + controllerA.noteStepUsage(u(96000)); // 4% drop — inside the ratio gate + expect(a.host.track).not.toHaveBeenCalled(); + + const b = makeHost(); + const controllerB = new CacheHintController(b.host); + controllerB.noteStepUsage(u(4000)); + controllerB.noteStepUsage(u(2500)); // drop 1500 ≤ 2000 — under the token threshold + expect(b.host.track).not.toHaveBeenCalled(); + }); + + it('skips unmeasured usage without touching the baseline', () => { + const { host } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.noteStepUsage(undefined); + controller.noteStepUsage({ inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }); + controller.noteStepUsage(u(5000)); + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ prev_input_cache_read: 10000, curr_input_cache_read: 5000 }), + ); + }); + + it('resets the baseline on compaction, but records a mid-session model/effort switch', () => { + const { host, state } = makeHost(); + const controller = new CacheHintController(host); + controller.noteStepUsage(u(10000)); + controller.resetCacheBreakBaseline(); + controller.noteStepUsage(u(100)); // post-compaction drop is expected + expect(host.track).not.toHaveBeenCalled(); + + // A model switch busts the cache key — that drop IS the signal to record. + controller.noteStepUsage(u(10000)); + state.appState.model = 'other-model'; + controller.noteStepUsage(u(100)); + expect(host.track).toHaveBeenCalledWith( + 'cache_break_detected', + expect.objectContaining({ + prev_model: 'k2', + curr_model: 'other-model', + prev_input_cache_read: 10000, + curr_input_cache_read: 100, + }), + ); + }); +}); + +describe('CacheHintController scenario 1 (resume)', () => { + /** maybeShowOnResume awaits the user's choice; dismiss the dialog once mounted. */ + async function showOnResumeAndDismiss( + controller: CacheHintController, + host: CacheHintHost, + ): Promise<void> { + const pending = controller.maybeShowOnResume(); + await vi.waitFor(() => { + expect(host.mountEditorReplacement).toHaveBeenCalled(); + }); + const dialog = (host.mountEditorReplacement as ReturnType<typeof vi.fn>).mock.calls[0]![0] as { + handleInput: (data: string) => void; + }; + dialog.handleInput('\u001B'); + await pending; + } + + it('shows the dialog on resume when idle beyond cache_duration', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'resume' }), + ); + }); + + it('shows at most once per session', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('falls back to summary.updatedAt when there are no replay records', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([], 150000, Date.now() - 1200_000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await showOnResumeAndDismiss(controller, host); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('drops the dialog when the session switched during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise<CacheHintConfig>((res) => { + resolveFetch = res; + }), + ); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); // let the fetch start (resolveFetch gets assigned) + (host as unknown as { session: unknown }).session = { id: 'other-session' }; + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('drops the dialog when a turn started during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise<CacheHintConfig>((res) => { + resolveFetch = res; + }), + ); + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host, state } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); + // The user sent the first prompt while the fetch was in flight. + state.appState.streamingPhase = 'waiting'; + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('re-evaluates against fresh activity when a turn completed during the config fetch', async () => { + let resolveFetch!: (config: CacheHintConfig) => void; + getMock.mockImplementation( + () => + new Promise<CacheHintConfig>((res) => { + resolveFetch = res; + }), + ); + // Idle long past the window when the resume check started… + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + const pending = controller.maybeShowOnResume(); + await flush(); // let the fetch start (resolveFetch gets assigned) + // …but the user's first prompt ran to completion while the config was in + // flight, refreshing the server-side cache — the stale replay timestamp + // must not trigger the dialog. + controller.recordActivity(); + resolveFetch(CONFIG); + await pending; + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('ignores local-only state records when computing the resume idle time', async () => { + getMock.mockResolvedValue(CONFIG); + const oldMessage = { type: 'message', time: Date.now() - 1200_000 }; + const recentStateRecord = { type: 'permission_updated', time: Date.now() - 5000 }; + const session = { + id: 's1', + summary: { updatedAt: 0 }, + getResumeState: () => ({ + agents: { + main: { replay: [oldMessage, recentStateRecord], context: { tokenCount: 150000 } }, + }, + }), + }; + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + // The recent permission change must not mask the expired cache. + await showOnResumeAndDismiss(controller, host); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + }); + + it('skips when the config cannot be resolved', async () => { + const session = resumeSession([Date.now() - 1200_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('skips small sessions below the token threshold', async () => { + getMock.mockResolvedValue(CONFIG); + const session = resumeSession([Date.now() - 1200_000], 50_000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + }); + + it('seeds the activity baseline when the resume check skips inside the cache window', async () => { + getMock.mockResolvedValue(CONFIG); + peekMock.mockReturnValue(CONFIG); + const now = Date.now(); + // 9 minutes into a 10-minute cache window — nothing to show on resume. + const session = resumeSession([now - 540_000], 150000); + const { host } = makeHost({ session }); + const controller = new CacheHintController(host); + + await controller.maybeShowOnResume(); + expect(host.mountEditorReplacement).not.toHaveBeenCalled(); + + // Two minutes later the window has expired; the seeded baseline lets the + // idle-submit path catch it instead of waving the prompt through. + vi.spyOn(Date, 'now').mockReturnValue(now + 660_000); + expect(controller.maybeInterceptOnSubmit('hello')).toBe(true); + expect(host.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(host.track).toHaveBeenCalledWith( + 'cache_hint_shown', + expect.objectContaining({ scene: 'idle' }), + ); + vi.restoreAllMocks(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/clipboard-image-hint.test.ts b/apps/pythinker-code/test/tui/controllers/clipboard-image-hint.test.ts new file mode 100644 index 00000000..befe71a6 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/clipboard-image-hint.test.ts @@ -0,0 +1,672 @@ +import type { TUI } from '@pymodel/pi-tui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ClipboardImageHintController, + type ClipboardImageHintHost, +} from '#/tui/controllers/clipboard-image-hint'; +import type { FooterComponent } from '#/tui/components/chrome/footer'; +import { TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT } from '#/tui/utils/terminal-focus'; +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; + +vi.mock('#/utils/clipboard/clipboard-has-image', () => ({ + clipboardHasImage: vi.fn(async () => false), +})); + +type FakeTUI = TUI & { emitInput(data: string): void }; + +interface FakeFooter { + hint: string | null; + setTransientHint(hint: string | null): void; + getTransientHint(): string | null; +} + +function createFakeFooter(): FooterComponent { + const footer: FakeFooter = { + hint: null, + setTransientHint(hint: string | null): void { + this.hint = hint; + }, + getTransientHint(): string | null { + return this.hint; + }, + }; + return footer as unknown as FooterComponent; +} + +function createFakeTUI(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + listener(data); + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +function createFakeTUIWithConsumingFocusTracker(): FakeTUI { + const listeners = new Set<(data: string) => { consume?: boolean; data?: string } | undefined>(); + return { + addInputListener: vi.fn((listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + emitInput: (data: string) => { + for (const listener of listeners) { + const result = listener(data); + if (result?.consume) return; + } + }, + requestRender: vi.fn(), + } as unknown as FakeTUI; +} + +// Drive the controller through its first clipboard observation with an empty +// clipboard. The first observation only establishes a baseline and never shows +// a hint, leaving the controller armed and ready for the next new image. +async function primeEmptyBaseline(ui: FakeTUI): Promise<void> { + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +// Simulate the user returning to the terminal and let the debounced check fire. +async function focusReturnAndFlush(ui: FakeTUI): Promise<void> { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); +} + +describe('ClipboardImageHintController', () => { + let platformSpy: ReturnType<typeof vi.spyOn> | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + vi.mocked(clipboardHasImage).mockResolvedValue(false); + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); + }); + + afterEach(() => { + platformSpy?.mockRestore(); + vi.useRealTimers(); + }); + + it('does not show a hint for an image already in the clipboard at startup', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Startup baseline observes the image already present; the focus check + // runs too but must stay quiet for that same image. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + // One call is the startup baseline; the second is the focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('shows hint when a new image is copied during the session', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('shows hint for the first image copied after startup when startup baseline was empty', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).toBeNull(); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + expect(footer.getTransientHint()).toMatch(/Ctrl\+V/); + + controller.stop(); + }); + + it('does not show hint when model does not support images', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => false, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not repeat the hint for the same lingering image', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline and show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // The same image is still in the clipboard: focusing again must not nag. + vi.mocked(clipboardHasImage).mockClear(); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + + controller.stop(); + }); + + it('shows the hint again for a new image after the clipboard is cleared', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish the baseline, then show a hint for the first image. + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + // Clipboard cleared: the empty check re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + footer.setTransientHint(null); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows again. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('clears the hint after the display duration', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('cancels a pending debounced check when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await vi.advanceTimersByTimeAsync(0); + vi.mocked(clipboardHasImage).mockClear(); + + ui.emitInput(TERMINAL_FOCUS_IN); + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).not.toHaveBeenCalled(); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('handles rapid focus churn without duplicate checks or hints', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + vi.mocked(clipboardHasImage).mockClear(); + + for (let i = 0; i < 5; i++) { + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + } + + await vi.advanceTimersByTimeAsync(1000); + + expect(clipboardHasImage).toHaveBeenCalledTimes(1); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + }); + + it('ignores stale clipboard read result when focus is lost', async () => { + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise((resolve) => setTimeout(() => { resolve(true); }, 1500)), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + ui.emitInput(TERMINAL_FOCUS_OUT); + await vi.advanceTimersByTimeAsync(1500); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('ignores a pending clipboard read result after stop', async () => { + let resolveDeferred: (value: boolean) => void = () => {}; + vi.mocked(clipboardHasImage).mockImplementation( + () => new Promise<boolean>((resolve) => { + resolveDeferred = resolve; + }), + ); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + // One call is the startup baseline; the second is the debounced focus check. + expect(clipboardHasImage).toHaveBeenCalledTimes(2); + + controller.stop(); + resolveDeferred(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + }); + + it('clears a displayed hint when stopped', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + controller.stop(); + expect(footer.getTransientHint()).toBeNull(); + expect(host.requestRender).toHaveBeenCalled(); + }); + + it('does not clear a hint set by another caller when stopped', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const requestRender = vi.fn(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender, + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // First observation only establishes the baseline and sets no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + const otherHint = 'Other hint'; + footer.setTransientHint(otherHint); + + const requestRenderCalls = requestRender.mock.calls.length; + controller.stop(); + expect(footer.getTransientHint()).toBe(otherHint); + expect(host.requestRender).toHaveBeenCalledTimes(requestRenderCalls); + }); + + it('uses only the latest clipboard read result after focus churn', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Establish an empty baseline with a normal resolved read first. + await primeEmptyBaseline(ui); + + const deferreds: Array<{ resolve: (value: boolean) => void; promise: Promise<boolean> }> = []; + vi.mocked(clipboardHasImage).mockImplementation(() => { + let resolve: (value: boolean) => void = () => {}; + const promise = new Promise<boolean>((res) => { + resolve = res; + }); + deferreds.push({ resolve, promise }); + return promise; + }); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(1); + + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(deferreds).toHaveLength(2); + + deferreds[0]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toBeNull(); + + deferreds[1]!.resolve(true); + await vi.advanceTimersByTimeAsync(0); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('keeps the existing auto-clear timer when a re-check exits early', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).not.toBeNull(); + + // Trigger a re-check that exits early because the clipboard is now empty. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + + // The previous hint should still be visible because its auto-clear timer + // was preserved through the re-check. + expect(footer.getTransientHint()).not.toBeNull(); + + // Advance the remaining original display duration and verify it expires. + await vi.advanceTimersByTimeAsync(3000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + }); + + it('does not clear a matching hint owned by another caller after auto-clear', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + const hintText = footer.getTransientHint(); + expect(hintText).not.toBeNull(); + + await vi.advanceTimersByTimeAsync(4000); + expect(footer.getTransientHint()).toBeNull(); + + // Another caller sets the same hint text the controller previously used. + footer.setTransientHint(hintText); + + controller.stop(); + expect(footer.getTransientHint()).toBe(hintText); + }); + + it('re-establishes the baseline after stop and restart', async () => { + vi.mocked(clipboardHasImage).mockResolvedValue(true); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Image already present at start: baseline only, no hint. + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + expect(footer.getTransientHint()).toBeNull(); + + controller.stop(); + controller.start(); + + // After restart the image is still present: baseline again, no hint. + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // Clipboard cleared: re-arms the controller. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toBeNull(); + + // A genuinely new image: hint shows. + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('observes focus events even when another listener consumes them', async () => { + const footer = createFakeFooter(); + const ui = createFakeTUIWithConsumingFocusTracker(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + // Register a second listener that consumes focus events, like installTerminalFocusTracking. + const consumedEvents: string[] = []; + ui.addInputListener((data) => { + if (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT) { + consumedEvents.push(data); + return { consume: true }; + } + return undefined; + }); + + // Baseline observation (consumed), then a new image on the next focus. + vi.mocked(clipboardHasImage).mockResolvedValue(false); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + vi.mocked(clipboardHasImage).mockResolvedValue(true); + ui.emitInput(TERMINAL_FOCUS_OUT); + ui.emitInput(TERMINAL_FOCUS_IN); + await vi.advanceTimersByTimeAsync(1000); + + expect(consumedEvents).toEqual([TERMINAL_FOCUS_IN, TERMINAL_FOCUS_OUT, TERMINAL_FOCUS_IN]); + expect(footer.getTransientHint()).toMatch(/Image in clipboard/); + + controller.stop(); + }); + + it('shows Alt+V shortcut on Windows', async () => { + platformSpy?.mockRestore(); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + + const footer = createFakeFooter(); + const ui = createFakeTUI(); + const host: ClipboardImageHintHost = { + ui, + footer, + getModelSupportsImage: () => true, + requestRender: vi.fn(), + }; + + const controller = new ClipboardImageHintController(host); + controller.start(); + + await primeEmptyBaseline(ui); + vi.mocked(clipboardHasImage).mockResolvedValue(true); + await focusReturnAndFlush(ui); + + expect(footer.getTransientHint()).toMatch(/Alt\+V/); + + controller.stop(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts new file mode 100644 index 00000000..42842d46 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -0,0 +1,443 @@ +/** + * Clipboard image paste → attachment store, with ingestion-time compression. + * + * Tests pin: + * - an oversized pasted image is downsampled while building the attachment, + * so the stored bytes, the `[image #N (W×H)]` placeholder, and the eventual + * submitted image all agree on the compressed size + * - the pre-compression original is recorded on the attachment in memory — + * never persisted at paste time, because the session whose + * media-originals dir it belongs in may not exist yet; dispatch-time + * caption resolution owns persistence (see image-placeholder tests) + * - a within-budget paste is stored byte-for-byte (fast path), with no + * original recorded + * - on the v2 engine the final bytes are uploaded to the daemon file store + * with a crash-recovery TTL, and the attachment carries the returned id + * and expiry; an upload failure leaves the paste on the inline fallback + */ + +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Jimp } from 'jimp'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorKeyboardController, + type EditorKeyboardHost, +} from '#/tui/controllers/editor-keyboard'; +import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; +import { parseImageMeta } from '#/utils/image/image-mime'; +import { ImageLimits, type PythinkerHarness } from '@pymodel/pythinker-code-sdk'; + +// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still +// applies to the editor-keyboard module that pulls in readClipboardMedia. +const { readClipboardMedia } = vi.hoisted(() => ({ readClipboardMedia: vi.fn() })); + +vi.mock('#/utils/clipboard/clipboard-image', async (importActual) => { + const actual = await importActual<typeof import('#/utils/clipboard/clipboard-image')>(); + return { ...actual, readClipboardMedia }; +}); + +interface PasteHarness { + readonly store: ImageAttachmentStore; + readonly track: ReturnType<typeof vi.fn>; + /** Invoke the paste handler, then wait for the background ingestion to settle. */ + pasteImage(): Promise<void>; + /** Invoke the paste handler only — background ingestion may still be pending. */ + pasteImageRaw(): Promise<boolean>; +} + +function createPasteHarness( + options: { + sessionDir?: string; + imageLimits?: ImageLimits; + engineV2?: boolean; + uploadFile?: ( + data: Uint8Array, + opts: { name: string; mimeType?: string; expiresInSec?: number }, + ) => Promise<{ id: string }>; + } = {}, +): PasteHarness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const store = new ImageAttachmentStore(); + const track = vi.fn(); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: + options.sessionDir === undefined + ? undefined + : { summary: { sessionDir: options.sessionDir } }, + btwPanelController: { closeOrCancel: vi.fn(() => false) }, + engineV2: options.engineV2, + track, + showError: vi.fn(), + openUndoSelector: vi.fn(), + cancelRunningShellCommand: vi.fn(), + } as unknown as EditorKeyboardHost; + if (options.imageLimits !== undefined || options.uploadFile !== undefined) { + (host as unknown as { harness: PythinkerHarness }).harness = { + imageLimits: options.imageLimits, + uploadFile: options.uploadFile, + } as unknown as PythinkerHarness; + } + + const controller = new EditorKeyboardController(host, store); + controller.install(); + + const pasteImageRaw = (): Promise<boolean> => { + const handler = editor['onPasteImage']; + if (handler === undefined) throw new Error('onPasteImage handler not installed'); + return (handler as () => Promise<boolean>)(); + }; + + return { + store, + track, + async pasteImage() { + await pasteImageRaw(); + for (let id = 1; id <= store.size(); id++) { + const attachment = store.get(id); + if (attachment?.kind === 'image') await attachment.pending; + } + }, + pasteImageRaw, + }; +} + +async function solidPng(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/png'), + ); +} + +async function solidJpeg(width: number, height: number): Promise<Uint8Array> { + return new Uint8Array( + await new Jimp({ width, height, color: 0x3366ccff }).getBuffer('image/jpeg', { quality: 90 }), + ); +} + +/** Typed `uploadFile` stub so `mock.calls` keeps the (data, options) tuple. */ +function uploadFileMock(id: string) { + return vi.fn(async ( + _data: Uint8Array, + _opts: { name: string; mimeType?: string; expiresInSec?: number }, + ) => ({ id, expires_at: '2030-01-02T03:04:05.000Z' })); +} + +/** + * Insert a minimal EXIF APP1 segment carrying only an Orientation tag right + * after the JPEG SOI marker (jimp itself never writes EXIF). Mirrors the + * fixture in agent-core's image-compress tests. + */ +function withExifOrientation(jpeg: Uint8Array, orientation: number): Uint8Array { + // TIFF body, little-endian: 8-byte header + IFD0 with a single entry. + const tiff = Buffer.alloc(26); + tiff.write('II', 0, 'latin1'); + tiff.writeUInt16LE(42, 2); + tiff.writeUInt32LE(8, 4); // offset of IFD0 + tiff.writeUInt16LE(1, 8); // one directory entry + tiff.writeUInt16LE(0x0112, 10); // tag: Orientation + tiff.writeUInt16LE(3, 12); // type: SHORT + tiff.writeUInt32LE(1, 14); // count + tiff.writeUInt16LE(orientation, 18); // value, left-aligned in the 4-byte field + tiff.writeUInt32LE(0, 22); // no next IFD + const exifBody = Buffer.concat([Buffer.from('Exif\0\0', 'latin1'), tiff]); + const app1Header = Buffer.alloc(4); + app1Header.writeUInt16BE(0xff_e1, 0); + app1Header.writeUInt16BE(exifBody.length + 2, 2); + return new Uint8Array( + Buffer.concat([ + Buffer.from(jpeg.subarray(0, 2)), // SOI + app1Header, + exifBody, + Buffer.from(jpeg.subarray(2)), + ]), + ); +} + +describe('clipboard image paste compression', () => { + beforeEach(() => { + readClipboardMedia.mockReset(); + }); + + it('downsamples an oversized pasted image before storing it', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + expect(store.size()).toBe(1); + const att = store.get(1); + expect(att?.kind).toBe('image'); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + + // Stored metadata reflects the compressed size. + expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000); + expect(att.placeholder).toContain('2000×1000'); + + // The stored bytes decode to the compressed dimensions — the thumbnail and + // the submitted image both read from these bytes, so they cannot diverge. + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000); + }); + + it('honors the harness [image] max_edge_px when pasting', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ + imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }), + }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + // The harness [image] config — not the built-in 2000px — drives ingestion. + expect(Math.max(att.width, att.height)).toBe(800); + expect(att.placeholder).toContain('800×400'); + const dims = parseImageMeta(att.bytes); + expect(dims).not.toBeNull(); + expect(Math.max(dims!.width, dims!.height)).toBe(800); + }); + + it('records the pre-compression original in memory for an oversized paste', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original).toBeDefined(); + expect(att.original?.bytes).toEqual(big); + expect(att.original?.width).toBe(3600); + expect(att.original?.height).toBe(1800); + expect(att.original?.byteLength).toBe(big.length); + expect(att.original?.mime).toBe('image/png'); + + // Nothing is persisted at paste time — dispatch-time caption resolution + // owns that, once the session (and its media-originals dir) is known. + expect(att.original?.path).toBeUndefined(); + }); + + it('does not persist the original at paste time, even with a known session', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'pythinker-paste-session-')); + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness({ sessionDir }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.bytes).toEqual(big); + expect(att.original?.path).toBeUndefined(); + expect(existsSync(join(sessionDir, 'media-originals'))).toBe(false); + await rm(sessionDir, { recursive: true, force: true }); + }); + + it('stores a within-budget paste byte-for-byte', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.width).toBe(80); + expect(att.height).toBe(80); + expect(att.bytes).toBe(small); // identity: no re-encode on the fast path + expect(att.original).toBeUndefined(); + }); + + it( + 'records an EXIF-rotated compressed original in display space', + async () => { + // Orientation 6 (rotate 90° CW): the header says 3600x400, but the image + // decodes to 400x3600 — the space the compressed bytes and any later + // ReadMediaFile region readback live in. The recorded original (which + // drives the submit-time compression caption) must match that space, or + // the caption contradicts the sent image's aspect and region coordinates + // land axis-swapped. (Kept narrow: pure-JS decode+rotate+encode of a + // larger frame can outlast the test timeout on slow CI runners.) + const portrait = withExifOrientation(await solidJpeg(3600, 400), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.original?.width).toBe(400); + expect(att.original?.height).toBe(3600); + // The compressed attachment itself keeps the portrait aspect. + expect(att.width).toBeLessThan(att.height); + }, + 15_000, + ); + + it('stores display-space dimensions for an EXIF-rotated untouched paste', async () => { + // Within budgets → sent byte-for-byte, but the placeholder and metadata + // must still describe the display (rotated) space. + const portrait = withExifOrientation(await solidJpeg(120, 80), 6); + readClipboardMedia.mockResolvedValue({ + kind: 'image', + bytes: portrait, + mimeType: 'image/jpeg', + }); + + const { store, pasteImage } = createPasteHarness(); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.bytes).toBe(portrait); // fast path — untouched + expect(att.original).toBeUndefined(); + expect(att.width).toBe(80); + expect(att.height).toBe(120); + expect(att.placeholder).toContain('80×120'); + }); + + it('emits image_compress telemetry tagged tui_paste through host.track', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + + const { track, pasteImage } = createPasteHarness(); + await pasteImage(); + + const compressCalls = track.mock.calls.filter(([event]) => event === 'image_compress'); + expect(compressCalls).toHaveLength(1); + const props = compressCalls[0]![1] as Record<string, unknown>; + expect(props['source']).toBe('tui_paste'); + expect(props['outcome']).toBe('compressed'); + }); + + it('uploads final bytes with a crash-recovery TTL while the staging lease owns normal cleanup', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + const uploadFile = uploadFileMock('file-1'); + + const { store, pasteImage } = createPasteHarness({ engineV2: true, uploadFile }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.fileId).toBe('file-1'); + expect(att.fileExpiresAt).toBe(Date.parse('2030-01-02T03:04:05.000Z')); + expect(uploadFile).toHaveBeenCalledTimes(1); + const [data, opts] = uploadFile.mock.calls[0]!; + expect(new Uint8Array(data)).toEqual(small); + expect(opts).toEqual({ + name: 'pasted-image.png', + mimeType: 'image/png', + expiresInSec: 60 * 60, + }); + // The bytes stay on the attachment for the inline fallback / cache copy. + expect(att.bytes).toBe(small); + }); + + it('uploads the compressed bytes when paste-time compression changed them (v2)', async () => { + const big = await solidPng(3600, 1800); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' }); + const uploadFile = uploadFileMock('file-9'); + + const { store, pasteImage } = createPasteHarness({ engineV2: true, uploadFile }); + await pasteImage(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.fileId).toBe('file-9'); + // The upload carries exactly what the attachment stores — the compressed + // bytes, not the clipboard original. + const [data] = uploadFile.mock.calls[0]!; + expect(data).toBe(att.bytes); + expect(att.bytes).not.toBe(big); + }); + + it('keeps the paste on the inline fallback when the daemon upload fails (v2)', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + const uploadFile = vi.fn( + async ( + _data: Uint8Array, + _opts: { name: string; mimeType?: string; expiresInSec?: number }, + ): Promise<{ id: string }> => { + throw new Error('daemon down'); + }, + ); + + const { store, pasteImage } = createPasteHarness({ engineV2: true, uploadFile }); + await pasteImage(); // must not throw + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.fileId).toBeUndefined(); + expect(att.bytes).toBe(small); + }); + + it('never uploads on the v1 engine', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + const uploadFile = uploadFileMock('file-1'); + + // engineV2 unset — the v1 host shape. + const { store, pasteImage } = createPasteHarness({ uploadFile }); + await pasteImage(); + + expect(uploadFile).not.toHaveBeenCalled(); + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.fileId).toBeUndefined(); + }); + + it('settles the paste callback before the background daemon upload completes (v2)', async () => { + const small = await solidPng(80, 80); + readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: small, mimeType: 'image/png' }); + let resolveUpload!: (meta: { id: string }) => void; + const uploadFile = vi.fn( + ( + _data: Uint8Array, + _opts: { name: string; mimeType?: string; expiresInSec?: number }, + ): Promise<{ id: string }> => + new Promise<{ id: string }>((resolve) => { + resolveUpload = resolve; + }), + ); + + const { store, pasteImageRaw } = createPasteHarness({ engineV2: true, uploadFile }); + // The handler returns once the placeholder is in the editor; the upload + // is still unresolved here — typing is never held behind it. + await pasteImageRaw(); + + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + expect(att.placeholder).toBe('[image #1 (80×80)]'); + expect(att.fileId).toBeUndefined(); + expect(att.pending).toBeDefined(); + + resolveUpload({ id: 'file-late' }); + await att.pending; + + expect(att.fileId).toBe('file-late'); + expect(att.pending).toBeUndefined(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts index 1f233fa4..bba93007 100644 --- a/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/pythinker-code/test/tui/controllers/editor-keyboard.test.ts @@ -1,61 +1,651 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DOUBLE_ESC_WINDOW_MS, NO_ACTIVE_SESSION_MESSAGE } from '#/tui/constant/pythinker-tui'; import { EditorKeyboardController, type EditorKeyboardHost, } from '#/tui/controllers/editor-keyboard'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -function makeHost() { - const editor: Record<string, unknown> = {}; - const setConfig = vi.fn(() => Promise.resolve()); - const getConfig = vi.fn(() => - Promise.resolve({ defaultModel: undefined, defaultThinking: undefined, thinking: undefined }), - ); +interface Harness { + readonly host: EditorKeyboardHost; + readonly editor: Record<string, ((...args: never[]) => unknown) | undefined>; + readonly openUndoSelector: ReturnType<typeof vi.fn>; + readonly cancelRunningShellCommand: ReturnType<typeof vi.fn>; + readonly cancelCompaction: ReturnType<typeof vi.fn>; + readonly btwCancelRunning: ReturnType<typeof vi.fn>; + readonly btwCloseOrCancel: ReturnType<typeof vi.fn>; +} + +function createHarness(options: { streamingPhase?: string; isCompacting?: boolean } = {}): Harness { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + setInputMode: vi.fn() as unknown as (...args: never[]) => unknown, + getText: vi.fn(() => '') as unknown as (...args: never[]) => unknown, + setText: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const openUndoSelector = vi.fn(); + const cancelRunningShellCommand = vi.fn(); + const cancelCompaction = vi.fn(async () => {}); + const btwCancelRunning = vi.fn(() => false); + const btwCloseOrCancel = vi.fn(() => false); + const session = { cancel: vi.fn(async () => {}), cancelCompaction }; + const host = { state: { editor, - ui: { addInputListener: vi.fn(() => () => {}), requestRender: vi.fn() }, + activeDialog: null, appState: { - model: 'test/model', - thinkingLevel: 'low', - availableModels: { - 'test/model': { - capabilities: ['thinking'], - supportEfforts: ['low', 'medium', 'high', 'max'], - }, - }, + streamingPhase: options.streamingPhase ?? 'idle', + isCompacting: options.isCompacting ?? false, }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, }, - session: { setThinking: vi.fn(() => Promise.resolve()) }, - harness: { getConfig, setConfig }, - cancelInFlight: undefined, - setAppState: vi.fn(), - track: vi.fn(), - showError: vi.fn(), - showNotice: vi.fn(), - dispatchFooter: vi.fn(), + session, + btwPanelController: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel }, + openUndoSelector, + cancelRunningShellCommand, updateEditorBorderHighlight: vi.fn(), - updateQueueDisplay: vi.fn(), + updateGoalLengthWarning: vi.fn(), } as unknown as EditorKeyboardHost; - return { host, editor, setConfig, getConfig }; + + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + + return { + host, + editor, + openUndoSelector, + cancelRunningShellCommand, + cancelCompaction, + btwCancelRunning, + btwCloseOrCancel, + }; } -describe('EditorKeyboardController thinking-effort cycling', () => { - it('persists the cycled effort as the startup default', async () => { - const { host, editor, setConfig } = makeHost(); - const controller = new EditorKeyboardController(host, {} as unknown as ImageAttachmentStore); - controller.install(); +function pressEscape(editor: Harness['editor']): void { + const handler = editor['onEscape']; + if (handler === undefined) throw new Error('onEscape handler not installed'); + (handler as () => void)(); +} + +function pressCtrlC(editor: Harness['editor']): void { + const handler = editor['onCtrlC']; + if (handler === undefined) throw new Error('onCtrlC handler not installed'); + (handler as () => void)(); +} + +function pressNonEscape(editor: Harness['editor']): void { + const handler = editor['onNonEscapeInput']; + if (handler === undefined) throw new Error('onNonEscapeInput handler not installed'); + (handler as () => void)(); +} + +describe('EditorKeyboardController double-Esc undo', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('opens the undo selector when Esc is pressed twice within the window while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + expect(openUndoSelector).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(openUndoSelector).toHaveBeenCalledOnce(); + }); + + it('does nothing for a single Esc while idle', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when the second Esc arrives after the window expires', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + vi.advanceTimersByTime(DOUBLE_ESC_WINDOW_MS + 1); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger when another key is pressed between the two Esc presses', () => { + const { editor, openUndoSelector } = createHarness(); + + pressEscape(editor); + pressNonEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + }); + + it('does not trigger undo while streaming; Esc cancels the stream instead', () => { + const { editor, host, openUndoSelector, cancelRunningShellCommand } = createHarness({ + streamingPhase: 'waiting', + }); + + pressEscape(editor); + pressEscape(editor); + + expect(openUndoSelector).not.toHaveBeenCalled(); + expect(cancelRunningShellCommand).toHaveBeenCalled(); + const session = host.session as unknown as { cancel: ReturnType<typeof vi.fn> }; + expect(session.cancel).toHaveBeenCalled(); + }); +}); + +describe('EditorKeyboardController btw panel priority', () => { + it('Esc closes the btw panel first while compacting, without cancelling compaction', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValue(true); + + pressEscape(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Esc cancels compaction on the next press once the btw panel is gone', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValueOnce(true); + + pressEscape(editor); + expect(cancelCompaction).not.toHaveBeenCalled(); + + pressEscape(editor); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); + + it('Esc cancels compaction directly when no btw panel is open', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + + pressEscape(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); + + it('Ctrl+C cancels a running btw question first while compacting', () => { + const { editor, btwCancelRunning, cancelCompaction } = createHarness({ isCompacting: true }); + btwCancelRunning.mockReturnValue(true); + + pressCtrlC(editor); + + expect(btwCancelRunning).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Ctrl+C closes an idle btw panel while compacting, without cancelling compaction', () => { + const { editor, btwCloseOrCancel, cancelCompaction } = createHarness({ isCompacting: true }); + btwCloseOrCancel.mockReturnValue(true); + + pressCtrlC(editor); + + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).not.toHaveBeenCalled(); + }); + + it('Ctrl+C cancels compaction when no btw panel is open', () => { + const { editor, btwCancelRunning, btwCloseOrCancel, cancelCompaction } = createHarness({ + isCompacting: true, + }); + + pressCtrlC(editor); + + expect(btwCancelRunning).toHaveBeenCalledOnce(); + expect(btwCloseOrCancel).toHaveBeenCalledOnce(); + expect(cancelCompaction).toHaveBeenCalledOnce(); + }); +}); + +describe('EditorKeyboardController shell history recall', () => { + type Recall = (entry: string, direction: 1 | -1) => string | undefined; + type Mock = ReturnType<typeof vi.fn>; + + it('installs a filter that allows shell entries only in bash mode', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + expect(setHistoryFilter).toHaveBeenCalledOnce(); + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(true); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(filter('!cmd')).toBe(true); + expect(filter('hello')).toBe(false); + }); + + it('locks the filter to the browse-entry mode once browsing starts', () => { + const { editor } = createHarness(); + const setHistoryFilter = editor['setHistoryFilter'] as unknown as Mock; + const [filter] = setHistoryFilter.mock.calls[0] as [(entry: string) => boolean]; + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + // Enter browse from prompt mode, then simulate landing on a shell entry + // (which flips inputMode to bash). The filter should stay locked to prompt + // and keep allowing plain entries. + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + save(); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + + expect(filter('hello')).toBe(true); + expect(filter('!cmd')).toBe(true); + }); + + it('strips the leading ! and switches to bash mode when recalling a shell entry', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('!cmd', -1); + + expect(result).toBe('cmd'); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('bash'); + }); + + it('keeps plain entries as-is and switches to prompt mode', () => { + const { editor } = createHarness(); + const onRecall = editor['onRecall'] as unknown as Recall; + + const result = onRecall('hello', -1); + + expect(result).toBeUndefined(); + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); + + it('saves the current input mode as the history draft host state', () => { + const { editor } = createHarness(); + const save = editor['onHistoryDraftSave'] as unknown as () => unknown; + + (editor as unknown as { inputMode: string }).inputMode = 'prompt'; + expect(save()).toBe('prompt'); + + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + expect(save()).toBe('bash'); + }); + + it('restores the input mode from the saved draft host state', () => { + const { editor } = createHarness(); + const restore = editor['onHistoryDraftRestore'] as unknown as (state: unknown) => void; + + restore('prompt'); + + expect(editor['setInputMode'] as unknown as Mock).toHaveBeenCalledWith('prompt'); + }); +}); + +describe('EditorKeyboardController input changes', () => { + function installExpandedText( + editor: Harness['editor'], + expanded: string, + ): ReturnType<typeof vi.fn> { + const getExpandedText = vi.fn(() => expanded); + editor['getExpandedText'] = getExpandedText as unknown as (...args: never[]) => unknown; + return getExpandedText; + } + + it('forwards text changes to the border highlight and goal length warning', () => { + const { host, editor } = createHarness(); + installExpandedText(editor, '/goal Ship feature X'); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('/goal Ship feature X'); + + expect(host.updateEditorBorderHighlight).toHaveBeenCalledWith('/goal Ship feature X'); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('measures the goal length warning on paste-expanded text, not the collapsed marker', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + // The visible text only holds the collapsed paste marker. + onChange('/goal [paste #1 +4000 chars]'); + + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('expands a leading paste marker because its content may start with /goal', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('[paste #1 +4000 chars]'); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('expands a paste that can complete a partially typed /goal command', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + // Visible text is `/go[paste #1 …]`; the paste completes the command. + onChange('/go[paste #1 +3999 chars]'); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('skips paste expansion entirely for non-goal input', () => { + const { host, editor } = createHarness(); + const getExpandedText = installExpandedText(editor, 'whatever'); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('just a normal prompt'); + onChange('/help'); + + expect(getExpandedText).not.toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined); + }); + + it('gates on trimmed text because submit trims leading whitespace', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange(` /goal ${'x'.repeat(4001)}`); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('skips the goal length warning in bash mode', () => { + const { host, editor } = createHarness(); + const getExpandedText = installExpandedText(editor, '/goal x'); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('/goal x'); + + expect(getExpandedText).not.toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined); + }); +}); + +describe('EditorKeyboardController Shift-Tab plan toggle', () => { + function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) { + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + }; + const handlePlanToggle = vi.fn(); + const track = vi.fn(); + const showError = vi.fn(); + const ensureSession = vi.fn(async (): Promise<{ id: string } | undefined> => ({ id: 'ses-lazy' })); + const host = { + state: { + editor, + activeDialog: null, + appState: { streamingPhase: 'idle', isCompacting: false, planMode: false }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: options.sessionless ? undefined : { cancel: vi.fn(async () => {}) }, + engineV2: options.engineV2 ?? false, + ensureSession, + handlePlanToggle, + track, + showError, + btwPanelController: { cancelRunning: vi.fn(), closeOrCancel: vi.fn() }, + } as unknown as EditorKeyboardHost; + + new EditorKeyboardController(host, undefined as unknown as ImageAttachmentStore).install(); + const onShiftTab = editor['onShiftTab'] as unknown as () => void; + return { onShiftTab, handlePlanToggle, track, showError, ensureSession }; + } + + it('toggles plan mode directly with an active session', () => { + const { onShiftTab, handlePlanToggle, ensureSession } = createShiftTabHarness(); + + onShiftTab(); + + expect(ensureSession).not.toHaveBeenCalled(); + expect(handlePlanToggle).toHaveBeenCalledWith(true); + }); + + it('reports no active session on v1 when session-less', () => { + const { onShiftTab, showError, handlePlanToggle } = createShiftTabHarness({ + sessionless: true, + }); + + onShiftTab(); + + expect(showError).toHaveBeenCalledWith(NO_ACTIVE_SESSION_MESSAGE); + expect(handlePlanToggle).not.toHaveBeenCalled(); + }); + + it('lazy-creates the session before toggling on v2 when session-less', async () => { + const { onShiftTab, ensureSession, handlePlanToggle, track } = createShiftTabHarness({ + sessionless: true, + engineV2: true, + }); + + onShiftTab(); + expect(handlePlanToggle).not.toHaveBeenCalled(); - const onCycleEffort = editor['onCycleEffort'] as () => void; - expect(typeof onCycleEffort).toBe('function'); - onCycleEffort(); await vi.waitFor(() => { - expect(setConfig).toHaveBeenCalledWith({ - defaultModel: 'test/model', - defaultThinking: true, - thinking: { effort: 'medium', mode: 'on' }, + expect(handlePlanToggle).toHaveBeenCalledWith(true); + }); + expect(ensureSession).toHaveBeenCalledOnce(); + expect(track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); + }); + + it('does not toggle when the lazy creation fails on v2', async () => { + const { onShiftTab, ensureSession, handlePlanToggle } = createShiftTabHarness({ + sessionless: true, + engineV2: true, + }); + ensureSession.mockResolvedValue(undefined); + + onShiftTab(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(handlePlanToggle).not.toHaveBeenCalled(); + }); +}); + +/** + * Ctrl-S steering of the TUI queue: plain-text items steer as messages, + * slash-skill items fire as real activations into the running turn (never as + * literal text), grouped inline-skill submissions stay queued for the drain + * path, bash items stay queued — all in queue order. + */ +describe('EditorKeyboardController Ctrl-S steering', () => { + function createCtrlSHarness(options: { + editorText: string; + queued: Array<Record<string, unknown>>; + engineV2?: boolean; + skillCommandMap?: Map<string, string>; + }) { + const steerMessage = vi.fn(); + const steerSkillActivation = vi.fn(); + const updateQueueDisplay = vi.fn(); + const setText = vi.fn(); + const editor: Record<string, ((...args: never[]) => unknown) | undefined> = { + setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, + setInputMode: vi.fn() as unknown as (...args: never[]) => unknown, + getText: vi.fn(() => options.editorText) as unknown as (...args: never[]) => unknown, + setText: setText as unknown as (...args: never[]) => unknown, + inputMode: 'prompt' as unknown as (...args: never[]) => unknown, + }; + const host = { + state: { + editor, + activeDialog: null, + queuedMessages: options.queued, + appState: { streamingPhase: 'waiting', isCompacting: false, model: 'k2' }, + footer: { setTransientHint: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + engineV2: options.engineV2 ?? false, + skillCommandMap: options.skillCommandMap ?? new Map(), + steerMessage, + steerSkillActivation, + updateQueueDisplay, + validateMediaCapabilities: vi.fn(() => true), + showError: vi.fn(), + track: vi.fn(), + btwPanelController: { + cancelRunning: vi.fn(() => false), + closeOrCancel: vi.fn(() => false), + }, + } as unknown as EditorKeyboardHost; + const controller = new EditorKeyboardController( + host, + undefined as unknown as ImageAttachmentStore, + ); + controller.install(); + const onCtrlS = editor['onCtrlS']; + if (onCtrlS === undefined) throw new Error('onCtrlS handler not installed'); + return { + host, + editor, + setText, + steerMessage, + steerSkillActivation, + updateQueueDisplay, + onCtrlS: onCtrlS as () => void, + }; + } + + it('steers text as a message, skill items as activations, and keeps bash queued', () => { + const { host, steerMessage, steerSkillActivation, updateQueueDisplay, onCtrlS } = + createCtrlSHarness({ + editorText: '', + queued: [ + { text: 'queued text', agentId: 'main' }, + { + text: '/tower status', + agentId: 'main', + mode: 'skill', + skillName: 'tower', + skillArgs: 'status', + }, + { text: '!ls', agentId: 'main', mode: 'bash' }, + ], }); + + onCtrlS(); + + expect(steerMessage).toHaveBeenCalledWith(host.session, [ + { text: 'queued text', parts: undefined, imageAttachmentIds: undefined }, + ]); + expect(steerSkillActivation).toHaveBeenCalledWith(host.session, 'tower', 'status'); + expect(host.state.queuedMessages).toEqual([{ text: '!ls', agentId: 'main', mode: 'bash' }]); + expect(updateQueueDisplay).toHaveBeenCalled(); + }); + + it('steers plain queued messages but keeps grouped inline-skill submissions queued', () => { + const { host, steerMessage, updateQueueDisplay, onCtrlS } = createCtrlSHarness({ + editorText: '', + queued: [ + { text: 'plain note', agentId: 'main' }, + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + ], + }); + + onCtrlS(); + + expect(steerMessage).toHaveBeenCalledWith(host.session, [ + { text: 'plain note', parts: undefined, imageAttachmentIds: undefined }, + ]); + expect(host.state.queuedMessages).toEqual([ + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + ]); + expect(updateQueueDisplay).toHaveBeenCalled(); + }); + + it('stops steering at the first bundle so later messages keep FIFO order', () => { + const { host, steerMessage, onCtrlS } = createCtrlSHarness({ + editorText: '', + queued: [ + { text: 'earlier note', agentId: 'main' }, + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + { text: 'later note', agentId: 'main' }, + ], + }); + + onCtrlS(); + + expect(steerMessage).toHaveBeenCalledWith(host.session, [ + { text: 'earlier note', parts: undefined, imageAttachmentIds: undefined }, + ]); + expect(host.state.queuedMessages).toEqual([ + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + { text: 'later note', agentId: 'main' }, + ]); + }); + + it('steers nothing when a bundle leads the queue', () => { + const { host, steerMessage, onCtrlS } = createCtrlSHarness({ + editorText: '', + queued: [ + { + text: 'check /skill:review', + agentId: 'main', + inlineSkillActivations: [{ skillName: 'review' }], + }, + { text: 'later note', agentId: 'main' }, + ], }); + + onCtrlS(); + + expect(steerMessage).not.toHaveBeenCalled(); + expect(host.state.queuedMessages).toHaveLength(2); + }); + + it('leaves an editor draft with inline skill tokens in the editor for the grouped path', () => { + const { host, setText, steerMessage, onCtrlS } = createCtrlSHarness({ + editorText: 'check /skill:review', + queued: [{ text: 'plain note', agentId: 'main' }], + engineV2: true, + skillCommandMap: new Map([['skill:review', 'review']]), + }); + + onCtrlS(); + + expect(steerMessage).toHaveBeenCalledWith(host.session, [ + { text: 'plain note', parts: undefined, imageAttachmentIds: undefined }, + ]); + expect(setText).not.toHaveBeenCalled(); + expect(host.state.queuedMessages).toEqual([]); }); }); diff --git a/apps/pythinker-code/test/tui/controllers/mouse-controller.test.ts b/apps/pythinker-code/test/tui/controllers/mouse-controller.test.ts deleted file mode 100644 index cea67298..00000000 --- a/apps/pythinker-code/test/tui/controllers/mouse-controller.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { type Component, Container } from '@earendil-works/pi-tui'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('#/utils/clipboard/clipboard-text', () => ({ - copyTextToClipboard: vi.fn(() => Promise.resolve()), -})); - -import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; -import { TranscriptViewport } from '#/tui/components/chrome/transcript-viewport'; -import { - MOUSE_DRAG_SCROLL_INTERVAL_MS, - MOUSE_REPORTING_DISABLE, - MOUSE_REPORTING_ENABLE, -} from '#/tui/constant/mouse'; -import { MouseController, type MouseControllerHost } from '#/tui/controllers/mouse-controller'; - -class StubLines implements Component { - constructor(private readonly lines: readonly string[]) {} - render(): string[] { - return [...this.lines]; - } - invalidate(): void {} -} - -type InputListener = (data: string) => { consume: boolean } | undefined; - -function makeHost(lineCount = 10, height = 4) { - const container = new Container(); - container.addChild( - new StubLines(Array.from({ length: lineCount }, (_, i) => `line-${String(i + 1)}`)), - ); - const viewport = new TranscriptViewport(container); - viewport.setHeight(height); - viewport.render(80); - - const listeners: InputListener[] = []; - const state = { - transcriptViewport: viewport, - ui: { - addInputListener: (listener: InputListener) => { - listeners.push(listener); - return () => {}; - }, - requestRender: vi.fn(), - }, - }; - const presentation = { writeTerminalControl: vi.fn() }; - const host = { state, presentation } as unknown as MouseControllerHost; - const fire = (data: string): Array<{ consume: boolean } | undefined> => - listeners.map((listener) => listener(data)); - return { host, viewport, presentation, state, fire }; -} - -describe('MouseController', () => { - beforeEach(() => { - vi.mocked(copyTextToClipboard).mockClear(); - }); - - it('enables SGR mouse reporting on start and disables it on stop', () => { - const { host, presentation } = makeHost(); - const controller = new MouseController(host); - controller.start(); - expect(presentation.writeTerminalControl).toHaveBeenCalledWith(MOUSE_REPORTING_ENABLE); - controller.stop(); - expect(presentation.writeTerminalControl).toHaveBeenCalledWith(MOUSE_REPORTING_DISABLE); - // Idempotent: a second stop writes nothing more. - controller.stop(); - expect(presentation.writeTerminalControl).toHaveBeenCalledTimes(2); - }); - - it('scrolls the transcript viewport on wheel events and consumes them', () => { - const { host, viewport, fire } = makeHost(); - new MouseController(host).start(); - const results = fire('\u001B[<64;10;2M'); // wheel up - expect(results).toEqual([{ consume: true }]); - expect(viewport.getScrollOffset()).toBe(3); - fire('\u001B[<65;10;2M'); // wheel down - expect(viewport.getScrollOffset()).toBe(0); - }); - - it('ignores non-mouse input so the editor still receives it', () => { - const { host, fire } = makeHost(); - new MouseController(host).start(); - expect(fire('a')).toEqual([undefined]); - }); - - it('copies the drag selection to the clipboard on release', () => { - const { host, fire, presentation } = makeHost(); - new MouseController(host).start(); - // Pinned window shows buffer rows 6..9 on screen rows 1..4. - fire('\u001B[<0;1;1M'); // left press at screen (1,1) -> buffer (6,0) - fire('\u001B[<32;7;2M'); // drag to screen (7,2) -> buffer (7,6) - fire('\u001B[<0;7;2m'); // release - expect(copyTextToClipboard).toHaveBeenCalledWith('line-7\nline-8'); - expect(presentation.writeTerminalControl).toHaveBeenLastCalledWith( - '\u001B]52;c;bGluZS03CmxpbmUtOA==\u0007', - ); - }); - - it('uses the release position as the final selection endpoint', () => { - const { host, fire } = makeHost(); - new MouseController(host).start(); - fire('\u001B[<0;1;1M'); - fire('\u001B[<0;7;2m'); - expect(copyTextToClipboard).toHaveBeenCalledWith('line-7\nline-8'); - }); - - it('auto-scrolls downward while dragging past the transcript bottom', () => { - vi.useFakeTimers(); - try { - const { host, viewport, fire } = makeHost(12, 4); - new MouseController(host).start(); - viewport.scrollBy(4); - viewport.render(80); - - fire('\u001B[<0;1;2M'); // left press on line 6 - fire('\u001B[<32;8;5M'); // drag one row below the transcript - vi.advanceTimersByTime(MOUSE_DRAG_SCROLL_INTERVAL_MS * 4); - - expect(viewport.isPinned()).toBe(true); - fire('\u001B[<0;8;5m'); - expect(vi.getTimerCount()).toBe(0); - expect(copyTextToClipboard).toHaveBeenCalledWith( - 'line-6\nline-7\nline-8\nline-9\nline-10\nline-11\nline-12', - ); - } finally { - vi.useRealTimers(); - } - }); - - it('does not copy on a bare click or a release without a drag', () => { - const { host, fire } = makeHost(); - new MouseController(host).start(); - fire('\u001B[<0;3;2M'); - fire('\u001B[<0;3;2m'); - fire('\u001B[<0;5;3m'); - expect(copyTextToClipboard).not.toHaveBeenCalled(); - }); - - it('jumps to the bottom when the "N more" chip is clicked', () => { - const { host, viewport, fire } = makeHost(); - new MouseController(host).start(); - viewport.scrollBy(2); - viewport.render(80); // lay out the chip on the region's last row - expect(viewport.chipHit(4, 80)).toBe(true); - fire('\u001B[<0;80;4M'); - expect(viewport.isPinned()).toBe(true); - }); - - it('clears the selection when clicking the chrome area below the transcript', () => { - const { host, viewport, fire } = makeHost(); - new MouseController(host).start(); - fire('\u001B[<0;1;1M'); - fire('\u001B[<32;7;2M'); - fire('\u001B[<0;7;2m'); - expect(viewport.hasSelection()).toBe(true); - fire('\u001B[<0;10;8M'); // below the 4-row transcript region - expect(viewport.hasSelection()).toBe(false); - }); -}); diff --git a/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts b/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts new file mode 100644 index 00000000..c2c82d72 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts @@ -0,0 +1,301 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PluginSummary } from '@pymodel/pythinker-code-sdk'; + +import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + PluginUpdateNotifier, + type PluginUpdateNotifierSession, +} from '#/tui/controllers/plugin-update-notifier'; +import type { PluginMarketplace } from '#/utils/plugin-marketplace'; +import { readPluginUpdateNoticeState } from '#/utils/plugin-update-notice-state'; + +function makePluginSummary(overrides: Partial<PluginSummary> = {}): PluginSummary { + return { + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + ...overrides, + }; +} + +function makeMarketplaceEntry( + id: string, + displayName: string, + version: string, +): PluginMarketplace['plugins'][number] { + return { + id, + displayName, + source: `https://code.kimi.com/pythinker-code/plugins/official/${id}.zip`, + tier: 'official', + version, + }; +} + +function makeMarketplace(version = '3.4.0'): PluginMarketplace { + return { + source: PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [makeMarketplaceEntry('pythinker-datasource', 'Pythinker Datasource', version)], + }; +} + +interface HarnessOptions { + readonly marketplace?: PluginMarketplace; + readonly installed?: readonly PluginSummary[]; + readonly mcpServers?: readonly string[]; + readonly loadMarketplace?: () => Promise<PluginMarketplace>; +} + +function makeHarness(options: HarnessOptions = {}) { + const session: PluginUpdateNotifierSession = { + listMcpServers: vi.fn(async () => + (options.mcpServers ?? ['plugin-pythinker-datasource:data']).map((name) => ({ name })), + ), + listPlugins: vi.fn(async () => options.installed ?? [makePluginSummary()]), + }; + const notify = vi.fn(); + const loadMarketplace = vi.fn( + options.loadMarketplace ?? (async () => options.marketplace ?? makeMarketplace()), + ); + return { session, notify, loadMarketplace }; +} + +const DATASOURCE_TOOL = 'mcp__plugin-pythinker-datasource_data__call_data_source_tool'; +const EXPECTED_MESSAGE = + 'Update detected: Pythinker Datasource 3.4.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.'; + +describe('PluginUpdateNotifier', () => { + let tempDir: string; + let stateFile: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'plugin-update-notifier-')); + stateFile = join(tempDir, 'plugin-notices.json'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function makeNotifier(harness: ReturnType<typeof makeHarness>) { + return new PluginUpdateNotifier({ + getSession: () => harness.session, + workDir: tempDir, + notify: harness.notify, + loadMarketplace: harness.loadMarketplace, + stateFile, + }); + } + + it('notifies once after a plugin MCP tool completes, then stays silent for that version', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + + harness.notify.mockClear(); + // Follow-up checks run but hit the persisted "already notified" record + // instead of notifying again. + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).not.toHaveBeenCalled(); + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tool names without touching the session', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted('Bash'); + await notifier.handleMcpToolCompleted('mcp__github__create_issue'); + + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the installed version is up to date', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ version: '3.4.0' })] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listPlugins).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for plugins absent from the marketplace', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ id: 'local-only' })] }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('local-only'); + // No marketplace entry — the check bails before even listing plugins. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the catalog is not the official marketplace', async () => { + const harness = makeHarness({ + marketplace: { + source: 'https://example.test/custom-marketplace.json', + plugins: [makeMarketplaceEntry('pythinker-datasource', 'Pythinker Datasource', '3.4.0')], + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + // A custom catalog may advertise anything under any id — the check bails + // before comparing versions. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for a same-id fork installed from a local path', async () => { + const harness = makeHarness({ + installed: [ + makePluginSummary({ source: 'local-path', originalSource: undefined }), + ], + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + // Provenance is not official, so the marketplace version is irrelevant. + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('resolves plugin tools whose qualified name core truncated before the separator', async () => { + // Server part exactly 50 chars: the 64-char truncation cuts the whole + // `__` separator and tool name, leaving `mcp__<server>_<hash>`. + const serverName = `plugin-pythinker-datasource:${'s'.repeat(22)}`; + const sanitized = `plugin-pythinker-datasource_${'s'.repeat(22)}`; + expect(`mcp__${sanitized}`.length).toBe(55); + const truncatedToolName = `mcp__${sanitized}_a1b2c3d4`; + + const harness = makeHarness({ mcpServers: [serverName] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(truncatedToolName); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('notifies after a plugin command turn ends', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + // Plugin commands resolve the plugin id directly — no MCP server lookup. + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + }); + + it('reminds again when the marketplace advertises a newer version', async () => { + const first = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const notifier = makeNotifier(first); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(first.notify).toHaveBeenCalledTimes(1); + + // A new notifier (fresh app run) against the same state file stays silent + // for the already-notified version… + const second = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const secondNotifier = makeNotifier(second); + await secondNotifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(second.notify).not.toHaveBeenCalled(); + + // …but reminds once the marketplace moves to a newer version. + const third = makeHarness({ marketplace: makeMarketplace('3.5.0') }); + const thirdNotifier = makeNotifier(third); + await thirdNotifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(third.notify).toHaveBeenCalledWith( + 'Update detected: Pythinker Datasource 3.5.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + }); + + it('swallows marketplace failures and retries on the next invocation', async () => { + let attempts = 0; + const harness = makeHarness({ + loadMarketplace: async () => { + attempts += 1; + if (attempts === 1) throw new Error('offline'); + return makeMarketplace(); + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(harness.loadMarketplace).toHaveBeenCalledTimes(1); + expect(harness.notify).not.toHaveBeenCalled(); + + await notifier.handlePluginCommandCompleted('pythinker-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('refreshes the memoized MCP server map when a lookup misses', async () => { + const harness = makeHarness({ mcpServers: [] }); + let servers: readonly string[] = []; + harness.session.listMcpServers = vi.fn(async () => servers.map((name) => ({ name }))); + const notifier = makeNotifier(harness); + + // The plugin's MCP server is not registered yet (the plugin gets + // installed later in the same app run, applied on /reload or /new). + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listMcpServers).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + + // After the reload the new server shows up; the next completion must + // refresh the memoized map instead of silently staying unresolved. + servers = ['plugin-pythinker-datasource:data']; + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('keeps every notified plugin when a turn uses two outdated plugins', async () => { + const harness = makeHarness({ + marketplace: { + source: PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [ + makeMarketplaceEntry('pythinker-datasource', 'Pythinker Datasource', '3.4.0'), + makeMarketplaceEntry('another-plugin', 'Another Plugin', '2.0.0'), + ], + }, + installed: [ + makePluginSummary(), + makePluginSummary({ + id: 'another-plugin', + displayName: 'Another Plugin', + version: '1.0.0', + }), + ], + }); + const notifier = makeNotifier(harness); + + await Promise.all([ + notifier.handlePluginCommandCompleted('pythinker-datasource'), + notifier.handlePluginCommandCompleted('another-plugin'), + ]); + + expect(harness.notify).toHaveBeenCalledTimes(2); + // Both entries must survive in the persisted state — no lost update. + const state = await readPluginUpdateNoticeState(stateFile); + expect(state.notified).toEqual({ + 'pythinker-datasource': '3.4.0', + 'another-plugin': '2.0.0', + }); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts new file mode 100644 index 00000000..9bf46ace --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -0,0 +1,220 @@ +import type { Event } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { + SubAgentEventHandler, + type SubagentLifecycleEvent, +} from '#/tui/controllers/subagent-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeStreamingUIStub() { + return { + getToolComponent: vi.fn(() => undefined), + getActiveToolCall: vi.fn(() => undefined), + onToolCallStart: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), + removeToolComponentIfInactive: vi.fn(), + applyBackgroundTaskTerminalStatus: vi.fn(), + markSubagentBackgrounded: vi.fn(), + setTurnId: vi.fn(), + flushNow: vi.fn(), + setTodoList: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + }; +} + +function makeSubagentHandler() { + const backgroundTasks = new Map<string, never>(); + const host = { + state: { + appState: { availableModels: {} }, + ui: { requestRender: vi.fn() }, + transcriptContainer: { addChild: vi.fn() }, + }, + streamingUI: makeStreamingUIStub(), + appendTranscriptEntry: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + updateActivityPane: vi.fn(), + }; + const handler = new SubAgentEventHandler(host as never, { + backgroundTasks, + backgroundTaskTranscriptedTerminal: new Set(), + syncBackgroundAgentBadge: vi.fn(), + }); + return { handler, backgroundTasks }; +} + +function spawnEvent(subagentId: string, runInBackground: boolean): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.spawned', + subagentId, + subagentName: 'explore', + parentToolCallId: `tc-${subagentId}`, + description: `task ${subagentId}`, + runInBackground, + } as unknown as SubagentLifecycleEvent; +} + +function completedEvent(subagentId: string): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.completed', + subagentId, + parentToolCallId: `tc-${subagentId}`, + resultSummary: 'done', + } as unknown as SubagentLifecycleEvent; +} + +describe('SubAgentEventHandler — activity record pruning', () => { + it('drops the record of a foreground-only subagent at terminal state', () => { + const { handler } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('a1', false)); + handler.activityStore.applyEvent({ + sessionId: 's1', + agentId: 'a1', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + expect(handler.activityStore.get('a1')).toBeDefined(); + + handler.handleLifecycleEvent(completedEvent('a1')); + + expect(handler.activityStore.get('a1')).toBeUndefined(); + }); + + it('keeps the record of a spawn-time background agent even before the task syncs', () => { + const { handler } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('a2', true)); + handler.activityStore.applyEvent({ + sessionId: 's1', + agentId: 'a2', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + + // No background.task.started has populated the task map yet. + handler.handleLifecycleEvent(completedEvent('a2')); + + const record = handler.activityStore.get('a2'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('done'); + }); +}); + +function makeSessionEventHost() { + const host = { + state: { + appState: { + sessionId: 's1', + workDir: '/tmp/wd', + streamingPhase: 'idle', + availableModels: {}, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + tasksBrowser: undefined, + footer: { setBackgroundCounts: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: makeStreamingUIStub(), + requireSession: vi.fn(), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: { repaint: vi.fn(), refreshOutputViewer: vi.fn() }, + }; + return host as never; +} + +describe('SessionEventHandler — background.task.terminated', () => { + function terminatedEvent(agentId: string, status: string): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'background.task.terminated', + info: { + taskId: `task-${agentId}`, + kind: 'agent', + agentId, + description: 'bg task', + status, + startedAt: 0, + endedAt: 1, + }, + } as unknown as Event; + } + + it('marks a still-running record failed when an agent is stopped without subagent.failed', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + handler.subAgentEventHandler.activityStore.ensureRecord({ + agentId: 'agent-9', + agentName: 'explore', + parentToolCallId: 'tc-9', + }); + + handler.handleEvent(terminatedEvent('agent-9', 'killed'), vi.fn()); + + expect(handler.subAgentEventHandler.activityStore.get('agent-9')?.status).toBe('failed'); + }); + + it('does not overwrite a record that already reached terminal state with a summary', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + const store = handler.subAgentEventHandler.activityStore; + store.ensureRecord({ agentId: 'agent-8', agentName: 'explore', parentToolCallId: 'tc-8' }); + store.markCompleted('agent-8', 'final summary'); + + handler.handleEvent(terminatedEvent('agent-8', 'completed'), vi.fn()); + + const record = store.get('agent-8'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('final summary'); + }); + + it('drops foreground-only records when the main turn ends (aborted subagents emit no lifecycle event)', () => { + const handler = new SessionEventHandler(makeSessionEventHost()); + const store = handler.subAgentEventHandler.activityStore; + store.ensureRecord({ agentId: 'agent-7', agentName: 'explore', parentToolCallId: 'tc-7' }); + + handler.handleEvent( + { + sessionId: 's1', + agentId: 'main', + type: 'turn.ended', + turnId: 1, + reason: 'cancelled', + } as Event, + vi.fn(), + ); + + expect(store.get('agent-7')).toBeUndefined(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts new file mode 100644 index 00000000..452e250a --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + isCompacting: true, + model: 'pythinker-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), + hasThinkingDraft: vi.fn(() => false), + flushThinkingToTranscript: vi.fn(), + appendAssistantDelta: vi.fn(), + scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record<string, unknown>) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +const compactionCompleted = { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { summary: 'summary', tokensBefore: 100, tokensAfter: 10, compactedCount: 1 }, +} as const; + +const compactionCancelled = { + type: 'compaction.cancelled', + sessionId: 's1', + agentId: 'main', +} as const; + +describe('SessionEventHandler compaction cache bookkeeping', () => { + it('records activity and resets the cache-break baseline after a completed compaction', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(compactionCompleted, vi.fn()); + expect(host.recordSessionActivity).toHaveBeenCalledOnce(); + expect(host.noteCompactionFinished).toHaveBeenCalledOnce(); + }); + + it('keeps both baselines after a cancelled compaction (context was not cut)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(compactionCancelled, vi.fn()); + expect(host.noteCompactionFinished).not.toHaveBeenCalled(); + expect(host.recordSessionActivity).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index d8c307d2..4fd92f80 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -1,26 +1,8 @@ -import { Container } from '@earendil-works/pi-tui'; -import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest'; - -import { MCP_STATUS_TRANSIENT_DURATION_MS } from '#/tui/constant/pythinker-tui'; -import { FooterComponent, footerStatusFromAppState } from '#/tui/components/chrome/footer'; -import { TranscriptContainer } from '#/tui/components/chrome/transcript-container'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - createFooterState, - reduceFooterState, - selectFooterViewModel, - selectStatusBarExtras, - selectStatusItemParts, - type FooterEvent, -} from '#/tui/runtime/footer/footer-model'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; + import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; -import type { AppState } from '#/tui/types'; import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '#/tui/goal-queue-store'; -import { - buildMcpStartupStatusLine, - type McpServerStatusSnapshot, -} from '#/tui/utils/mcp-server-status'; vi.mock('#/tui/goal-queue-store', () => ({ readGoalQueue: vi.fn(async () => ({ @@ -32,11 +14,6 @@ vi.mock('#/tui/goal-queue-store', () => ({ })), })); -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllEnvs(); -}); - function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'paused' | 'complete') { return { goalId: 'g1', @@ -62,8 +39,6 @@ function fakeGoalSnapshot(objective: string, status: 'active' | 'blocked' | 'pau function makeHost(options: { createGoalRejects?: boolean } = {}) { const session = { - id: 's1', - listMcpServers: vi.fn(async () => []), createGoal: vi.fn(async () => { if (options.createGoalRejects === true) throw new Error('create failed'); return fakeGoalSnapshot('Ship queued goal', 'active'); @@ -79,12 +54,11 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { permissionMode: 'auto', }, queuedMessages: [], + queuedMessageDispatchPending: false, theme: { palette: getBuiltInPalette('dark') }, toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, - footer: { setTokenSpeed: vi.fn() }, - transcriptContainer: { addTranscriptChild: vi.fn() }, - mcpStatusContainer: new Container(), + transcriptContainer: { addChild: vi.fn() }, ui: { requestRender: vi.fn() }, }, session, @@ -92,38 +66,34 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { sessionEventUnsubscribe: undefined, streamingUI: { setTurnId: vi.fn(), - setStep: vi.fn(), flushNow: vi.fn(), resetToolUi: vi.fn(), - finalizeLiveTextBuffers: vi.fn(), finalizeTurn: vi.fn(), + hasActiveTurn: vi.fn(() => false), hasThinkingDraft: vi.fn(() => false), flushThinkingToTranscript: vi.fn(), - appendThinkingDelta: vi.fn(), appendAssistantDelta: vi.fn(), - getTurnContext: vi.fn(() => ({ turnId: undefined, step: 0 })), - registerToolCall: vi.fn(), - completeToolResult: vi.fn(() => undefined), - accumulateToolCallDelta: vi.fn(), - getStreamingToolCallPreview: vi.fn(() => undefined), scheduleFlush: vi.fn(), + beginCompaction: vi.fn(), + endCompaction: vi.fn(), + cancelCompaction: vi.fn(), }, requireSession: vi.fn(() => session), setAppState: vi.fn(), - dispatchFooter: vi.fn(), patchLivePane: vi.fn(), resetLivePane: vi.fn(), showError: vi.fn(), showStatus: vi.fn(), showNotice: vi.fn(), - updateActivityPane: vi.fn(), track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), restoreInputText: vi.fn(), appendTranscriptEntry: vi.fn(), sendNormalUserInput: vi.fn(), - refreshSkillCommands: vi.fn(async () => {}), sendQueuedMessage: vi.fn(), shiftQueuedMessage: vi.fn(), btwPanelController: { routeEvent: vi.fn(() => false) }, @@ -138,55 +108,6 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { return { host: host as any, session }; } -function makeTokenSpeedHost() { - const { host } = makeHost(); - const appState = { - ...host.state.appState, - workDir: '/tmp', - planMode: false, - dynamicWorkflowMode: false, - thinkingLevel: 'medium', - contextUsage: 0, - contextTokens: 0, - maxContextTokens: 0, - isCompacting: false, - isReplaying: false, - streamingStartTime: 0, - theme: 'dark', - version: 'test', - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - availableModels: {}, - availableProviders: {}, - sessionTitle: null, - mcpServersSummary: null, - } as AppState; - const footer = new FooterComponent(appState); - host.state.appState = appState; - host.state.footer = footer; - let footerState = createFooterState(footerStatusFromAppState(appState, footer.getGitStatus())); - host.dispatchFooter.mockImplementation((event: FooterEvent) => { - footerState = reduceFooterState(footerState, event); - footer.setViewModel( - selectFooterViewModel( - footerState, - Date.now(), - DEFAULT_STATUS_LINE_CONFIG, - ), - ); - }); - return { - host, - footer, - renderStatusBarExtras: () => - selectStatusBarExtras(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).join(' '), - renderStatusBarModel: () => - selectStatusItemParts(footerState, Date.now(), DEFAULT_STATUS_LINE_CONFIG).model, - }; -} - function sendQueuedViaHost(host: ReturnType<typeof makeHost>['host'], session: unknown) { return (item: unknown) => { host.sendQueuedMessage(session as never, item as never); @@ -226,6 +147,20 @@ function turnEndedEvent() { } as const; } +function compactionCompletedEvent() { + return { + type: 'compaction.completed', + sessionId: 's1', + agentId: 'main', + result: { + summary: 'summary', + tokensBefore: 100, + tokensAfter: 10, + compactedCount: 1, + }, + } as const; +} + function modelBlockedEvent() { return { type: 'goal.updated', @@ -237,488 +172,10 @@ function modelBlockedEvent() { } function addedTranscriptText(host: ReturnType<typeof makeHost>['host']): string { - const component = host.state.transcriptContainer.addTranscriptChild.mock.calls.at(-1)?.[0]; - return component.render(80).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); -} - -function renderContainer(container: Container): string { - return container.render(120).join('\n').replaceAll(/\u001B\[[0-9;]*m/g, ''); + const component = host.state.transcriptContainer.addChild.mock.calls.at(-1)?.[0]; + return component.render(80).join('\n').replaceAll(/\[[0-9;]*m/g, ''); } -function occurrences(text: string, needle: string): number { - return text.split(needle).length - 1; -} - -describe('SessionEventHandler Dynamic Workflow routing', () => { - it('specializes only the exact DynamicWorkflow tool name', () => { - const { host } = makeHost(); - const handler = new SessionEventHandler(host); - const start = vi - .spyOn(handler.subAgentEventHandler, 'handleDynamicWorkflowToolCallStarted') - .mockImplementation(() => {}); - - handler.handleEvent( - { - type: 'tool.call.started', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_dynamic_workflow', - name: 'DynamicWorkflow', - args: { items: ['a', 'b'] }, - } as never, - vi.fn(), - ); - handler.handleEvent( - { - type: 'tool.call.started', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_removed_swarm', - name: 'AgentSwarm', - args: { items: ['a', 'b'] }, - } as never, - vi.fn(), - ); - - expect(start).toHaveBeenCalledTimes(1); - expect(start).toHaveBeenCalledWith('call_dynamic_workflow', { items: ['a', 'b'] }); - expect(host.streamingUI.registerToolCall).toHaveBeenCalledTimes(2); - }); - - it('specializes only matching DynamicWorkflow results', () => { - const { host } = makeHost(); - const handler = new SessionEventHandler(host); - const result = vi - .spyOn(handler.subAgentEventHandler, 'handleDynamicWorkflowToolResult') - .mockImplementation(() => {}); - host.streamingUI.completeToolResult - .mockReturnValueOnce({ name: 'DynamicWorkflow' }) - .mockReturnValueOnce({ name: 'AgentSwarm' }); - - handler.handleEvent( - { - type: 'tool.result', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_dynamic_workflow', - output: 'dynamic result', - } as never, - vi.fn(), - ); - handler.handleEvent( - { - type: 'tool.result', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_removed_swarm', - output: 'legacy result', - } as never, - vi.fn(), - ); - - expect(result).toHaveBeenCalledTimes(1); - expect(result).toHaveBeenCalledWith( - 'call_dynamic_workflow', - expect.objectContaining({ output: 'dynamic result' }), - false, - ); - }); - - it('ignores a retired Dynamic Workflow result before mutating streaming state', () => { - const { host } = makeHost(); - const handler = new SessionEventHandler(host); - - handler.handleEvent( - { - type: 'tool.call.started', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_retired_workflow', - name: 'DynamicWorkflow', - args: { items: ['a'] }, - } as never, - vi.fn(), - ); - handler.clearDynamicWorkflowMissionControls(); - - const result = vi.spyOn(handler.subAgentEventHandler, 'handleDynamicWorkflowToolResult'); - host.streamingUI.setTurnId.mockClear(); - host.streamingUI.flushNow.mockClear(); - host.streamingUI.completeToolResult.mockClear(); - host.patchLivePane.mockClear(); - - handler.handleEvent( - { - type: 'tool.result', - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call_retired_workflow', - output: 'late result', - } as never, - vi.fn(), - ); - - expect(host.streamingUI.setTurnId).not.toHaveBeenCalled(); - expect(host.streamingUI.flushNow).not.toHaveBeenCalled(); - expect(host.streamingUI.completeToolResult).not.toHaveBeenCalled(); - expect(host.patchLivePane).not.toHaveBeenCalled(); - expect(result).not.toHaveBeenCalled(); - }); -}); - -describe('SessionEventHandler token speed', () => { - it('projects spend into the footer and retains pricing for /cost', () => { - const { host, footer, renderStatusBarExtras } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'agent.status.updated', - sessionId: 's1', - agentId: 'main', - modelCostRates: { input: 3, output: 15 }, - usage: { totalCostUsd: 0.125 }, - }, - vi.fn(), - ); - - expect(renderStatusBarExtras()).not.toContain('in $3/M out $15/M'); - expect(renderStatusBarExtras()).toContain('$0.13'); - expect(renderStatusBarExtras()).not.toContain('spent'); - expect(host.state.appState.modelCostRates).toEqual({ input: 3, output: 15 }); - expect(host.state.appState.totalCostUsd).toBe(0.125); - - handler.handleEvent( - { - type: 'agent.status.updated', - sessionId: 's1', - agentId: 'main', - model: 'unpriced-model', - usage: { totalCostUsd: 0.125 }, - }, - vi.fn(), - ); - - expect(host.state.appState.modelCostRates).toBeUndefined(); - expect(host.state.appState.totalCostUsd).toBe(0.125); - handler.resetRuntimeState(); - expect(host.state.appState.modelCostRates).toBeUndefined(); - expect(host.state.appState.totalCostUsd).toBeUndefined(); - } finally { - footer.dispose(); - } - }); - - it.each([ - { - name: 'assistant text', - event: (delta: string) => ({ - type: 'assistant.delta' as const, - sessionId: 's1', - agentId: 'main', - turnId: 1, - delta, - }), - }, - { - name: 'thinking text', - event: (delta: string) => ({ - type: 'thinking.delta' as const, - sessionId: 's1', - agentId: 'main', - turnId: 1, - delta, - }), - }, - { - name: 'tool-call arguments', - event: (argumentsPart: string) => ({ - type: 'tool.call.delta' as const, - sessionId: 's1', - agentId: 'main', - turnId: 1, - toolCallId: 'call-1', - name: 'Read', - argumentsPart, - }), - }, - ])('updates a live estimate from $name and replaces it with exact usage', ({ event }) => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'turn.step.started', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - }, - vi.fn(), - ); - - vi.setSystemTime(2_000); - handler.handleEvent(event('abcd'), vi.fn()); - vi.setSystemTime(3_000); - handler.handleEvent(event('x'.repeat(400)), vi.fn()); - - expect(renderStatusBarModel()).toContain('~100.0 t/s'); - - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - usage: { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 43, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('42.0 t/s'); - expect(renderStatusBarModel()).not.toContain('~42.0 t/s'); - } finally { - footer.dispose(); - } - }); - - it('keeps concurrent agent stream estimates separate', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - const event = (agentId: string, delta: string) => ({ - type: 'assistant.delta' as const, - sessionId: 's1', - agentId, - turnId: 1, - delta, - }); - try { - handler.handleEvent(event('agent-a', 'abcd'), vi.fn()); - vi.setSystemTime(500); - handler.handleEvent(event('agent-b', 'abcd'), vi.fn()); - vi.setSystemTime(1_000); - handler.handleEvent(event('agent-a', 'x'.repeat(400)), vi.fn()); - expect(renderStatusBarModel()).toContain('~100.0 t/s'); - - vi.setSystemTime(1_500); - handler.handleEvent(event('agent-b', 'x'.repeat(200)), vi.fn()); - expect(renderStatusBarModel()).toContain('~50.0 t/s'); - } finally { - footer.dispose(); - } - }); - - it('uses the latest valid main or child completed stream', () => { - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - usage: { - inputOther: 10, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 43, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('42.0 t/s'); - - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'agent-1', - turnId: 1, - step: 1, - usage: { - inputOther: 10, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 101, - }, - llmStreamDurationMs: 2_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('50.0 t/s'); - } finally { - footer.dispose(); - } - }); - - it.each([ - ['missing usage', undefined, 1_000], - ['one output token', { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 1, - }, 1_000], - ['zero duration', { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 10, - }, 0], - ['non-finite output', { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: Number.NaN, - }, 1_000], - ['non-finite duration', { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 10, - }, Number.NaN], - ] as const)('ignores %s', (_label, usage, llmStreamDurationMs) => { - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - usage: { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 43, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('42.0 t/s'); - - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 2, - usage, - llmStreamDurationMs, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('42.0 t/s'); - } finally { - footer.dispose(); - } - }); - - it('clears completed throughput when the turn ends', () => { - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - usage: { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 43, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('42.0 t/s'); - - handler.handleEvent(turnEndedEvent(), vi.fn()); - - expect(renderStatusBarModel()).not.toContain('t/s'); - } finally { - footer.dispose(); - } - }); - - it('ignores replayed completion metrics and clears on runtime reset', () => { - const { host, footer, renderStatusBarModel } = makeTokenSpeedHost(); - const handler = new SessionEventHandler(host); - try { - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 1, - usage: { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 11, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('10.0 t/s'); - - host.state.appState.isReplaying = true; - handler.handleEvent( - { - type: 'turn.step.completed', - sessionId: 's1', - agentId: 'main', - turnId: 1, - step: 2, - usage: { - inputOther: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - output: 101, - }, - llmStreamDurationMs: 1_000, - }, - vi.fn(), - ); - expect(renderStatusBarModel()).toContain('10.0 t/s'); - - handler.resetRuntimeState(); - expect(renderStatusBarModel()).not.toContain('t/s'); - } finally { - footer.dispose(); - } - }); -}); - describe('SessionEventHandler goal queue promotion', () => { beforeEach(() => { vi.mocked(readGoalQueue).mockClear(); @@ -750,7 +207,6 @@ describe('SessionEventHandler goal queue promotion', () => { text: 'Ship queued goal', }); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); - expect(host.track).toHaveBeenCalledWith('goal_create', { replace: false }); }); it('waits for queued user input to drain before promoting the next queued goal', async () => { @@ -800,6 +256,76 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); }); + it('defers queued-goal promotion while a queued message is mid-dispatch', async () => { + const { host, session } = makeHost(); + host.state.appState.streamingPhase = 'idle'; + host.state.queuedMessages = []; + // The queue looks empty and the phase is idle, but a shifted queued message + // is still awaiting its deferred send. Promotion must not jump ahead of it. + host.state.queuedMessageDispatchPending = true; + const handler = new SessionEventHandler(host); + + handler.requestQueuedGoalPromotion(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.createGoal).not.toHaveBeenCalled(); + + // Once the queued message has been dispatched, the flag clears and the + // promotion proceeds on the next retry. + host.state.queuedMessageDispatchPending = false; + handler.retryQueuedGoalPromotion(); + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + }); + + it('waits for a queued user input drained after compaction before promoting the next queued goal', async () => { + const { host, session } = makeHost(); + host.state.appState.isCompacting = true; + host.state.queuedMessages = [{ text: 'queued user turn' }]; + host.shiftQueuedMessage.mockImplementation(() => host.state.queuedMessages.shift()); + const handler = new SessionEventHandler(host); + host.setAppState.mockImplementation((patch: Record<string, unknown>) => { + const busyChanged = 'streamingPhase' in patch || 'isCompacting' in patch; + Object.assign(host.state.appState, patch); + if (busyChanged) handler.retryQueuedGoalPromotion(); + }); + host.sendQueuedMessage.mockImplementation((_session: unknown, item: { text: string }) => { + if (item.text === 'queued user turn') { + host.setAppState({ streamingPhase: 'waiting' }); + } + }); + const sendQueued = sendQueuedViaHost(host, session); + + handler.requestQueuedGoalPromotion(); + handler.handleEvent(compactionCompletedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'queued user turn' }); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledWith({ + objective: 'Ship queued goal', + replace: false, + }); + }); + const sendQueuedCalls = host.sendQueuedMessage.mock.calls as Array<[unknown, { text?: string }]>; + const userMessageIndex = sendQueuedCalls.findIndex( + ([, item]) => item.text === 'queued user turn', + ); + expect(userMessageIndex).toBeGreaterThanOrEqual(0); + expect(host.sendQueuedMessage).toHaveBeenLastCalledWith(session, { text: 'Ship queued goal' }); + const userMessageOrder = host.sendQueuedMessage.mock.invocationCallOrder[userMessageIndex]!; + const goalCreateOrder = session.createGoal.mock.invocationCallOrder[0]!; + expect(userMessageOrder).toBeLessThan(goalCreateOrder); + }); + it('leaves the queued goal in place when the next goal cannot start', async () => { const { host, session } = makeHost({ createGoalRejects: true }); const handler = new SessionEventHandler(host); @@ -940,7 +466,7 @@ describe('SessionEventHandler goal queue promotion', () => { handler.handleEvent(modelBlockedEvent(), vi.fn()); - expect(host.state.transcriptContainer.addTranscriptChild).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); }); it('renders a blocked fallback when the model does not explain the blocked goal', () => { @@ -970,7 +496,7 @@ describe('SessionEventHandler goal queue promotion', () => { ); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(host.state.transcriptContainer.addTranscriptChild).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); }); it('does not render a blocked fallback after earlier assistant text in the same turn', () => { @@ -990,7 +516,7 @@ describe('SessionEventHandler goal queue promotion', () => { handler.handleEvent(modelBlockedEvent(), vi.fn()); handler.handleEvent(turnEndedEvent(), vi.fn()); - expect(host.state.transcriptContainer.addTranscriptChild).not.toHaveBeenCalled(); + expect(host.state.transcriptContainer.addChild).not.toHaveBeenCalled(); }); it('does not promote on paused or cancelled updates', async () => { @@ -1013,226 +539,3 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).not.toHaveBeenCalled(); }); }); - -describe('SessionEventHandler MCP startup status', () => { - it('builds aggregate startup copy for loading, success, issues, and empty states', () => { - expect( - buildMcpStartupStatusLine([ - { name: 'ready', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'loading-a', transport: 'stdio', status: 'pending', toolCount: 0 }, - { name: 'loading-b', transport: 'http', status: 'pending', toolCount: 0 }, - { name: 'disabled', transport: 'stdio', status: 'disabled', toolCount: 0 }, - ]), - ).toEqual({ - label: 'MCP servers · 1/3 connected · 2 loading…', - color: 'primary', - loading: true, - transient: false, - }); - expect( - buildMcpStartupStatusLine([ - { name: 'a', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'b', transport: 'http', status: 'connected', toolCount: 1 }, - ]), - ).toEqual({ - label: 'MCP servers · 2/2 connected · 3 tools', - color: 'success', - loading: false, - transient: true, - }); - expect( - buildMcpStartupStatusLine([ - { name: 'a', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'b', transport: 'http', status: 'failed', toolCount: 0 }, - { name: 'c', transport: 'http', status: 'needs-auth', toolCount: 0 }, - ]), - ).toEqual({ - label: 'MCP servers · 1/3 connected · 1 failed · 1 needs auth · /mcp for details', - color: 'error', - loading: false, - transient: false, - }); - expect(buildMcpStartupStatusLine([])).toBeNull(); - expect( - buildMcpStartupStatusLine([ - { name: 'disabled', transport: 'stdio', status: 'disabled', toolCount: 0 }, - ]), - ).toBeNull(); - }); - - it('updates one aggregate MCP startup row in place', async () => { - vi.useFakeTimers(); - vi.stubEnv('PYTHINKER_NO_ANIMATION', ''); - vi.stubEnv('CI', ''); - vi.stubEnv('NO_COLOR', ''); - const { host, session } = makeHost(); - const container = new Container(); - host.state.mcpStatusContainer = container; - session.listMcpServers = vi.fn(async () => [ - { name: 'ready', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'second', transport: 'stdio', status: 'pending', toolCount: 0 }, - { name: 'third', transport: 'http', status: 'pending', toolCount: 0 }, - { name: 'fourth', transport: 'http', status: 'pending', toolCount: 0 }, - ]) as never; - const handler = new SessionEventHandler(host); - - await handler.syncMcpServerStatusSnapshot(session as never); - expect(renderContainer(container)).toContain( - 'MCP servers · 1/4 connected · 3 loading…', - ); - - handler.handleEvent({ - type: 'mcp.server.status', - sessionId: 's1', - agentId: 'main', - server: { - name: 'second', - transport: 'stdio', - status: 'connected', - toolCount: 1, - }, - } as never, () => {}); - - const output = renderContainer(container); - expect(output).toContain('MCP servers · 2/4 connected · 2 loading…'); - expect(occurrences(output, 'MCP servers')).toBe(1); - expect(output).not.toContain('"second"'); - expect(host.state.transcriptContainer.addTranscriptChild).not.toHaveBeenCalled(); - handler.disposeMcpServerStatusRows(); - expect(vi.getTimerCount()).toBe(0); - }); - - it('expires a healthy aggregate row but keeps issue summaries', async () => { - vi.useFakeTimers(); - vi.stubEnv('PYTHINKER_NO_ANIMATION', ''); - vi.stubEnv('CI', ''); - vi.stubEnv('NO_COLOR', ''); - const healthy = makeHost(); - const healthyContainer = new Container(); - healthy.host.state.mcpStatusContainer = healthyContainer; - healthy.session.listMcpServers = vi.fn(async () => [ - { name: 'first', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'second', transport: 'http', status: 'connected', toolCount: 1 }, - ]) as never; - const healthyHandler = new SessionEventHandler(healthy.host); - - await healthyHandler.syncMcpServerStatusSnapshot(healthy.session as never); - expect(renderContainer(healthyContainer)).toContain( - '✓ MCP servers · 2/2 connected · 3 tools', - ); - vi.advanceTimersByTime(MCP_STATUS_TRANSIENT_DURATION_MS); - expect(renderContainer(healthyContainer)).not.toContain('MCP servers'); - - const issues = makeHost(); - const issueContainer = new Container(); - issues.host.state.mcpStatusContainer = issueContainer; - issues.session.listMcpServers = vi.fn(async () => [ - { name: 'ready', transport: 'stdio', status: 'connected', toolCount: 2 }, - { name: 'failed', transport: 'http', status: 'failed', toolCount: 0 }, - { name: 'auth', transport: 'http', status: 'needs-auth', toolCount: 0 }, - { name: 'disabled', transport: 'stdio', status: 'disabled', toolCount: 0 }, - ]) as never; - const issueHandler = new SessionEventHandler(issues.host); - - await issueHandler.syncMcpServerStatusSnapshot(issues.session as never); - vi.advanceTimersByTime(MCP_STATUS_TRANSIENT_DURATION_MS * 2); - expect(renderContainer(issueContainer)).toContain( - '✗ MCP servers · 1/3 connected · 1 failed · 1 needs auth · /mcp for details', - ); - issueHandler.disposeMcpServerStatusRows(); - expect(vi.getTimerCount()).toBe(0); - }); - - it('keeps a live MCP event newer than an in-flight snapshot', async () => { - vi.useFakeTimers(); - const { host, session } = makeHost(); - const container = new Container(); - host.state.mcpStatusContainer = container; - let resolveSnapshot: (servers: McpServerStatusSnapshot[]) => void = () => {}; - session.listMcpServers = vi.fn(() => new Promise((resolve) => { - resolveSnapshot = resolve; - })) as never; - const handler = new SessionEventHandler(host); - - const syncing = handler.syncMcpServerStatusSnapshot(session as never); - handler.handleEvent({ - type: 'mcp.server.status', - sessionId: 's1', - agentId: 'main', - server: { - name: 'live', - transport: 'stdio', - status: 'connected', - toolCount: 2, - }, - } as never, () => {}); - resolveSnapshot([ - { - name: 'live', - transport: 'stdio', - status: 'failed', - toolCount: 0, - error: 'stale failure', - }, - { name: 'other', transport: 'http', status: 'pending', toolCount: 0 }, - ]); - await syncing; - - const output = renderContainer(container); - expect(output).toContain('MCP servers · 1/2 connected · 1 loading…'); - expect(output).not.toContain('stale failure'); - handler.disposeMcpServerStatusRows(); - }); - - it('ignores an older same-session snapshot after reset and a newer sync', async () => { - vi.useFakeTimers(); - const { host, session } = makeHost(); - const container = new Container(); - host.state.mcpStatusContainer = container; - let resolveOldSnapshot: (servers: McpServerStatusSnapshot[]) => void = () => {}; - session.listMcpServers = vi.fn(() => new Promise((resolve) => { - resolveOldSnapshot = resolve; - })) as never; - const handler = new SessionEventHandler(host); - - const oldSync = handler.syncMcpServerStatusSnapshot(session as never); - handler.resetRuntimeState(); - session.listMcpServers = vi.fn(async () => []) as never; - await handler.syncMcpServerStatusSnapshot(session as never); - expect(renderContainer(container)).not.toContain('MCP servers'); - expect(host.state.appState.mcpServersSummary).toBeNull(); - expect(host.refreshSkillCommands).toHaveBeenCalledOnce(); - - resolveOldSnapshot([ - { name: 'stale', transport: 'http', status: 'failed', toolCount: 0 }, - ]); - await oldSync; - - expect(renderContainer(container)).not.toContain('MCP servers'); - expect(host.state.appState.mcpServersSummary).toBeNull(); - expect(host.refreshSkillCommands).toHaveBeenCalledOnce(); - }); -}); - -describe('SessionEventHandler hook status', () => { - it('shows configured hook status only while the hook is running', () => { - const { host } = makeHost(); - const transcriptContainer = new TranscriptContainer(0, 0); - host.state.transcriptContainer = transcriptContainer as never; - const handler = new SessionEventHandler(host); - const event = { - type: 'hook.status', - sessionId: 's1', - agentId: 'main', - statusId: 'hook-1', - hookEvent: 'Stop', - content: 'Checking the result', - } as const; - - handler.handleEvent({ ...event, active: true } as never, () => {}); - expect(transcriptContainer.render(120).join('\n')).toContain('Checking the result'); - - handler.handleEvent({ ...event, active: false } as never, () => {}); - expect(transcriptContainer.render(120).join('\n')).not.toContain('Checking the result'); - }); -}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts new file mode 100644 index 00000000..9c53a940 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PluginUpdateNotifier } from '#/tui/controllers/plugin-update-notifier'; +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +const DATASOURCE_TOOL = 'mcp__plugin-pythinker-datasource_data__call_data_source_tool'; + +function makeHost() { + const streamingUI = { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + setStep: vi.fn(), + finalizeTurn: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), + registerToolCall: vi.fn(), + completeToolResult: vi.fn(), + setTodoList: vi.fn(), + }; + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + model: 'pythinker-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: {}, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI, + requireSession: vi.fn(() => ({})), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + updateActivityPane: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as never, streamingUI }; +} + +function makeNotifier() { + return { + handleMcpToolCompleted: vi.fn(), + handlePluginCommandCompleted: vi.fn(), + }; +} + +function toolCallStarted(name: string) { + return { + type: 'tool.call.started', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + name, + args: {}, + } as never; +} + +function toolResult() { + return { + type: 'tool.result', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + output: 'ok', + } as never; +} + +function turnEnded(reason: string, turnId = 1) { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId, + reason, + } as never; +} + +function pluginCommandTurnStarted() { + return { + type: 'turn.started', + sessionId: 's1', + agentId: 'main', + turnId: 2, + origin: { + kind: 'plugin_command', + activationId: 'a1', + pluginId: 'pythinker-datasource', + commandName: 'setup', + trigger: 'user-slash', + }, + } as never; +} + +const sendQueued = (): void => {}; + +describe('SessionEventHandler plugin update notices', () => { + it('reports plugin MCP usage only when the turn ends', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + // The tool result alone must not trigger the notice mid-turn. + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledWith(DATASOURCE_TOOL); + }); + + it('skips the notice for a cancelled turn and clears the buffer', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('cancelled'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + // A later completed turn must not replay the cancelled turn's usage. + handler.handleEvent(turnEnded('completed', 3), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tools', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: 'Bash', args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted('Bash'), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('reports a finished plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('completed', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('pythinker-datasource'); + }); + + it('skips a cancelled plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('cancelled', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts new file mode 100644 index 00000000..e21dc6f3 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + isCompacting: false, + model: 'pythinker-model', + permissionMode: 'auto', + stepRetry: null, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: vi.fn(), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record<string, unknown>) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +const retryingEvent = { + type: 'turn.step.retrying', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, +} as const; + +describe('SessionEventHandler step retry state', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stores the retry snapshot when a step starts retrying', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toEqual({ + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + }); + }); + + it('drives the pane back to waiting so mid-stream retries render', () => { + const { host } = makeHost(); + host.state.appState.streamingPhase = 'composing'; + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.patchLivePane).toHaveBeenCalledWith({ mode: 'waiting' }); + expect(host.state.appState.streamingPhase).toBe('waiting'); + }); + + it.each([ + [{ type: 'turn.step.completed', turnId: 1, step: 1 }, 'turn.step.completed'], + [ + { type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'error' }, + 'turn.step.interrupted', + ], + [{ type: 'turn.ended', turnId: 1, reason: 'completed' }, 'turn.ended'], + [ + { type: 'tool.result', turnId: 1, toolCallId: 'tc1', output: 'ok', isError: false }, + 'tool.result', + ], + ])('clears the retry snapshot on %s', (event, _label) => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).not.toBeNull(); + handler.handleEvent( + { sessionId: 's1', agentId: 'main', ...event } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('flips to attempt phase once the backoff delay elapses', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + vi.advanceTimersByTime(4000); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'attempt' }); + }); + + it('cancels the phase flip when the retry is cleared during the backoff', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { + type: 'turn.step.interrupted', + sessionId: 's1', + agentId: 'main', + turnId: 1, + step: 1, + reason: 'error', + } as any, + vi.fn(), + ); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toBeNull(); + }); + + it('keeps the retry snapshot on turn.step.started (v2 re-emits it per attempt)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.handleEvent( + { type: 'turn.step.started', sessionId: 's1', agentId: 'main', turnId: 1, step: 1 } as any, + vi.fn(), + ); + expect(host.state.appState.stepRetry).toMatchObject({ nextAttempt: 2, phase: 'backoff' }); + }); + + it('cancels the pending phase flip via clearStepRetryAttemptTimer (TUI shutdown path)', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + handler.handleEvent(retryingEvent as any, vi.fn()); + handler.clearStepRetryAttemptTimer(); + vi.advanceTimersByTime(10_000); + expect(host.state.appState.stepRetry).toMatchObject({ phase: 'backoff' }); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts b/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts new file mode 100644 index 00000000..3c9c1e1d --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/staging-leases.test.ts @@ -0,0 +1,364 @@ +import type { TurnEndedEvent, TurnStartedEvent } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { + StagingLeaseTracker, + type StagingLeaseEffects, + type StagingLeaseOrigin, +} from '#/tui/controllers/staging-leases'; + +function turnStarted(turnId: number | string, kind: string, promptId?: string): TurnStartedEvent { + return { type: 'turn.started', agentId: 'main', turnId, origin: { kind }, promptId } as TurnStartedEvent; +} + +function turnEnded(turnId: number | string): TurnEndedEvent { + return { type: 'turn.ended', agentId: 'main', turnId, reason: 'completed' } as TurnEndedEvent; +} + +function makeEffects(): { + effects: StagingLeaseEffects; + takeFileIds: ReturnType<typeof vi.fn<(ids: readonly number[]) => readonly string[]>>; + releaseRetains: ReturnType<typeof vi.fn<(ids: readonly number[]) => void>>; + deleteFiles: ReturnType< + typeof vi.fn<(fileIds: readonly string[], paths: readonly string[]) => Promise<void>> + >; + warn: ReturnType<typeof vi.fn<(message: string) => void>>; + deleted: { fileIds: string[]; paths: string[] }; +} { + const deleted = { fileIds: [] as string[], paths: [] as string[] }; + const takeFileIds = vi.fn((ids: readonly number[]) => ids.map((id) => `file-${id}`)); + const releaseRetains = vi.fn((ids: readonly number[]) => void ids); + const deleteFiles = vi.fn((fileIds: readonly string[], paths: readonly string[]) => { + deleted.fileIds.push(...fileIds); + deleted.paths.push(...paths); + return Promise.resolve(); + }); + const warn = vi.fn((message: string) => void message); + return { + effects: { takeFileIds, releaseRetains, deleteFiles, warn }, + takeFileIds, + releaseRetains, + deleteFiles, + warn, + deleted, + }; +} + +function makeTracker(): ReturnType<typeof makeEffects> & { tracker: StagingLeaseTracker } { + const mocks = makeEffects(); + return { ...mocks, tracker: new StagingLeaseTracker(mocks.effects) }; +} + +describe('StagingLeaseTracker', () => { + describe('create', () => { + it('returns undefined when nothing is staged', () => { + const { tracker } = makeTracker(); + expect(tracker.create([], [], 'user')).toBeUndefined(); + }); + }); + + describe('turn claiming', () => { + it('claims the earliest unbound lease of the matching origin', () => { + const { tracker } = makeTracker(); + const first = tracker.create([], ['/cache/a'], 'user'); + const second = tracker.create([], ['/cache/b'], 'user'); + + tracker.handleTurnStarted(turnStarted(1, 'user')); + expect(first?.turnId).toBe('1'); + expect(second?.turnId).toBeUndefined(); + + tracker.handleTurnStarted(turnStarted(2, 'user')); + expect(second?.turnId).toBe('2'); + }); + + it('warns when several unclaimed same-origin leases make the heuristic claim ambiguous', () => { + const { tracker, warn } = makeTracker(); + tracker.create([], ['/cache/a'], 'user'); + tracker.create([], ['/cache/b'], 'user'); + + tracker.handleTurnStarted(turnStarted(1, 'user')); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn.mock.calls[0]![0]).toContain("'user'"); + expect(warn.mock.calls[0]![0]).toContain('1'); + }); + + it('stays silent while at most one same-origin lease is unclaimed', () => { + const { tracker, warn } = makeTracker(); + tracker.create([], ['/cache/a'], 'user'); + + tracker.handleTurnStarted(turnStarted(1, 'user')); + tracker.handleTurnStarted(turnStarted(2, 'user')); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('binds the exact lease when turn.started echoes its submission id', () => { + const { tracker, warn } = makeTracker(); + const earlier = tracker.create([], ['/cache/a'], 'user'); + const exact = tracker.create([], ['/cache/b'], 'user', 'sub-2'); + + // The exact id wins over the earlier unclaimed same-origin lease, and + // the ambiguity warning stays silent. + tracker.handleTurnStarted(turnStarted(1, 'user', 'sub-2')); + + expect(exact?.turnId).toBe('1'); + expect(earlier?.turnId).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('falls back to the origin heuristic when the promptId is unknown', () => { + const { tracker, warn } = makeTracker(); + const first = tracker.create([], ['/cache/a'], 'user', 'sub-1'); + const second = tracker.create([], ['/cache/b'], 'user'); + + tracker.handleTurnStarted(turnStarted(1, 'user', 'sub-unknown')); + + expect(first?.turnId).toBe('1'); + expect(second?.turnId).toBeUndefined(); + expect(warn).toHaveBeenCalledOnce(); + }); + + it('does not exact-bind a released lease whose submission id is echoed again', () => { + const { tracker } = makeTracker(); + const released = tracker.create([], ['/cache/a'], 'user', 'sub-1'); + tracker.release(released); + const fallback = tracker.create([], ['/cache/b'], 'user'); + + tracker.handleTurnStarted(turnStarted(1, 'user', 'sub-1')); + + expect(released?.turnId).toBeUndefined(); + expect(fallback?.turnId).toBe('1'); + }); + + it('ignores turns of other or unknown origins', () => { + const { tracker } = makeTracker(); + const lease = tracker.create([], ['/cache/a'], 'skill_activation'); + + tracker.handleTurnStarted(turnStarted(1, 'user')); + tracker.handleTurnStarted(turnStarted(2, 'plugin_command')); + tracker.handleTurnStarted(turnStarted(3, 'system_trigger')); + expect(lease?.turnId).toBeUndefined(); + + tracker.handleTurnStarted(turnStarted(4, 'skill_activation')); + expect(lease?.turnId).toBe('4'); + }); + + it('does not rebind a bound or released lease', () => { + const { tracker } = makeTracker(); + const lease = tracker.create([], ['/cache/a'], 'user'); + tracker.bindToTurn(lease, '1'); + tracker.bindToTurn(lease, '2'); + expect(lease?.turnId).toBe('1'); + + tracker.release(lease); + tracker.bindToTurn(lease, '3'); + expect(lease?.turnId).toBe('1'); + }); + }); + + describe('turn-end release', () => { + it('deletes daemon uploads but retires cache copies to session lifetime', () => { + const { tracker, deleted } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], 'user'); + tracker.bindToTurn(lease, '1'); + + tracker.handleTurnEnded(turnEnded(1)); + + expect(deleted.fileIds).toEqual(['file-1']); + expect(deleted.paths).toEqual([]); + }); + + it('deletes retired cache copies at session close', () => { + const { tracker, deleted } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], 'user'); + tracker.bindToTurn(lease, '1'); + tracker.handleTurnEnded(turnEnded(1)); + expect(deleted.paths).toEqual([]); + + tracker.releaseAll(); + expect(deleted.paths).toEqual(['/cache/a']); + }); + + it('releases a bound lease exactly once across repeated turn.ended events', () => { + const { tracker, deleteFiles } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], 'user'); + tracker.bindToTurn(lease, '1'); + + tracker.handleTurnEnded(turnEnded(1)); + tracker.handleTurnEnded(turnEnded(1)); + tracker.release(lease); + + expect(deleteFiles).toHaveBeenCalledTimes(1); + }); + + it('ignores turn.ended for unknown turns', () => { + const { tracker, deleteFiles } = makeTracker(); + tracker.create([1], ['/cache/a'], 'user'); + tracker.handleTurnEnded(turnEnded(99)); + expect(deleteFiles).not.toHaveBeenCalled(); + }); + + it('consumes one retain per id occurrence at turn end', () => { + const { tracker, takeFileIds } = makeTracker(); + // Multiplicity in the lease's id list is the retain count (creation + // sites dedupe per extraction): [7, 7] means two retains, e.g. a + // batched steer of two queued messages sharing the image. + tracker.create([7, 7], [], 'user', 'sub-dup'); + tracker.handleTurnStarted(turnStarted(1, 'user', 'sub-dup')); + + tracker.handleTurnEnded(turnEnded(1)); + + expect(takeFileIds.mock.calls).toEqual([[[7]], [[7]]]); + }); + }); + + describe('abandonment', () => { + // Every abandonment entry point deletes daemon uploads and cache copies + // immediately, whether or not a turn ever consumed the lease. + it.each([ + [ + 'release', + (tracker: StagingLeaseTracker) => { + tracker.release(tracker.create([1], ['/cache/a'], 'user')); + tracker.release(tracker.create([2], ['/cache/b'], 'user')); + }, + ], + [ + 'releaseMedia and releaseQueued', + (tracker: StagingLeaseTracker) => { + tracker.releaseMedia([1], ['/cache/a']); + tracker.releaseQueued([ + { text: 'q', agentId: 'main', imageAttachmentIds: [2], stagingPaths: ['/cache/b'] }, + ]); + }, + ], + [ + 'releaseAll', + (tracker: StagingLeaseTracker) => { + tracker.create([1], ['/cache/a'], 'user'); + tracker.bindToTurn(tracker.create([2], ['/cache/b'], 'user'), '1'); + tracker.releaseAll(); + }, + ], + ] as const)('%s deletes daemon uploads and cache copies immediately', (_name, abandon) => { + const { tracker, deleted } = makeTracker(); + + abandon(tracker); + + expect(deleted.fileIds).toEqual(['file-1', 'file-2']); + expect(deleted.paths).toEqual(['/cache/a', '/cache/b']); + }); + }); + + describe('queue recall', () => { + it('consumes only the retain and retires cache copies instead of deleting', () => { + const { tracker, releaseRetains, deleted } = makeTracker(); + + // A recall restores the draft into the editor — not a discard: the + // daemon upload stays staged (only the retain is consumed) and the + // cache copy retires to session lifetime. + tracker.releaseRecalled({ + imageAttachmentIds: [2], + stagingPaths: ['/cache/b'], + }); + + expect(releaseRetains).toHaveBeenCalledWith([2]); + expect(deleted.fileIds).toEqual([]); + expect(deleted.paths).toEqual([]); + + tracker.releaseAll(); + expect(deleted.fileIds).toEqual([]); + expect(deleted.paths).toEqual(['/cache/b']); + }); + }); + + describe('defer', () => { + it('unbinds the lease without consuming retains or deleting files', () => { + const { tracker, takeFileIds, releaseRetains, deleted } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], 'user', 'sub-1'); + + tracker.defer(lease); + + expect(lease?.released).toBe(true); + expect(takeFileIds).not.toHaveBeenCalled(); + expect(releaseRetains).not.toHaveBeenCalled(); + expect(deleted).toEqual({ fileIds: [], paths: [] }); + + // A deferred lease is gone for good: turn events cannot claim it and + // releaseAll does not sweep its media. + tracker.handleTurnStarted(turnStarted(1, 'user', 'sub-1')); + expect(lease?.turnId).toBeUndefined(); + tracker.releaseAll(); + expect(deleted).toEqual({ fileIds: [], paths: [] }); + }); + }); + + describe('trackDispatch', () => { + const origin: StagingLeaseOrigin = 'user'; + + it('keeps the lease when the dispatch resolves', async () => { + const { tracker, deleteFiles } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], origin); + const onError = vi.fn(); + + tracker.trackDispatch(lease, Promise.resolve(), onError); + await tracker.drain(); + + expect(onError).not.toHaveBeenCalled(); + expect(deleteFiles).not.toHaveBeenCalled(); + expect(lease?.released).toBe(false); + }); + + it('releases an unclaimed lease exactly once when the dispatch rejects', async () => { + const { tracker, deleted } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], origin); + const onError = vi.fn(); + + tracker.trackDispatch(lease, Promise.reject(new Error('boom')), onError); + await tracker.drain(); + + expect(onError).toHaveBeenCalledOnce(); + expect(deleted.fileIds).toEqual(['file-1']); + expect(deleted.paths).toEqual(['/cache/a']); + // A later turn end must not delete again. + tracker.handleTurnEnded(turnEnded(1)); + tracker.releaseAll(); + expect(deleted.fileIds).toEqual(['file-1']); + }); + + it('does not release a lease a turn already claimed when the dispatch rejects', async () => { + const { tracker, deleted } = makeTracker(); + const lease = tracker.create([1], ['/cache/a'], origin); + tracker.bindToTurn(lease, '7'); + + tracker.trackDispatch(lease, Promise.reject(new Error('boom')), vi.fn()); + await tracker.drain(); + expect(deleted.fileIds).toEqual([]); + + // The owning turn still releases it at turn end (uploads deleted, copies retired). + tracker.handleTurnEnded(turnEnded(7)); + expect(deleted.fileIds).toEqual(['file-1']); + expect(deleted.paths).toEqual([]); + }); + }); + + describe('track/drain', () => { + it('drain awaits in-flight cleanups and track swallows rejections', async () => { + const { tracker } = makeTracker(); + let settled = false; + tracker.track( + new Promise<void>((resolve) => { + setTimeout(() => { + settled = true; + resolve(); + }, 10); + }), + ); + tracker.track(Promise.reject(new Error('ignored'))); + + await tracker.drain(); + expect(settled).toBe(true); + }); + }); +}); diff --git a/apps/pythinker-code/test/tui/controllers/subagent-activity-store.test.ts b/apps/pythinker-code/test/tui/controllers/subagent-activity-store.test.ts new file mode 100644 index 00000000..5aa9409a --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/subagent-activity-store.test.ts @@ -0,0 +1,294 @@ +import type { Event } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + MAX_SUBAGENT_ACTIVITY_STEPS, + SUBAGENT_ARG_STRING_MAX_CHARS, + SUBAGENT_STEP_TEXT_TAIL_CHARS, + SUBAGENT_TOOL_OUTPUT_MAX_CHARS, +} from '#/tui/constant/rendering'; +import { STREAMING_ARGS_PREVIEW_MAX_CHARS } from '#/tui/constant/streaming'; +import { + SubagentActivityStore, + type SubagentActivitySpawn, +} from '#/tui/controllers/subagent-activity-store'; + +function ev(partial: Record<string, unknown>): Event { + return { sessionId: 's1', agentId: 'agent-1', ...partial } as unknown as Event; +} + +function spawn(overrides: Partial<SubagentActivitySpawn> = {}): SubagentActivitySpawn { + return { + agentId: 'agent-1', + agentName: 'explore', + description: 'find things', + parentToolCallId: 'tc-1', + model: 'K3', + effort: 'high', + ...overrides, + }; +} + +describe('SubagentActivityStore', () => { + it('folds a full step lifecycle (text + tool call + result)', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'Hello ' })); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'world' })); + store.applyEvent( + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 't1', name: 'Grep', args: { pattern: 'foo' } }), + ); + store.applyEvent( + ev({ type: 'tool.progress', turnId: 1, toolCallId: 't1', update: { kind: 'stdout', text: 'line1\nline2\n' } }), + ); + store.applyEvent( + ev({ type: 'tool.result', turnId: 1, toolCallId: 't1', output: 'a\nb\nc', isError: false }), + ); + + const record = store.get('agent-1'); + expect(record?.agentName).toBe('explore'); + expect(record?.steps).toHaveLength(1); + expect(record?.totalSteps).toBe(1); + expect(record?.steps[0]?.textTail).toBe('Hello world'); + const call = record?.steps[0]?.toolCalls[0]; + expect(call?.name).toBe('Grep'); + expect(call?.args).toEqual({ pattern: 'foo' }); + expect(call?.status).toBe('done'); + expect(call?.result?.output).toBe('a\nb\nc'); + expect(call?.result?.is_error).toBe(false); + expect(call?.liveOutputTail).toBeUndefined(); + expect(call?.durationMs).toBeGreaterThanOrEqual(0); + expect(record?.version).toBeGreaterThan(0); + }); + + it('creates a call from streaming deltas and replaces args on start', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', name: 'Bash', argumentsPart: '{"command":"ls' }), + ); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', argumentsPart: ' -la"}' }), + ); + + let record = store.get('agent-1'); + // No step event yet — a synthetic step holds the in-flight call. + expect(record?.steps).toHaveLength(1); + expect(record?.steps[0]?.toolCalls[0]?.args).toEqual({ command: 'ls -la' }); + + store.applyEvent( + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Bash', + args: { command: 'ls -la', timeout: 5 }, + }), + ); + record = store.get('agent-1'); + expect(record?.steps[0]?.toolCalls[0]?.args).toEqual({ command: 'ls -la', timeout: 5 }); + }); + + it('evicts whole steps beyond the cap while totalSteps keeps counting', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + for (let i = 0; i < MAX_SUBAGENT_ACTIVITY_STEPS + 2; i++) { + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: i })); + } + const record = store.get('agent-1'); + expect(record?.steps).toHaveLength(MAX_SUBAGENT_ACTIVITY_STEPS); + expect(record?.totalSteps).toBe(MAX_SUBAGENT_ACTIVITY_STEPS + 2); + expect(record?.steps[0]?.step).toBe(2); + }); + + it('keeps only the tail of long assistant text', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ + type: 'assistant.delta', + turnId: 1, + delta: 'x'.repeat(SUBAGENT_STEP_TEXT_TAIL_CHARS) + 'y'.repeat(100), + }), + ); + const step = store.get('agent-1')?.steps[0]; + expect(step?.textTail).toHaveLength(SUBAGENT_STEP_TEXT_TAIL_CHARS); + expect(step?.textTail.endsWith('y'.repeat(100))).toBe(true); + }); + + it('caps tool output and appends a truncation sentinel', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ type: 'tool.call.started', turnId: 1, toolCallId: 't1', name: 'Bash', args: {} }), + ); + store.applyEvent( + ev({ + type: 'tool.result', + turnId: 1, + toolCallId: 't1', + output: 'y'.repeat(SUBAGENT_TOOL_OUTPUT_MAX_CHARS + 100), + }), + ); + const call = store.get('agent-1')?.steps[0]?.toolCalls[0]; + expect(call?.result?.output.startsWith('yyy')).toBe(true); + expect(call?.result?.output).toContain('[output truncated'); + expect(call?.result?.output.length).toBeLessThan(SUBAGENT_TOOL_OUTPUT_MAX_CHARS + 120); + }); + + it('marks the current step on retry without opening a new one', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.applyEvent( + ev({ + type: 'turn.step.retrying', + turnId: 1, + step: 0, + nextAttempt: 2, + maxAttempts: 5, + errorName: 'RateLimitError', + }), + ); + let record = store.get('agent-1'); + expect(record?.steps).toHaveLength(1); + expect(record?.steps[0]?.retrying).toContain('2/5'); + + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 1 })); + record = store.get('agent-1'); + expect(record?.steps[1]?.retrying).toBeUndefined(); + }); + + it('implicitly creates a record for events from an unseen agent', () => { + const store = new SubagentActivityStore(); + store.applyEvent(ev({ type: 'assistant.delta', turnId: 1, delta: 'hi' })); + const record = store.get('agent-1'); + expect(record?.agentName).toBe('agent-1'); + expect(record?.steps[0]?.textTail).toBe('hi'); + }); + + it('drops results for unknown agents instead of creating records', () => { + const store = new SubagentActivityStore(); + store.applyEvent(ev({ type: 'tool.result', turnId: 1, toolCallId: 't1', output: 'x' })); + expect(store.get('agent-1')).toBeUndefined(); + }); + + it('caps the raw streaming-args buffer at the preview window', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ + type: 'tool.call.delta', + turnId: 1, + toolCallId: 't1', + name: 'Write', + argumentsPart: 'x'.repeat(STREAMING_ARGS_PREVIEW_MAX_CHARS + 1000), + }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map<string, string> } + ).streamingArgs; + expect(buffers.get('agent-1:t1')?.length).toBeLessThanOrEqual(STREAMING_ARGS_PREVIEW_MAX_CHARS); + }); + + it('tracks terminal state and resets it on respawn', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.markCompleted('agent-1', 'done summary'); + let record = store.get('agent-1'); + expect(record?.status).toBe('completed'); + expect(record?.resultSummary).toBe('done summary'); + + store.ensureRecord(spawn()); + record = store.get('agent-1'); + expect(record?.status).toBe('running'); + expect(record?.resultSummary).toBeUndefined(); + + store.markFailed('agent-1', 'boom'); + record = store.get('agent-1'); + expect(record?.status).toBe('failed'); + expect(record?.error).toBe('boom'); + }); + + it('clear() releases all records', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: 0 })); + store.clear(); + expect(store.get('agent-1')).toBeUndefined(); + }); + + it('drop() removes one record along with its streaming buffers', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.ensureRecord(spawn({ agentId: 'agent-2', agentName: 'general' })); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't1', name: 'Write', argumentsPart: '{"path":"a"}' }), + ); + + store.drop('agent-1'); + + expect(store.get('agent-1')).toBeUndefined(); + expect(store.get('agent-2')).toBeDefined(); + const buffers = ( + store as unknown as { streamingArgs: Map<string, string> } + ).streamingArgs; + expect([...buffers.keys()].every((key) => !key.startsWith('agent-1:'))).toBe(true); + }); + + it('caps long string argument values retained in a record', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Write', + args: { path: 'a.ts', content: 'c'.repeat(SUBAGENT_ARG_STRING_MAX_CHARS + 500) }, + }), + ); + const call = store.get('agent-1')?.steps[0]?.toolCalls[0]; + expect(typeof call?.args['content']).toBe('string'); + expect((call?.args['content'] as string).length).toBeLessThanOrEqual( + SUBAGENT_ARG_STRING_MAX_CHARS + 1, + ); + expect(call?.args['path']).toBe('a.ts'); + }); + + it('drops delta-only arg buffers when their step is evicted', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + // A call truncated before started/result only ever produced deltas. + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't-trunc', name: 'Write', argumentsPart: '{"path":"a"' }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map<string, string> } + ).streamingArgs; + expect(buffers.has('agent-1:t-trunc')).toBe(true); + + for (let i = 0; i < MAX_SUBAGENT_ACTIVITY_STEPS; i++) { + store.applyEvent(ev({ type: 'turn.step.started', turnId: 1, step: i })); + } + expect(buffers.has('agent-1:t-trunc')).toBe(false); + }); + + it('drops leftover arg buffers when the record turns terminal', () => { + const store = new SubagentActivityStore(); + store.ensureRecord(spawn()); + store.applyEvent( + ev({ type: 'tool.call.delta', turnId: 1, toolCallId: 't-trunc', name: 'Write', argumentsPart: '{"path":"a"' }), + ); + const buffers = ( + store as unknown as { streamingArgs: Map<string, string> } + ).streamingArgs; + expect(buffers.has('agent-1:t-trunc')).toBe(true); + + store.markCompleted('agent-1', 'done'); + expect(buffers.has('agent-1:t-trunc')).toBe(false); + }); +}); diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index 25a8f9a1..f4bc2bb7 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -1,20 +1,22 @@ import { describe, it, expect, vi } from 'vitest'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { TuiAltScreen, TuiMainScreen } from '@pymodel/pi-tui'; + import { createTUIState, type PythinkerTUIOptions } from '#/tui/pythinker-tui'; -import { LegacyPiPresentation } from '#/tui/runtime/legacy-pi-presentation'; import type { AppState } from '#/tui/types'; function fakeInitialAppState(): AppState { return { model: 'test-model', workDir: '/tmp/pythinker-test', + additionalDirs: [], sessionId: 'sess-1', permissionMode: 'manual', planMode: false, + inputMode: 'prompt', dynamicWorkflowMode: false, -thinkingLevel: 'off', + thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -22,12 +24,12 @@ thinkingLevel: 'off', isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: 'dark', version: '0.0.0-test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, availableModels: {}, availableProviders: {}, sessionTitle: null, @@ -36,27 +38,6 @@ thinkingLevel: 'off', } describe('createTUIState', () => { - it('gates rendering until the event loop starts, and ui.start() re-opens it', () => { - // The gate writes pi-tui's PRIVATE `stopped` field, and `ui.start()` clearing - // it again is what lets the TUI ever paint. If an upgrade renames the field or - // stops resetting it, the app renders nothing — a silent, total failure. This - // asserts both halves so that upgrade fails here instead of in someone's terminal. - const state = createTUIState({ - initialAppState: fakeInitialAppState(), - startup: { continueLast: false, yolo: false, auto: false, plan: false }, - layout: 'fixed', - }); - const ui = state.ui as unknown as { stopped: boolean; start: () => void }; - - expect(ui.stopped).toBe(true); - - vi.spyOn(state.terminal, 'start').mockImplementation(() => {}); - vi.spyOn(state.terminal, 'hideCursor').mockImplementation(() => {}); - ui.start(); - - expect(ui.stopped).toBe(false); - }); - it('initializes all fields with sensible defaults', () => { const opts: PythinkerTUIOptions = { initialAppState: fakeInitialAppState(), @@ -66,7 +47,6 @@ describe('createTUIState', () => { auto: false, plan: false, }, - layout: 'inline', }; const state = createTUIState(opts); @@ -74,14 +54,9 @@ describe('createTUIState', () => { expect(state.ui).toBeDefined(); expect(state.terminal).toBeDefined(); expect(state.transcriptContainer).toBeDefined(); - expect(state.transcriptViewport).toBeDefined(); - expect(state.layoutRoot).toBeDefined(); - expect(state.footerWrap).toBeDefined(); - expect(state.layout).toBe('inline'); expect(state.activityContainer).toBeDefined(); expect(state.todoPanelContainer).toBeDefined(); expect(state.queueContainer).toBeDefined(); - expect(state.mcpStatusContainer).toBeDefined(); expect(state.editorContainer).toBeDefined(); expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); @@ -91,8 +66,8 @@ describe('createTUIState', () => { // App state is cloned from initialAppState, not reused by reference. expect(state.appState).not.toBe(opts.initialAppState); expect(state.appState.model).toBe('test-model'); + expect(state.appState.additionalDirs).toEqual([]); expect(state.appState.sessionId).toBe('sess-1'); - expect(state.appState.statusLine).toEqual(DEFAULT_STATUS_LINE_CONFIG); expect(state.startupState).toBe('pending'); // LivePane defaults. @@ -113,7 +88,7 @@ describe('createTUIState', () => { expect(state.activitySpinner).toBeNull(); }); - it('starts pi-tui with its input and resize handlers through the legacy presentation', () => { + it('uses the main-screen renderer by default', () => { const state = createTUIState({ initialAppState: fakeInitialAppState(), startup: { @@ -122,28 +97,15 @@ describe('createTUIState', () => { auto: false, plan: false, }, - layout: 'inline', }); - const presentation = new LegacyPiPresentation(state); - let resizeHandler: (() => void) | undefined; - const terminalStart = vi - .spyOn(state.terminal, 'start') - .mockImplementation((_inputHandler, onResize) => { - resizeHandler = onResize; - }); - vi.spyOn(state.terminal, 'hideCursor').mockImplementation(() => {}); - const requestRender = vi.spyOn(state.ui, 'requestRender').mockImplementation(() => {}); - - presentation.start(() => {}); - - expect(terminalStart).toHaveBeenCalledOnce(); - expect(resizeHandler).toBeTypeOf('function'); - requestRender.mockClear(); - resizeHandler?.(); - expect(requestRender).toHaveBeenCalledOnce(); + + expect(state.ui).toBeInstanceOf(TuiMainScreen); + expect(state.ui.mode).toBe('regular'); + expect(state.dockContainer).toBeUndefined(); }); - it('delegates terminal, composer, idle, and shutdown operations without translation', async () => { + it('builds an alternate-screen renderer with a docked layout in fullscreen mode', () => { + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); const state = createTUIState({ initialAppState: fakeInitialAppState(), startup: { @@ -152,40 +114,34 @@ describe('createTUIState', () => { auto: false, plan: false, }, - layout: 'inline', }); - const presentation = new LegacyPiPresentation(state); - const stop = vi.spyOn(state.ui, 'stop').mockImplementation(() => {}); - const drainInput = vi.spyOn(state.terminal, 'drainInput').mockResolvedValue(); - const setTitle = vi.spyOn(state.terminal, 'setTitle').mockImplementation(() => {}); - const setProgress = vi.spyOn(state.terminal, 'setProgress').mockImplementation(() => {}); - const write = vi.spyOn(state.terminal, 'write').mockImplementation(() => {}); - const getText = vi.spyOn(state.editor, 'getText').mockReturnValue('draft'); - const setText = vi.spyOn(state.editor, 'setText').mockImplementation(() => {}); - const setFocus = vi.spyOn(state.ui, 'setFocus').mockImplementation(() => {}); - const addToHistory = vi.spyOn(state.editor, 'addToHistory').mockImplementation(() => {}); - const requestRender = vi.spyOn(state.ui, 'requestRender').mockImplementation(() => {}); - - presentation.setTerminalTitle('Title'); - presentation.setTerminalProgress(true); - presentation.writeTerminalControl('\u001B[2J'); - expect(presentation.getComposerText()).toBe('draft'); - presentation.setComposerText('next'); - presentation.focusComposer(); - presentation.addComposerHistory('previous'); - presentation.notifyIdle(); - await presentation.drainInput(); - presentation.stop(); - - expect(setTitle).toHaveBeenCalledWith('Title'); - expect(setProgress).toHaveBeenCalledWith(true); - expect(write).toHaveBeenCalledWith('\u001B[2J'); - expect(getText).toHaveBeenCalledOnce(); - expect(setText).toHaveBeenCalledWith('next'); - expect(setFocus).toHaveBeenCalledWith(state.editor); - expect(addToHistory).toHaveBeenCalledWith('previous'); - expect(requestRender).toHaveBeenCalledOnce(); - expect(drainInput).toHaveBeenCalledOnce(); - expect(stop).toHaveBeenCalledOnce(); + vi.unstubAllEnvs(); + + expect(state.ui).toBeInstanceOf(TuiAltScreen); + expect(state.ui.mode).toBe('fullscreen'); + + // The chrome docks below the transcript ScrollView, in z-order. + const dock = state.dockContainer; + expect(dock).toBeDefined(); + expect(dock?.children).toEqual([ + state.activityContainer, + state.todoPanelContainer, + state.queueContainer, + state.btwPanelContainer, + state.editorContainer, + ]); + + // The layout root is mounted and the root children list stays empty. + expect((state.ui as TuiAltScreen).getLayoutRoot()).toBeDefined(); + expect(state.ui.children).toHaveLength(0); + + // Mouse capture replaces native terminal link activation / right-click + // paste, so both must be routed through renderer callbacks. + const internals = state.ui as unknown as { + openUrl?: (url: string) => void; + onRightClickPaste?: () => void; + }; + expect(typeof internals.openUrl).toBe('function'); + expect(typeof internals.onRightClickPaste).toBe('function'); }); }); diff --git a/apps/pythinker-code/test/tui/easter-eggs/dance.test.ts b/apps/pythinker-code/test/tui/easter-eggs/dance.test.ts new file mode 100644 index 00000000..5406a299 --- /dev/null +++ b/apps/pythinker-code/test/tui/easter-eggs/dance.test.ts @@ -0,0 +1,264 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + DANCE_FLOW_MS, + DANCE_FRAME_MS, + getRainbowDanceView, + installRainbowDance, + RainbowDance, + rainbowText, + setRainbowDance, + tryHandleDanceCommand, +} from '#/tui/easter-eggs/dance'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { darkColors } from '#/tui/theme/colors'; + +const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; + +/** Ordered list of "r,g,b" truecolor codes in the order they appear. */ +function truecolorCodes(text: string): string[] { + return [...text.matchAll(TRUECOLOR_PATTERN)].map((m) => `${m[1]},${m[2]},${m[3]}`); +} + +describe('RainbowDance', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts uncolored — the banner keeps its default look', () => { + const dance = new RainbowDance(vi.fn()); + + expect(dance.colored).toBe(false); + expect(dance.phase).toBe(0); + }); + + it('flows while dancing and requests renders', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const dance = new RainbowDance(requestRender); + + dance.start({ hold: false }); + expect(dance.colored).toBe(true); + + const before = dance.phase; + vi.advanceTimersByTime(DANCE_FRAME_MS); + expect(dance.phase).not.toBe(before); + expect(requestRender).toHaveBeenCalled(); + }); + + it('fades back to default after the flow when not holding', () => { + vi.useFakeTimers(); + const dance = new RainbowDance(vi.fn()); + + dance.start({ hold: false }); + vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS); + + expect(dance.colored).toBe(false); + expect(dance.phase).toBe(0); + }); + + it('freezes into a static rainbow after the flow when holding', () => { + vi.useFakeTimers(); + const dance = new RainbowDance(vi.fn()); + + dance.start({ hold: true }); + vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS); + + expect(dance.colored).toBe(true); + const frozen = dance.phase; + vi.advanceTimersByTime(DANCE_FRAME_MS * 10); + expect(dance.phase).toBe(frozen); + }); + + it('stops on demand back to the default colors and clears its timers', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const dance = new RainbowDance(requestRender); + + dance.start({ hold: true }); + vi.advanceTimersByTime(DANCE_FRAME_MS * 3); + expect(dance.phase).toBeGreaterThan(0); + + requestRender.mockClear(); + dance.stop(); + expect(dance.colored).toBe(false); + expect(dance.phase).toBe(0); + expect(requestRender).toHaveBeenCalled(); + + requestRender.mockClear(); + vi.advanceTimersByTime(DANCE_FRAME_MS * 5); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('dispose clears timers silently, without a final render', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const dance = new RainbowDance(requestRender); + + dance.start({ hold: false }); + vi.advanceTimersByTime(DANCE_FRAME_MS * 2); + requestRender.mockClear(); + + dance.dispose(); + expect(requestRender).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS * 10); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('advances the phase by one per frame while flowing', () => { + vi.useFakeTimers(); + const dance = new RainbowDance(vi.fn()); + + dance.start({ hold: true }); + vi.advanceTimersByTime(DANCE_FRAME_MS * 5); + expect(dance.phase).toBe(5); + + // Monotonic — the dance state itself has no palette-length cycle. + vi.advanceTimersByTime(DANCE_FRAME_MS * 5); + expect(dance.phase).toBe(10); + }); +}); + +describe('rainbowText', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('assigns each visible character the next palette color', () => { + const out = rainbowText('abcd', ['#111111', '#226622', '#aa33cc', '#44ddee'], 0); + + expect(truecolorCodes(out)).toEqual([ + '17,17,17', + '34,102,34', + '170,51,204', + '68,221,238', + ]); + }); + + it('does not consume a palette slot for spaces', () => { + const out = rainbowText('a b', ['#111111', '#226622'], 0); + + expect(truecolorCodes(out)).toEqual(['17,17,17', '34,102,34']); + }); + + it('starts from the given offset', () => { + const out = rainbowText('a', ['#111111', '#226622'], 1); + + expect(truecolorCodes(out)).toEqual(['34,102,34']); + }); +}); + +describe('installRainbowDance', () => { + afterEach(() => { + setRainbowDance(undefined); + vi.useRealTimers(); + }); + + it('returns a disposer that clears timers and uninstalls the controller', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const dispose = installRainbowDance(requestRender); + const host = { + showStatus: vi.fn(), + state: { theme: { palette: darkColors } }, + } as unknown as SlashCommandHost; + + tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); + vi.advanceTimersByTime(DANCE_FRAME_MS * 2); + expect(requestRender).toHaveBeenCalled(); + + requestRender.mockClear(); + dispose(); + + expect(getRainbowDanceView()).toBeUndefined(); + vi.advanceTimersByTime(DANCE_FLOW_MS + DANCE_FRAME_MS * 10); + expect(requestRender).not.toHaveBeenCalled(); + }); +}); + +interface DanceCall { + fn: 'start' | 'stop'; + hold?: boolean; +} + +function makeHost(): { host: SlashCommandHost; calls: DanceCall[]; status: string[] } { + const calls: DanceCall[] = []; + const status: string[] = []; + const rainbowDance = { + colored: false, + phase: 0, + start: (opts: { hold: boolean }) => calls.push({ fn: 'start', hold: opts.hold }), + stop: () => calls.push({ fn: 'stop' }), + dispose: () => {}, + }; + setRainbowDance(rainbowDance); + const host = { + showStatus: (msg: string) => status.push(msg), + state: { theme: { palette: darkColors } }, + } as unknown as SlashCommandHost; + return { host, calls, status }; +} + +describe('tryHandleDanceCommand', () => { + let host: SlashCommandHost; + let calls: DanceCall[]; + let status: string[]; + + beforeEach(() => { + ({ host, calls, status } = makeHost()); + }); + + afterEach(() => { + setRainbowDance(undefined); + }); + + it('claims /dance, flowing then fading, and hints at /dance on', () => { + const handled = tryHandleDanceCommand(host, { name: 'dance', args: '' }); + + expect(handled).toBe(true); + expect(calls).toEqual([{ fn: 'start', hold: false }]); + expect(status.join(' ')).toContain('/dance on'); + }); + + it('holds the rainbow for /dance on and hints at /dance off', () => { + const handled = tryHandleDanceCommand(host, { name: 'dance', args: 'on' }); + + expect(handled).toBe(true); + expect(calls).toEqual([{ fn: 'start', hold: true }]); + expect(status.join(' ')).toContain('/dance off'); + }); + + it('turns the rainbow off for /dance off', () => { + const handled = tryHandleDanceCommand(host, { name: 'dance', args: 'off' }); + + expect(handled).toBe(true); + expect(calls).toEqual([{ fn: 'stop' }]); + }); + + it('ignores case and surrounding whitespace in the sub-command', () => { + tryHandleDanceCommand(host, { name: 'dance', args: ' ON ' }); + + expect(calls).toEqual([{ fn: 'start', hold: true }]); + }); + + it('treats an unknown sub-command as a one-off dance', () => { + tryHandleDanceCommand(host, { name: 'dance', args: 'wiggle' }); + + expect(calls).toEqual([{ fn: 'start', hold: false }]); + }); + + it('does not claim other commands, so they fall through normally', () => { + const handled = tryHandleDanceCommand(host, { name: 'help', args: '' }); + + expect(handled).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/apps/pythinker-code/test/tui/easter-eggs/rainbow-colors.test.ts b/apps/pythinker-code/test/tui/easter-eggs/rainbow-colors.test.ts deleted file mode 100644 index 451fa744..00000000 --- a/apps/pythinker-code/test/tui/easter-eggs/rainbow-colors.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands/dispatch'; -import { - createRainbowPainter, - getRainbowColorView, - handleColorsCommand, - installRainbowColors, - RAINBOW_FLOW_MS, - RAINBOW_FRAME_MS, - RainbowColorMode, - rainbowText, - renderRainbowFooterModel, - setRainbowColors, - type RainbowColorController, -} from '#/tui/easter-eggs/rainbow-colors'; -import { currentTheme } from '#/tui/theme'; -import { darkColors } from '#/tui/theme/colors'; - -const TRUECOLOR_PATTERN = /\[38;2;(\d+);(\d+);(\d+)m/g; - -function truecolorCodes(text: string): string[] { - return [...text.matchAll(TRUECOLOR_PATTERN)].map((match) => `${match[1]},${match[2]},${match[3]}`); -} - -function colorCode(hex: string): string { - const value = hex.slice(1); - return [0, 2, 4] - .map((offset) => String(Number.parseInt(value.slice(offset, offset + 2), 16))) - .join(','); -} - -describe('RainbowColorMode', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('starts uncolored', () => { - const colors = new RainbowColorMode(vi.fn()); - - expect(colors.colored).toBe(false); - expect(colors.phase).toBe(0); - }); - - it('flows and requests renders', () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const colors = new RainbowColorMode(requestRender); - - colors.start({ freeze: false }); - expect(colors.colored).toBe(true); - - vi.advanceTimersByTime(RAINBOW_FRAME_MS); - expect(colors.phase).toBe(1); - expect(requestRender).toHaveBeenCalled(); - }); - - it('returns to normal after a one-shot flow', () => { - vi.useFakeTimers(); - const colors = new RainbowColorMode(vi.fn()); - - colors.start({ freeze: false }); - vi.advanceTimersByTime(RAINBOW_FLOW_MS + RAINBOW_FRAME_MS); - - expect(colors.colored).toBe(false); - expect(colors.phase).toBe(0); - }); - - it('freezes after the flow when requested', () => { - vi.useFakeTimers(); - const colors = new RainbowColorMode(vi.fn()); - - colors.start({ freeze: true }); - vi.advanceTimersByTime(RAINBOW_FLOW_MS + RAINBOW_FRAME_MS); - - expect(colors.colored).toBe(true); - const frozen = colors.phase; - vi.advanceTimersByTime(RAINBOW_FRAME_MS * 10); - expect(colors.phase).toBe(frozen); - }); - - it('stops on demand and clears its timers', () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const colors = new RainbowColorMode(requestRender); - - colors.start({ freeze: true }); - vi.advanceTimersByTime(RAINBOW_FRAME_MS * 3); - requestRender.mockClear(); - colors.stop(); - - expect(colors.colored).toBe(false); - expect(colors.phase).toBe(0); - expect(requestRender).toHaveBeenCalledOnce(); - - requestRender.mockClear(); - vi.advanceTimersByTime(RAINBOW_FRAME_MS * 5); - expect(requestRender).not.toHaveBeenCalled(); - }); - - it('disposes silently', () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const colors = new RainbowColorMode(requestRender); - - colors.start({ freeze: false }); - vi.advanceTimersByTime(RAINBOW_FRAME_MS * 2); - requestRender.mockClear(); - colors.dispose(); - - expect(requestRender).not.toHaveBeenCalled(); - vi.advanceTimersByTime(RAINBOW_FLOW_MS + RAINBOW_FRAME_MS * 10); - expect(requestRender).not.toHaveBeenCalled(); - }); -}); - -describe('rainbow painters', () => { - const previousChalkLevel = chalk.level; - - beforeEach(() => { - chalk.level = 3; - currentTheme.setPalette(darkColors); - }); - - afterEach(() => { - chalk.level = previousChalkLevel; - currentTheme.setPalette(darkColors); - }); - - it('assigns each visible character the next palette color', () => { - const output = rainbowText('abcd', ['#111111', '#226622', '#aa33cc', '#44ddee']); - - expect(truecolorCodes(output)).toEqual([ - '17,17,17', - '34,102,34', - '170,51,204', - '68,221,238', - ]); - }); - - it('does not consume a palette slot for spaces', () => { - const output = rainbowText('a b', ['#111111', '#226622']); - - expect(truecolorCodes(output)).toEqual(['17,17,17', '34,102,34']); - }); - - it('keeps one offset across consecutive painter calls', () => { - const paint = createRainbowPainter(0); - const output = paint('ab') + paint('cd'); - - expect(truecolorCodes(output)).toEqual([ - colorCode(darkColors.rainbowRed), - colorCode(darkColors.rainbowOrange), - colorCode(darkColors.rainbowYellow), - colorCode(darkColors.rainbowGreen), - ]); - }); - - it('uses the active theme rainbow tokens', () => { - const output = renderRainbowFooterModel('abcdefg'); - - expect(truecolorCodes(output)).toEqual([ - darkColors.rainbowRed, - darkColors.rainbowOrange, - darkColors.rainbowYellow, - darkColors.rainbowGreen, - darkColors.rainbowBlue, - darkColors.rainbowIndigo, - darkColors.rainbowViolet, - ].map(colorCode)); - }); -}); - -describe('installRainbowColors', () => { - afterEach(() => { - setRainbowColors(undefined); - vi.useRealTimers(); - }); - - it('returns a disposer that clears timers and uninstalls the controller', () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const dispose = installRainbowColors(requestRender); - const host = { - showError: vi.fn(), - showStatus: vi.fn(), - } as unknown as SlashCommandHost; - - handleColorsCommand(host, 'on'); - vi.advanceTimersByTime(RAINBOW_FRAME_MS * 2); - expect(requestRender).toHaveBeenCalled(); - - requestRender.mockClear(); - dispose(); - - expect(getRainbowColorView()).toBeUndefined(); - vi.advanceTimersByTime(RAINBOW_FLOW_MS + RAINBOW_FRAME_MS * 10); - expect(requestRender).not.toHaveBeenCalled(); - }); -}); - -interface ColorCall { - readonly fn: 'start' | 'stop'; - readonly freeze?: boolean; -} - -function makeHost(): { - host: SlashCommandHost; - calls: ColorCall[]; - status: string[]; - errors: string[]; -} { - const calls: ColorCall[] = []; - const status: string[] = []; - const errors: string[] = []; - const controller: RainbowColorController = { - colored: false, - phase: 0, - start: ({ freeze }) => calls.push({ fn: 'start', freeze }), - stop: () => calls.push({ fn: 'stop' }), - dispose: () => {}, - }; - setRainbowColors(controller); - const host = { - showError: (message: string) => errors.push(message), - showStatus: (message: string) => status.push(message), - } as unknown as SlashCommandHost; - return { host, calls, status, errors }; -} - -describe('handleColorsCommand', () => { - let host: SlashCommandHost; - let calls: ColorCall[]; - let status: string[]; - let errors: string[]; - - beforeEach(() => { - ({ host, calls, status, errors } = makeHost()); - }); - - afterEach(() => { - setRainbowColors(undefined); - }); - - it('runs a one-shot flow for /colors', () => { - handleColorsCommand(host, ''); - - expect(calls).toEqual([{ fn: 'start', freeze: false }]); - expect(status.join(' ')).toContain('/colors on'); - }); - - it('freezes the rainbow for /colors on', () => { - handleColorsCommand(host, 'on'); - - expect(calls).toEqual([{ fn: 'start', freeze: true }]); - expect(status.join(' ')).toContain('/colors off'); - }); - - it('turns the rainbow off for /colors off', () => { - handleColorsCommand(host, 'off'); - - expect(calls).toEqual([{ fn: 'stop' }]); - }); - - it('ignores case and surrounding whitespace', () => { - handleColorsCommand(host, ' ON '); - - expect(calls).toEqual([{ fn: 'start', freeze: true }]); - }); - - it('rejects invalid arguments with usage guidance', () => { - handleColorsCommand(host, 'wiggle'); - - expect(calls).toEqual([]); - expect(status).toEqual([]); - expect(errors).toEqual(['Usage: /colors [on|off]']); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/dot-repeat.test.ts b/apps/pythinker-code/test/tui/editor/vim/dot-repeat.test.ts deleted file mode 100644 index d8c123d8..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/dot-repeat.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { VIM_OPEN_LINE_COUNT_CAP } from '../../../../src/tui/constant/vim'; -import { - graphemeColumnAtUtf16Offset, - graphemes, - utf16OffsetAtGraphemeColumn, -} from '../../../../src/tui/editor/vim/graphemes'; -import { - applyKey, - createInitialPersistent, - createInitialState, - type PersistentState, - type VimBuffer, - type VimState, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -interface InitialRegister { - readonly content: string; - readonly linewise: boolean; -} -type Step = - | { readonly keys: string } - | { readonly insert: string } - | { - readonly replaceBuffer: { - readonly lines: readonly string[]; - readonly cursor: Cursor; - }; - }; -interface ExpectedRepeat { - readonly lines: readonly string[]; - readonly cursor: Cursor; - readonly mode?: VimState['mode']; - readonly register?: string; - readonly registerIsLinewise?: boolean; -} -type RepeatCase = readonly [ - name: string, - initialLines: readonly string[], - cursor: Cursor, - steps: readonly Step[], - initialRegister: InitialRegister | null, - expected: ExpectedRepeat, -]; - -function utf16PositionOffset(buffer: VimBuffer): number { - let offset = 0; - for (let line = 0; line < buffer.line; line += 1) { - offset += (buffer.lines[line] ?? '').length + 1; - } - return offset + utf16OffsetAtGraphemeColumn( - buffer.lines[buffer.line] ?? '', - buffer.column, - ); -} - -function positionFromUtf16Offset( - lines: readonly string[], - sourceOffset: number, -): Cursor { - let offset = sourceOffset; - for (let line = 0; line < lines.length; line += 1) { - const text = lines[line] ?? ''; - if (offset <= text.length || line === lines.length - 1) { - return [ - line, - graphemeColumnAtUtf16Offset(text, Math.min(offset, text.length)), - ]; - } - offset -= text.length + 1; - } - return [0, 0]; -} - -function insertText(buffer: VimBuffer, text: string): VimBuffer { - const source = buffer.lines.join('\n'); - const offset = utf16PositionOffset(buffer); - const lines = `${source.slice(0, offset)}${text}${source.slice(offset)}`.split('\n'); - const cursor = positionFromUtf16Offset(lines, offset + text.length); - return { lines, line: cursor[0], column: cursor[1] }; -} - -function runSteps( - lines: readonly string[], - cursor: Cursor, - steps: readonly Step[], - initialRegister: InitialRegister | null = null, -): ReturnType<typeof applyKey> { - let state: VimState = createInitialState(); - let persistent: PersistentState = { - ...createInitialPersistent(), - register: initialRegister?.content ?? '', - registerIsLinewise: initialRegister?.linewise ?? false, - }; - let buffer: VimBuffer = { lines, line: cursor[0], column: cursor[1] }; - let handled = true; - - for (const step of steps) { - if ('insert' in step) { - for (const key of graphemes(step.insert)) { - const result = applyKey(state, persistent, buffer, key); - state = result.state; - persistent = result.persistent; - buffer = insertText(result.buffer, key); - handled = result.handled; - } - continue; - } - if ('replaceBuffer' in step) { - buffer = { - lines: step.replaceBuffer.lines, - line: step.replaceBuffer.cursor[0], - column: step.replaceBuffer.cursor[1], - }; - continue; - } - for (const key of graphemes(step.keys)) { - const result = applyKey(state, persistent, buffer, key); - state = result.state; - persistent = result.persistent; - buffer = result.buffer; - handled = result.handled; - } - } - - return { state, persistent, buffer, handled }; -} - -function expectedMode(expected: ExpectedRepeat): VimState['mode'] { - return expected.mode ?? 'NORMAL'; -} - -function expectedRegister( - expected: ExpectedRepeat, - initialRegister: InitialRegister | null, -): InitialRegister { - return { - content: expected.register ?? initialRegister?.content ?? '', - linewise: - expected.registerIsLinewise - ?? initialRegister?.linewise - ?? false, - }; -} - -const cases: readonly RepeatCase[] = [ - ['dw then dot repeats the operator motion', ['one two three'], [0, 0], [ - { keys: 'dw.' }, - ], null, { - lines: ['three'], cursor: [0, 0], register: 'two ', - }], - ['dw then dot deletes the empty line left by the first change', ['foo', 'bar'], [0, 0], [ - { keys: 'dw.' }, - ], null, { - lines: ['bar'], cursor: [0, 0], register: '', registerIsLinewise: true, - }], - ['dW then dot deletes the empty line left by the first change', ['foo', 'bar'], [0, 0], [ - { keys: 'dW.' }, - ], null, { - lines: ['bar'], cursor: [0, 0], register: '', registerIsLinewise: true, - }], - ['a count on dot replaces the recorded count', ['abcdef'], [0, 0], [ - { keys: 'x3.' }, - ], null, { - lines: ['ef'], cursor: [0, 0], register: 'bcd', - }], - ['dd then dot repeats the linewise operator', ['one', 'two', 'three'], [0, 0], [ - { keys: 'dd.' }, - ], null, { - lines: ['three'], cursor: [0, 0], register: 'two', - registerIsLinewise: true, - }], - ['ciw replays typed text on another word', ['one two'], [0, 0], [ - { keys: 'ciw' }, - { insert: 'X' }, - { keys: '\u001Bw.' }, - ], null, { - lines: ['X X'], cursor: [0, 2], register: 'two', - }], - ['A replays typed text at another line end', ['one', 'two'], [0, 0], [ - { keys: 'A' }, - { insert: '!' }, - { keys: '\u001Bj.' }, - ], null, { - lines: ['one!', 'two!'], cursor: [1, 3], - }], - ['dot replays a combining mark that joins the preceding grapheme', ['e', 'e'], [0, 0], [ - { keys: 'A' }, - { insert: '\u0301' }, - { keys: '\u001Bj.' }, - ], null, { - lines: ['e\u0301', 'e\u0301'], cursor: [1, 0], - }], - ['o opens a new line and dot repeats it below', ['one', 'two'], [0, 0], [ - { keys: 'o' }, - { insert: 'X' }, - { keys: '\u001B.' }, - ], null, { - lines: ['one', 'X', 'X', 'two'], cursor: [2, 0], - }], - ['O opens a new line and dot repeats it above', ['one', 'two'], [1, 0], [ - { keys: 'O' }, - { insert: 'X' }, - { keys: '\u001B.' }, - ], null, { - lines: ['one', 'X', 'X', 'two'], cursor: [1, 0], - }], - ['a counted o inserts the typed text on separate lines', ['one', 'two'], [0, 0], [ - { keys: '3o' }, - { insert: 'X' }, - { keys: '\u001B' }, - ], null, { - lines: ['one', 'X', 'X', 'X', 'two'], cursor: [3, 0], - }], - ['a counted O inserts the typed text on separate lines', ['one', 'two'], [1, 0], [ - { keys: '3O' }, - { insert: 'X' }, - { keys: '\u001B' }, - ], null, { - lines: ['one', 'X', 'X', 'X', 'two'], cursor: [3, 0], - }], - ['dot replays every line from a counted o', ['one', 'two'], [0, 0], [ - { keys: '2o' }, - { insert: 'X' }, - { keys: '\u001B.' }, - ], null, { - lines: ['one', 'X', 'X', 'X', 'X', 'two'], cursor: [4, 0], - }], - ['dot replays a counted o without inserted text', ['one', 'two'], [0, 0], [ - { keys: '3o\u001B.' }, - ], null, { - lines: ['one', '', '', '', '', '', '', 'two'], cursor: [6, 0], - }], - ['dot replays a counted O without inserted text', ['one', 'two'], [1, 0], [ - { keys: '3O\u001B.' }, - ], null, { - lines: ['one', '', '', '', '', '', '', 'two'], cursor: [5, 0], - }], - ['paste then dot repeats the paste', ['abc'], [0, 0], [ - { keys: 'p.' }, - ], { content: 'X', linewise: false }, { - lines: ['aXXbc'], cursor: [0, 2], register: 'X', - }], - ['yank then dot is a no-op without an earlier change', ['one two'], [0, 0], [ - { keys: 'yw.' }, - ], null, { - lines: ['one two'], cursor: [0, 0], register: 'one ', - }], - ['dot before any change is a no-op', ['abc'], [0, 1], [ - { keys: '.' }, - ], null, { - lines: ['abc'], cursor: [0, 1], - }], - ['a visual delete repeats the same span at the new cursor', ['abcdef'], [0, 0], [ - { keys: 'vldl.' }, - ], null, { - lines: ['cf'], cursor: [0, 1], register: 'de', - }], - ['a counted insert multiplies the typed text', ['x'], [0, 0], [ - { keys: '3i' }, - { insert: 'ab' }, - { keys: '\u001B' }, - ], null, { - lines: ['abababx'], cursor: [0, 5], - }], - ['dot replays a counted insert', ['--'], [0, 0], [ - { keys: '3i' }, - { insert: 'ab' }, - { keys: '\u001B$.' }, - ], null, { - lines: ['ababab-ababab-'], cursor: [0, 12], - }], - ['dot replays inserted text that spans lines', ['ab', 'cd'], [0, 0], [ - { keys: 'A' }, - { insert: 'X\nY' }, - { keys: '\u001BG.' }, - ], null, { - lines: ['abX', 'Y', 'cdX', 'Y'], cursor: [3, 0], - }], - ['yank preserves an earlier repeatable change', ['abc def'], [0, 0], [ - { keys: 'xyw.' }, - ], null, { - lines: ['c def'], cursor: [0, 0], register: 'b', - }], - ['dot replays a visual change with inserted text', ['abcdef'], [0, 0], [ - { keys: 'vlc' }, - { insert: 'X' }, - { keys: '\u001Bl.' }, - ], null, { - lines: ['XXef'], cursor: [0, 1], register: 'cd', - }], - ['dot repeats substitute on an empty line', ['', ''], [0, 0], [ - { keys: 's' }, - { insert: 'X' }, - { keys: '\u001Bj.' }, - ], null, { - lines: ['X', 'X'], cursor: [1, 0], - }], -]; - -describe('vim dot repeat', () => { - it.each(cases)( - '%s', - (_name, initialLines, cursor, steps, initialRegister, expected) => { - const result = runSteps( - initialLines, - cursor, - steps, - initialRegister, - ); - - expect(result.buffer).toEqual({ - lines: expected.lines, - line: expected.cursor[0], - column: expected.cursor[1], - }); - expect(result.state.mode).toBe(expectedMode(expected)); - expect({ - content: result.persistent.register, - linewise: result.persistent.registerIsLinewise, - }).toEqual(expectedRegister(expected, initialRegister)); - expect(result.handled).toBe(true); - }, - ); - - it.each(['o', 'O'] as const)( - 'caps a huge %s count for allocation, replication, and the repeat spec', - (command) => { - const result = runSteps(['one'], [0, 0], [ - { keys: `999999999${command}` }, - { insert: 'X' }, - { keys: '\u001B' }, - ]); - - expect(result.buffer.lines).toHaveLength(VIM_OPEN_LINE_COUNT_CAP + 1); - expect(result.buffer.lines.filter((line) => line === 'X')).toHaveLength( - VIM_OPEN_LINE_COUNT_CAP, - ); - expect(result.persistent.lastChange).toEqual({ - kind: 'insert', - key: command, - count: VIM_OPEN_LINE_COUNT_CAP, - insertedText: 'X', - }); - }, - ); - - it.each(['o', 'O'] as const)( - 'caps a count-replacing dot replay for %s', - (command) => { - const result = runSteps(['one'], [0, 0], [ - { keys: command }, - { insert: 'X' }, - { keys: '\u001B999999999.' }, - ]); - - expect(result.buffer.lines).toHaveLength(VIM_OPEN_LINE_COUNT_CAP + 2); - expect(result.buffer.lines.filter((line) => line === 'X')).toHaveLength( - VIM_OPEN_LINE_COUNT_CAP + 1, - ); - expect(result.persistent.lastChange).toEqual({ - kind: 'insert', - key: command, - count: VIM_OPEN_LINE_COUNT_CAP, - insertedText: 'X', - }); - }, - ); - - it('records null inserted text when the buffer changes elsewhere', () => { - const result = runSteps(['abc'], [0, 1], [ - { keys: 'i' }, - { replaceBuffer: { lines: ['zabc'], cursor: [0, 2] } }, - { keys: '\u001B.' }, - ]); - - expect(result.buffer.lines).toEqual(['zabc']); - expect(result.persistent.lastChange).toEqual({ - kind: 'insert', - key: 'i', - count: 1, - insertedText: null, - }); - }); - - it('is pure for a frozen INSERT entry', () => { - const pendingRepeat = Object.freeze({ - kind: 'insert' as const, - key: 'i', - count: 1, - insertedText: null, - }); - const snapshotCursor = Object.freeze({ line: 0, column: 1 }); - const snapshotLines = Object.freeze(['abc']); - const entry = Object.freeze({ - pendingRepeat, - snapshotLines, - snapshotCursor, - }); - const state = Object.freeze({ mode: 'INSERT' as const, entry }); - const persistent = Object.freeze({ - ...createInitialPersistent(), - }); - const lines = Object.freeze(['aXbc']); - const buffer = Object.freeze({ lines, line: 0, column: 2 }); - - const first = applyKey(state, persistent, buffer, '\u001B'); - const second = applyKey(state, persistent, buffer, '\u001B'); - - expect(first).toEqual(second); - expect(entry).toEqual({ - pendingRepeat: { - kind: 'insert', - key: 'i', - count: 1, - insertedText: null, - }, - snapshotLines: ['abc'], - snapshotCursor: { line: 0, column: 1 }, - }); - expect(persistent).toEqual({ - ...createInitialPersistent(), - }); - expect(buffer).toEqual({ lines: ['aXbc'], line: 0, column: 2 }); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/editor-bridge.test.ts b/apps/pythinker-code/test/tui/editor/vim/editor-bridge.test.ts deleted file mode 100644 index ae7dca35..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/editor-bridge.test.ts +++ /dev/null @@ -1,525 +0,0 @@ -import type { - AutocompleteItem, - AutocompleteProvider, - TUI, -} from '@earendil-works/pi-tui'; -import { describe, expect, it, vi } from 'vitest'; - -import { CustomEditor } from '#/tui/components/editor/custom-editor'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -import { - applyKey, - createInitialPersistent, - createInitialState, - type PersistentState, - type VimBuffer, - type VimState, -} from '../../../../src/tui/editor/vim'; - -const ESCAPE = '\u001B'; -const PASTE_START = '\u001B[200~'; -const PASTE_END = '\u001B[201~'; -const UNDO = '\u001F'; -const UP = '\u001B[A'; -const DOWN = '\u001B[B'; -const RIGHT = '\u001B[C'; -const LEFT = '\u001B[D'; -const HOME = '\u001B[H'; -const END = '\u001B[F'; -const KITTY_D = '\u001B[100u'; -const KITTY_Q = '\u001B[113u'; -const KITTY_W = '\u001B[119u'; - -function makeEditor(vimMode = true): CustomEditor { - const tui = { - requestRender: vi.fn(), - terminal: { rows: 40, cols: 120 }, - } as unknown as TUI; - return new CustomEditor(tui, { vimMode }); -} - -function typeText(editor: CustomEditor, text: string): void { - for (const character of Array.from(text)) { - editor.handleInput(character); - } -} - -function pasteLargeText(editor: CustomEditor, content: string): string { - editor.handleInput('i'); - editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); - editor.handleInput(ESCAPE); - return editor.getText(); -} - -function autocompleteProvider(items: AutocompleteItem[]): AutocompleteProvider { - return { - getSuggestions: vi.fn(async () => ({ items, prefix: '/' })), - applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ - lines, - cursorLine, - cursorCol, - })), - }; -} - -async function flushAutocomplete(): Promise<void> { - await Promise.resolve(); - await Promise.resolve(); -} - -function runPureKeys(buffer: VimBuffer, keys: string): VimBuffer { - let state: VimState = createInitialState(); - let persistent: PersistentState = createInitialPersistent(); - let current = buffer; - for (const key of Array.from(keys)) { - const result = applyKey(state, persistent, current, key); - state = result.state; - persistent = result.persistent; - current = result.buffer; - } - return current; -} - -describe('vim editor bridge paste integrity', () => { - const content = Array.from( - { length: 15 }, - (_, index) => `line${String(index)}`, - ).join('\n'); - - it('routes a large paste through pi-tui in NORMAL mode', () => { - const editor = makeEditor(); - - editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); - - expect(editor.getText()).toMatch(/^\[paste #\d+ \+\d+ lines\]$/u); - expect(editor.getExpandedText()).toBe(content); - }); - - it('routes a large paste through pi-tui in INSERT mode', () => { - const editor = makeEditor(); - editor.handleInput('i'); - - editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); - - expect(editor.getText()).toMatch(/^\[paste #\d+ \+\d+ lines\]$/u); - expect(editor.getExpandedText()).toBe(content); - }); - - it('routes a large paste through pi-tui when vim mode is off', () => { - const editor = makeEditor(false); - - editor.handleInput(`${PASTE_START}${content}${PASTE_END}`); - - expect(editor.getText()).toMatch(/^\[paste #\d+ \+\d+ lines\]$/u); - expect(editor.getExpandedText()).toBe(content); - }); - - it.each(['l', 'w', 'j'])( - 'preserves a large paste payload across the %s motion', - (motion) => { - const editor = makeEditor(); - pasteLargeText(editor, content); - - editor.handleInput(motion); - - expect(editor.getExpandedText()).toBe(content); - }, - ); - - it('preserves a large paste payload across an edit outside its marker', () => { - const editor = makeEditor(); - pasteLargeText(editor, content); - - editor.handleInput('A'); - typeText(editor, ' tail'); - editor.handleInput(ESCAPE); - - expect(editor.getExpandedText()).toBe(`${content} tail`); - }); - - it('preserves a large paste payload when vim changes adjacent text', () => { - const editor = makeEditor(); - pasteLargeText(editor, content); - editor.handleInput('A'); - typeText(editor, ' tail'); - editor.handleInput(ESCAPE); - - typeText(editor, 'bx'); - - expect(editor.getExpandedText()).toBe(`${content} ail`); - }); - - it('drops a deleted marker payload instead of retaining stale content', () => { - const editor = makeEditor(); - const marker = pasteLargeText(editor, content); - - typeText(editor, 'dd'); - expect(editor.getText()).toBe(''); - - editor.insertTextAtCursor(marker); - expect(editor.getExpandedText()).toBe(marker); - }); -}); - -describe('vim editor bridge pi-tui ownership', () => { - it('makes an edit performed by vim undoable through pi-tui', () => { - const editor = makeEditor(); - editor.setText('abc'); - - typeText(editor, '0x'); - expect(editor.getText()).toBe('bc'); - - editor.handleInput('i'); - editor.handleInput(UNDO); - - expect(editor.getText()).toBe('abc'); - }); - - it('does not add an undo snapshot for a pure motion', () => { - const editor = makeEditor(); - editor.setText('abc'); - - editor.handleInput('h'); - editor.handleInput('i'); - editor.handleInput(UNDO); - - expect(editor.getText()).toBe(''); - }); - - it('keeps slash autocomplete functional in INSERT and cancels it on NORMAL entry', async () => { - const editor = makeEditor(); - editor.setAutocompleteProvider( - autocompleteProvider([{ value: 'help', label: 'help' }]), - ); - - editor.handleInput('i'); - editor.handleInput('/'); - await flushAutocomplete(); - expect(editor.isShowingAutocomplete()).toBe(true); - - editor.handleInput(ESCAPE); - expect(editor.isShowingAutocomplete()).toBe(false); - - editor.handleInput('x'); - expect(editor.getText()).toBe(''); - }); - - it('keeps history browsing functional after a vim edit', () => { - const editor = makeEditor(); - editor.setText('abc'); - typeText(editor, '0x'); - editor.addToHistory('first'); - editor.addToHistory('second'); - - editor.handleInput('i'); - editor.handleInput(UP); - - expect(editor.getText()).toBe('second'); - }); -}); - -describe('vim editor bridge mode and cursor behavior', () => { - it('routes bare Escape to vim when leaving INSERT mode', () => { - const editor = makeEditor(); - editor.setText('abc'); - editor.handleInput('0'); - editor.handleInput('i'); - - editor.handleInput(ESCAPE); - editor.handleInput('x'); - - expect(editor.getText()).toBe('bc'); - }); - - it('routes bare Escape to vim to cancel a pending command', () => { - const editor = makeEditor(); - editor.setText('alpha beta'); - editor.handleInput('0'); - editor.handleInput('d'); - - editor.handleInput(ESCAPE); - editor.handleInput('w'); - - expect(editor.getText()).toBe('alpha beta'); - }); - - it('keeps text byte-identical across an empty i and Escape round-trip', () => { - const editor = makeEditor(); - editor.setText('alpha\nbeta'); - const before = editor.getText(); - - editor.handleInput('i'); - editor.handleInput(ESCAPE); - - expect(editor.getText()).toBe(before); - }); - - it('is off by default so vim command letters remain literal input', () => { - const editor = makeEditor(false); - - typeText(editor, 'dwxi'); - - expect(editor.getText()).toBe('dwxi'); - }); - - it('enables and disables vim mode after construction', () => { - const editor = makeEditor(false); - editor.setText('alpha beta'); - - editor.setVimMode(true); - editor.handleInput('0'); - typeText(editor, 'dw'); - expect(editor.getText()).toBe('beta'); - - editor.setText(''); - editor.setVimMode(false); - typeText(editor, 'dw'); - expect(editor.getText()).toBe('dw'); - }); - - it('preserves INSERT mode when vim mode is enabled redundantly', () => { - const editor = makeEditor(false); - editor.setVimMode(true); - editor.handleInput('i'); - - editor.setVimMode(true); - editor.handleInput('x'); - - expect(editor.getText()).toBe('x'); - }); - - it('does not leak printable keys into the text in NORMAL mode', () => { - const editor = makeEditor(); - - typeText(editor, 'dwxq'); - - expect(editor.getText()).toBe(''); - }); - - it('moves right and left with terminal arrow sequences in NORMAL mode', () => { - const editor = makeEditor(); - editor.setText('alpha beta'); - editor.handleInput('0'); - - editor.handleInput(RIGHT); - expect(editor.getCursor()).toEqual({ line: 0, col: 1 }); - - editor.handleInput(LEFT); - expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); - }); - - it('moves down and up across lines with terminal arrow sequences in NORMAL mode', () => { - const editor = makeEditor(); - editor.setText('alpha\nbeta'); - typeText(editor, 'gg0'); - - editor.handleInput(DOWN); - expect(editor.getCursor()).toEqual({ line: 1, col: 0 }); - - editor.handleInput(UP); - expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); - }); - - it('routes Home and End without corrupting the buffer in NORMAL mode', () => { - const editor = makeEditor(); - editor.setText('alpha beta'); - editor.handleInput('0'); - const before = editor.getText(); - - editor.handleInput(END); - expect(editor.getCursor()).toEqual({ line: 0, col: before.length }); - - editor.handleInput(HOME); - expect(editor.getCursor()).toEqual({ line: 0, col: 0 }); - expect(editor.getText()).toBe(before); - }); - - it.each([ - ['astral emoji', 'a😀b', 3], - ['combining sequence', 'ae\u0301b', 3], - ['flag', 'a🇺🇸b', 5], - ['ZWJ emoji', 'a👩‍💻b', 6], - ])( - 'converts %s between vim grapheme columns and pi-tui UTF-16 offsets', - (_name, text, utf16Column) => { - const editor = makeEditor(); - editor.setText(text); - - typeText(editor, '0ll'); - expect(editor.getCursor()).toEqual({ line: 0, col: utf16Column }); - - editor.handleInput('h'); - expect(editor.getCursor()).toEqual({ line: 0, col: 1 }); - expect(editor.getText()).toBe(text); - }, - ); - - it('inserts next to an astral character without splitting its surrogate pair', () => { - const editor = makeEditor(); - editor.setText('a😀b'); - - typeText(editor, '0lliX'); - editor.handleInput(ESCAPE); - - expect(editor.getText()).toBe('a😀Xb'); - expect(editor.getText()).not.toContain('\uFFFD'); - }); - - it('opens indented lines above and below through the real editor', () => { - const below = makeEditor(); - below.setText(' one\ntwo'); - typeText(below, 'gg0oX'); - below.handleInput(ESCAPE); - expect(below.getText()).toBe(' one\n X\ntwo'); - - const above = makeEditor(); - above.setText('one'); - typeText(above, '0OX'); - above.handleInput(ESCAPE); - expect(above.getText()).toBe('X\none'); - }); - - it('applies vim commands sent as Kitty CSI-u printables', () => { - const editor = makeEditor(); - editor.setText('alpha beta'); - editor.handleInput('0'); - - editor.handleInput(KITTY_D); - editor.handleInput(KITTY_W); - - expect(editor.getText()).toBe('beta'); - }); - - it('does not leak a Kitty CSI-u printable in NORMAL mode', () => { - const editor = makeEditor(); - - editor.handleInput(KITTY_Q); - - expect(editor.getText()).toBe(''); - }); - - it('inserts a Kitty CSI-u printable in INSERT mode', () => { - const editor = makeEditor(); - editor.handleInput('i'); - - editor.handleInput(KITTY_Q); - - expect(editor.getText()).toBe('q'); - }); - - it('keeps an astral Kitty printable under vim ownership in NORMAL mode', () => { - const editor = makeEditor(); - - editor.handleInput('\u001B[128512u'); - - expect(editor.getText()).toBe(''); - }); - - it('drops Kitty release events before NORMAL or INSERT vim handling', () => { - const editor = makeEditor(); - - editor.handleInput('\u001B[110;1:3u'); - editor.handleInput('i'); - editor.handleInput('\u001B[110u'); - editor.handleInput('\u001B[110;1:3u'); - - expect(editor.getText()).toBe('n'); - }); - - it('routes legacy application shortcuts before vim handling', () => { - const editor = makeEditor(); - const onCtrlC = vi.fn(); - const onCtrlD = vi.fn(); - const onSearchHistory = vi.fn(); - const onCommand = vi.fn(); - editor.onCtrlC = onCtrlC; - editor.onCtrlD = onCtrlD; - editor.onSearchHistory = onSearchHistory; - editor.onCommand = onCommand; - editor.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Chat', bindings: { 'ctrl+r': 'chat:historySearch' } }, - ]), - ]); - - editor.handleInput('\u0003'); - editor.handleInput('\u0004'); - editor.handleInput('\u0012'); - editor.handleInput('\u001Bp'); - - expect(onCtrlC).toHaveBeenCalledOnce(); - expect(onCtrlD).toHaveBeenCalledOnce(); - expect(onSearchHistory).toHaveBeenCalledOnce(); - expect(onCommand).toHaveBeenCalledWith('model'); - expect(editor.getText()).toBe(''); - }); - - it('does not call setText for a pure motion sequence', () => { - const editor = makeEditor(); - editor.setText('alpha beta gamma'); - const before = editor.getText(); - const setText = vi.spyOn(editor, 'setText'); - - typeText(editor, '0wwbb0$'); - - expect(editor.getText()).toBe(before); - expect(setText).not.toHaveBeenCalled(); - }); - - it('edits with dw and delegates INSERT typing to pi-tui', () => { - const editor = makeEditor(); - editor.setText('alpha beta'); - editor.handleInput('0'); - - typeText(editor, 'dw'); - editor.handleInput('i'); - typeText(editor, 'new '); - editor.handleInput(ESCAPE); - - expect(editor.getText()).toBe('new beta'); - }); - - it.each([ - ['h', 'h'], - ['l', '0l'], - ['j', 'ggj'], - ['k', 'k'], - ['w', 'ggw'], - ['W', 'ggW'], - ['b', 'b'], - ['B', 'B'], - ['e', 'gge'], - ['E', 'ggE'], - ['0', '0'], - ['^', '^'], - ['$', '0$'], - ['gg', 'gg'], - ['G', 'ggG'], - ['f', 'gg0ft'], - ['F', 'Ff'], - ['t', 'gg0tw'], - ['T', 'T '], - [';', 'gg0ft;'], - [',', 'gg0ft;,'], - ])('matches the pure state machine cursor for %s', (_motion, keys) => { - const editor = makeEditor(); - editor.setText(' one two\nthree four\n five six'); - const initialCursor = editor.getCursor(); - const expected = runPureKeys( - { - lines: editor.getLines(), - line: initialCursor.line, - column: initialCursor.col, - }, - keys, - ); - - typeText(editor, keys); - - expect(editor.getCursor()).toEqual({ - line: expected.line, - col: expected.column, - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/motions.test.ts b/apps/pythinker-code/test/tui/editor/vim/motions.test.ts deleted file mode 100644 index 711835bd..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/motions.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - findBackward, - findForward, - moveBigWordBackward, - moveBigWordEnd, - moveBigWordForward, - moveDown, - moveFirstNonBlank, - moveLeft, - moveLineEnd, - moveLineStart, - moveRight, - moveToFirstLine, - moveToLastLine, - moveUp, - moveWordBackward, - moveWordEnd, - moveWordForward, - tillBackward, - tillForward, - type VimBuffer, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -type MotionCase = readonly [ - name: string, - initialLines: readonly string[], - cursor: Cursor, - keys: string, - expected: Cursor, -]; - -function runMotion(lines: readonly string[], cursor: Cursor, keys: string): VimBuffer { - const buffer: VimBuffer = { lines, line: cursor[0], column: cursor[1] }; - const parsed = /^([1-9][0-9]*)?(gg|[hljkwWbBeE0^$GfFtT])(.*)$/u.exec(keys); - if (parsed === null) { - throw new Error(`Invalid motion fixture: ${keys}`); - } - - const count = parsed[1] === undefined ? 1 : Number.parseInt(parsed[1], 10); - const motion = parsed[2]; - if (motion === undefined) { - throw new Error(`Missing motion fixture: ${keys}`); - } - const target = parsed[3] ?? ''; - - switch (motion) { - case 'h': - return moveLeft(buffer, count); - case 'l': - return moveRight(buffer, count); - case 'j': - return moveDown(buffer, count, buffer.column); - case 'k': - return moveUp(buffer, count, buffer.column); - case 'w': - return moveWordForward(buffer, count); - case 'W': - return moveBigWordForward(buffer, count); - case 'b': - return moveWordBackward(buffer, count); - case 'B': - return moveBigWordBackward(buffer, count); - case 'e': - return moveWordEnd(buffer, count); - case 'E': - return moveBigWordEnd(buffer, count); - case '0': - return moveLineStart(buffer, count); - case '^': - return moveFirstNonBlank(buffer, count); - case '$': - return moveLineEnd(buffer, count); - case 'gg': - return moveToFirstLine(buffer, count); - case 'G': - return moveToLastLine(buffer, parsed[1] === undefined ? undefined : count); - case 'f': - return findForward(buffer, count, target); - case 'F': - return findBackward(buffer, count, target); - case 't': - return tillForward(buffer, count, target); - case 'T': - return tillBackward(buffer, count, target); - } - - throw new Error(`Unsupported motion fixture: ${motion}`); -} - -const cases: readonly MotionCase[] = [ - ['h moves left mid-line', ['abcdef'], [0, 3], 'h', [0, 2]], - ['h clamps at line start', ['abcdef'], [0, 0], 'h', [0, 0]], - ['h applies a count', ['abcdef'], [0, 5], '3h', [0, 2]], - ['h clamps an overshooting count', ['abcdef'], [0, 3], '20h', [0, 0]], - ['h is stable on an empty line', [''], [0, 0], 'h', [0, 0]], - ['h is stable on one character', ['x'], [0, 0], 'h', [0, 0]], - ['l moves right mid-line', ['abcdef'], [0, 2], 'l', [0, 3]], - ['l clamps at line end', ['abcdef'], [0, 5], 'l', [0, 5]], - ['l applies a count', ['abcdef'], [0, 1], '3l', [0, 4]], - ['l clamps an overshooting count', ['abcdef'], [0, 2], '20l', [0, 5]], - ['l is stable on an empty line', [''], [0, 0], 'l', [0, 0]], - ['l is stable on one character', ['x'], [0, 0], 'l', [0, 0]], - ['l treats an emoji as one character', ['a😀b'], [0, 0], '2l', [0, 2]], - ['l treats a combining sequence as one grapheme', ['ae\u0301b'], [0, 0], '2l', [0, 2]], - ['l treats a flag as one grapheme', ['a🇺🇸b'], [0, 0], '2l', [0, 2]], - ['l treats a ZWJ emoji as one grapheme', ['a👩‍💻b'], [0, 0], '2l', [0, 2]], - - ['j moves down mid-buffer', ['abcd', 'wxyz'], [0, 2], 'j', [1, 2]], - ['j clamps at the final line', ['abcd', 'wxyz'], [1, 2], 'j', [1, 2]], - ['j preserves desired column through a short line', ['abcdef', 'x', 'abcdef'], [0, 4], '2j', [2, 4]], - ['j clamps an overshooting count', ['abcdef', 'xy'], [0, 4], '20j', [1, 1]], - ['j clamps onto an empty line', ['abcd', ''], [0, 2], 'j', [1, 0]], - ['j is stable on a one-character buffer', ['x'], [0, 0], 'j', [0, 0]], - ['k moves up mid-buffer', ['abcd', 'wxyz'], [1, 2], 'k', [0, 2]], - ['k clamps at the first line', ['abcd', 'wxyz'], [0, 2], 'k', [0, 2]], - ['k preserves desired column through a short line', ['abcdef', 'x', 'abcdef'], [2, 4], '2k', [0, 4]], - ['k clamps an overshooting count', ['xy', 'abcdef'], [1, 4], '20k', [0, 1]], - ['k clamps onto an empty line', ['', 'abcd'], [1, 2], 'k', [0, 0]], - ['k is stable on a one-character buffer', ['x'], [0, 0], 'k', [0, 0]], - - ['w moves to the next word', ['one two'], [0, 1], 'w', [0, 4]], - ['w clamps at the final word', ['one'], [0, 2], 'w', [0, 2]], - ['w applies a count', ['one two three'], [0, 0], '2w', [0, 8]], - ['w clamps an overshooting count', ['one two three'], [0, 0], '20w', [0, 8]], - ['w is stable on an empty line', [''], [0, 0], 'w', [0, 0]], - ['w is stable on one character', ['x'], [0, 0], 'w', [0, 0]], - ['w crosses a line boundary', ['one', 'two'], [0, 0], 'w', [1, 0]], - ['w treats punctuation as a word', ['foo...bar'], [0, 0], '2w', [0, 6]], - ['w skips multiple spaces', ['foo bar'], [0, 0], 'w', [0, 6]], - ['w stops on punctuation in foo.bar baz', ['foo.bar baz'], [0, 0], 'w', [0, 3]], - ['w crosses Unicode and emoji words without splitting them', ['é 😀 dog'], [0, 0], '2w', [0, 4]], - ['W moves to the next whitespace-delimited word', ['one two'], [0, 1], 'W', [0, 4]], - ['W clamps at the final WORD', ['one'], [0, 2], 'W', [0, 2]], - ['W applies a count', ['one two three'], [0, 0], '2W', [0, 8]], - ['W clamps an overshooting count', ['one two three'], [0, 0], '20W', [0, 8]], - ['W is stable on an empty line', [''], [0, 0], 'W', [0, 0]], - ['W is stable on one character', ['x'], [0, 0], 'W', [0, 0]], - ['W crosses a line boundary', ['one', 'two'], [0, 0], 'W', [1, 0]], - ['W keeps punctuation inside a WORD', ['foo.bar baz'], [0, 0], 'W', [0, 8]], - ['W skips multiple spaces', ['foo bar'], [0, 0], 'W', [0, 6]], - - ['b moves to the previous word', ['one two'], [0, 6], 'b', [0, 4]], - ['b clamps at the first word', ['one'], [0, 0], 'b', [0, 0]], - ['b applies a count', ['one two three'], [0, 8], '2b', [0, 0]], - ['b clamps an overshooting count', ['one two three'], [0, 8], '20b', [0, 0]], - ['b is stable on an empty line', [''], [0, 0], 'b', [0, 0]], - ['b is stable on one character', ['x'], [0, 0], 'b', [0, 0]], - ['b crosses a line boundary', ['one', 'two'], [1, 0], 'b', [0, 0]], - ['b treats punctuation as a word', ['foo...bar'], [0, 6], 'b', [0, 3]], - ['b skips multiple spaces', ['foo bar'], [0, 6], 'b', [0, 0]], - ['B moves to the previous whitespace-delimited word', ['one two'], [0, 6], 'B', [0, 4]], - ['B clamps at the first WORD', ['one'], [0, 0], 'B', [0, 0]], - ['B applies a count', ['one two three'], [0, 8], '2B', [0, 0]], - ['B clamps an overshooting count', ['one two three'], [0, 8], '20B', [0, 0]], - ['B is stable on an empty line', [''], [0, 0], 'B', [0, 0]], - ['B is stable on one character', ['x'], [0, 0], 'B', [0, 0]], - ['B crosses a line boundary', ['one', 'two'], [1, 0], 'B', [0, 0]], - ['B keeps punctuation inside a WORD', ['foo.bar baz'], [0, 8], 'B', [0, 0]], - ['B skips multiple spaces', ['foo bar'], [0, 6], 'B', [0, 0]], - - ['e moves to the end of a word', ['one two'], [0, 0], 'e', [0, 2]], - ['e clamps at the final word end', ['one'], [0, 2], 'e', [0, 2]], - ['e applies a count', ['one two three'], [0, 0], '2e', [0, 6]], - ['e clamps an overshooting count', ['one two three'], [0, 0], '20e', [0, 12]], - ['e is stable on an empty line', [''], [0, 0], 'e', [0, 0]], - ['e is stable on one character', ['x'], [0, 0], 'e', [0, 0]], - ['e crosses a line boundary', ['one', 'two'], [0, 0], '2e', [1, 2]], - ['e treats punctuation as a word', ['foo...bar'], [0, 0], '2e', [0, 5]], - ['e skips multiple spaces', ['foo bar'], [0, 2], 'e', [0, 8]], - ['e stops before punctuation in foo.bar baz', ['foo.bar baz'], [0, 0], 'e', [0, 2]], - ['E moves to the end of a whitespace-delimited word', ['one two'], [0, 0], 'E', [0, 2]], - ['E clamps at the final WORD end', ['one'], [0, 2], 'E', [0, 2]], - ['E applies a count', ['one two three'], [0, 0], '2E', [0, 6]], - ['E clamps an overshooting count', ['one two three'], [0, 0], '20E', [0, 12]], - ['E is stable on an empty line', [''], [0, 0], 'E', [0, 0]], - ['E is stable on one character', ['x'], [0, 0], 'E', [0, 0]], - ['E crosses a line boundary', ['one', 'two'], [0, 0], '2E', [1, 2]], - ['E keeps punctuation inside a WORD', ['foo.bar baz'], [0, 0], 'E', [0, 6]], - ['E skips multiple spaces', ['foo bar'], [0, 2], 'E', [0, 8]], - - ['0 moves to line start', [' abc'], [0, 4], '0', [0, 0]], - ['0 is stable at line start', ['abc'], [0, 0], '0', [0, 0]], - ['0 ignores a repeated count at its anchor', ['abc'], [0, 2], '30', [0, 0]], - ['0 clamps an oversized input column', ['abc'], [0, 20], '0', [0, 0]], - ['0 is stable on an empty line', [''], [0, 0], '0', [0, 0]], - ['0 is stable on one character', ['x'], [0, 0], '0', [0, 0]], - ['^ moves to the first non-blank', [' abc'], [0, 4], '^', [0, 2]], - ['^ is stable at the first non-blank', [' abc'], [0, 2], '^', [0, 2]], - ['^ ignores a count', [' abc'], [0, 4], '3^', [0, 2]], - ['^ clamps an oversized input column', [' abc'], [0, 20], '^', [0, 2]], - ['^ is stable on an empty line', [''], [0, 0], '^', [0, 0]], - ['^ is stable on one character', ['x'], [0, 0], '^', [0, 0]], - ['$ moves to line end', ['abc'], [0, 0], '$', [0, 2]], - ['$ is stable at line end', ['abc'], [0, 2], '$', [0, 2]], - ['$ ignores a count', ['abc'], [0, 0], '3$', [0, 2]], - ['$ clamps an oversized input column', ['abc'], [0, 20], '$', [0, 2]], - ['$ is stable on an empty line', [''], [0, 0], '$', [0, 0]], - ['$ is stable on one character', ['x'], [0, 0], '$', [0, 0]], - - ['gg moves to the first line', ['a', ' b', ' c'], [2, 1], 'gg', [0, 0]], - ['gg is stable at first non-blank on the first line', [' a', 'b'], [0, 2], 'gg', [0, 2]], - ['gg uses a count as a one-based line number', ['a', ' b', ' c'], [0, 0], '3gg', [2, 1]], - ['gg clamps an overshooting count', ['a', ' b'], [0, 0], '20gg', [1, 2]], - ['gg handles an empty target line', ['', 'b'], [1, 0], 'gg', [0, 0]], - ['gg is stable on one character', ['x'], [0, 0], 'gg', [0, 0]], - ['G moves to the final line', ['a', ' b', ' c'], [0, 0], 'G', [2, 1]], - ['G is stable at first non-blank on the final line', ['a', ' b'], [1, 2], 'G', [1, 2]], - ['G uses a count as a one-based line number', ['a', ' b', ' c'], [0, 0], '2G', [1, 2]], - ['G clamps an overshooting count', ['a', ' b'], [0, 0], '20G', [1, 2]], - ['G handles an empty target line', ['a', ''], [0, 0], 'G', [1, 0]], - ['G is stable on one character', ['x'], [0, 0], 'G', [0, 0]], - - ['f moves onto a character', ['a x x'], [0, 0], 'fx', [0, 2]], - ['f is stable when no target follows', ['x a'], [0, 0], 'fx', [0, 0]], - ['f applies a count', ['a x x'], [0, 0], '2fx', [0, 4]], - ['f is a no-op when its count overshoots', ['a x x'], [0, 0], '3fx', [0, 0]], - ['f is stable on an empty line', [''], [0, 0], 'fx', [0, 0]], - ['f is stable on one character', ['x'], [0, 0], 'fx', [0, 0]], - ['f finds emoji as a whole character', ['a😀b😀'], [0, 0], '2f😀', [0, 3]], - ['f finds a combining sequence as one grapheme', ['ae\u0301be\u0301'], [0, 0], '2fe\u0301', [0, 3]], - ['f finds a flag as one grapheme', ['a🇺🇸b🇺🇸'], [0, 0], '2f🇺🇸', [0, 3]], - ['f finds a ZWJ emoji as one grapheme', ['a👩‍💻b'], [0, 0], 'f👩‍💻', [0, 1]], - ['f rejects a multi-grapheme target', ['abc'], [0, 0], 'fbc', [0, 0]], - ['F moves onto a character', ['x x a'], [0, 4], 'Fx', [0, 2]], - ['F is stable when no target precedes', ['a x'], [0, 0], 'Fa', [0, 0]], - ['F applies a count', ['x x a'], [0, 4], '2Fx', [0, 0]], - ['F is a no-op when its count overshoots', ['x x a'], [0, 4], '3Fx', [0, 4]], - ['F is stable on an empty line', [''], [0, 0], 'Fx', [0, 0]], - ['F is stable on one character', ['x'], [0, 0], 'Fx', [0, 0]], - ['t moves up to a character', ['a x x'], [0, 0], 'tx', [0, 1]], - ['t clamps before an adjacent target', ['ax'], [0, 0], 'tx', [0, 0]], - ['t applies a count', ['a x x'], [0, 0], '2tx', [0, 3]], - ['t is a no-op when its count overshoots', ['a x x'], [0, 0], '3tx', [0, 0]], - ['t is stable on an empty line', [''], [0, 0], 'tx', [0, 0]], - ['t is stable on one character', ['x'], [0, 0], 'tx', [0, 0]], - ['T moves back up to a character', ['x x a'], [0, 4], 'Tx', [0, 3]], - ['T clamps after an adjacent target', ['xa'], [0, 1], 'Tx', [0, 1]], - ['T applies a count', ['x x a'], [0, 4], '2Tx', [0, 1]], - ['T is a no-op when its count overshoots', ['x x a'], [0, 4], '3Tx', [0, 4]], - ['T is stable on an empty line', [''], [0, 0], 'Tx', [0, 0]], - ['T is stable on one character', ['x'], [0, 0], 'Tx', [0, 0]], -]; - -describe('vim motions', () => { - it.each(cases)('%s', (_name, initialLines, cursor, keys, expected) => { - const result = runMotion(initialLines, cursor, keys); - - expect(result).toEqual({ - lines: initialLines, - line: expected[0], - column: expected[1], - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/operators.test.ts b/apps/pythinker-code/test/tui/editor/vim/operators.test.ts deleted file mode 100644 index 28035eb6..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/operators.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - applyKey, - createInitialPersistent, - createInitialState, - type CommandState, - type PersistentState, - type VimBuffer, - type VimMode, - type VimState, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -interface ExpectedState { - readonly lines: readonly string[]; - readonly cursor: Cursor; - readonly mode: VimMode; - readonly command?: CommandState; - readonly lastFind?: PersistentState['lastFind']; - readonly register?: string; - readonly registerIsLinewise?: boolean; - readonly initialRegister?: InitialRegister; -} -interface InitialRegister { - readonly content: string; - readonly linewise: boolean; -} -type OperatorCase = readonly [ - name: string, - initialLines: readonly string[], - cursor: Cursor, - keys: string, - expected: ExpectedState, -]; - -function runKeys( - lines: readonly string[], - cursor: Cursor, - keys: string, - initialRegister?: InitialRegister, -): ReturnType<typeof applyKey> { - let state: VimState = createInitialState(); - let persistent: PersistentState = { - ...createInitialPersistent(), - register: initialRegister?.content ?? '', - registerIsLinewise: initialRegister?.linewise ?? false, - }; - let buffer: VimBuffer = { lines, line: cursor[0], column: cursor[1] }; - let handled = true; - - for (const key of Array.from(keys)) { - const result = applyKey(state, persistent, buffer, key); - state = result.state; - persistent = result.persistent; - buffer = result.buffer; - handled = result.handled; - } - - return { state, persistent, buffer, handled }; -} - -const cases: readonly OperatorCase[] = [ - ['dw deletes from the middle of a word to the next word', ['foo bar'], [0, 1], 'dw', { - lines: ['fbar'], cursor: [0, 1], mode: 'NORMAL', register: 'oo ', - }], - ['dw on the last word does not join the next line', ['foo', 'bar'], [0, 0], 'dw', { - lines: ['', 'bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo', - }], - ['dw on an empty line deletes that line linewise', ['', 'bar'], [0, 0], 'dw', { - lines: ['bar'], cursor: [0, 0], mode: 'NORMAL', register: '', - registerIsLinewise: true, - }], - ['dW on an empty line deletes that line linewise', ['', 'bar'], [0, 0], 'dW', { - lines: ['bar'], cursor: [0, 0], mode: 'NORMAL', register: '', - registerIsLinewise: true, - }], - ['dw on trailing whitespace preserves the next line', ['foo ', 'bar'], [0, 3], 'dw', { - lines: ['foo', 'bar'], cursor: [0, 2], mode: 'NORMAL', register: ' ', - }], - ['d2w from trailing whitespace preserves the line after an empty line', ['foo ', '', 'bar'], [0, 3], 'd2w', { - lines: ['foo', 'bar'], cursor: [0, 2], mode: 'NORMAL', register: ' \n', - }], - ['d2W from trailing whitespace preserves the line after an empty line', ['foo ', '', 'bar'], [0, 3], 'd2W', { - lines: ['foo', 'bar'], cursor: [0, 2], mode: 'NORMAL', register: ' \n', - }], - ['d2w from trailing whitespace still reaches a following word', ['foo ', 'bar baz'], [0, 3], 'd2w', { - lines: ['foobaz'], cursor: [0, 3], mode: 'NORMAL', register: ' \nbar ', - }], - ['dW on a whitespace-only line preserves the next line', [' ', 'bar'], [0, 0], 'dW', { - lines: ['', 'bar'], cursor: [0, 0], mode: 'NORMAL', register: ' ', - }], - ['d2w counts an empty line and preserves target indentation', ['foo', '', ' bar'], [0, 0], 'd2w', { - lines: [' bar'], cursor: [0, 2], mode: 'NORMAL', register: 'foo\n', - registerIsLinewise: true, - }], - ['d2W counts an empty line and preserves target indentation', ['foo', '', ' bar'], [0, 0], 'd2W', { - lines: [' bar'], cursor: [0, 2], mode: 'NORMAL', register: 'foo\n', - registerIsLinewise: true, - }], - ['y2w makes a final empty-line hop linewise', ['foo', '', ' bar'], [0, 0], 'y2w', { - lines: ['foo', '', ' bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo\n', - registerIsLinewise: true, - }], - ['y2W makes a final empty-line hop linewise', ['foo', '', ' bar'], [0, 0], 'y2W', { - lines: ['foo', '', ' bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo\n', - registerIsLinewise: true, - }], - ['d3w applies delete-special after a counted cross-line motion', ['foo', '', 'bar', 'baz'], [0, 0], 'd3w', { - lines: ['baz'], cursor: [0, 0], mode: 'NORMAL', register: 'foo\n\nbar', - registerIsLinewise: true, - }], - ['d2w through the final word deletes to EOF linewise', ['foo', 'bar'], [0, 0], 'd2w', { - lines: [''], cursor: [0, 0], mode: 'NORMAL', register: 'foo\nbar', - registerIsLinewise: true, - }], - ['d2W through the final word deletes to EOF linewise', ['foo', 'bar'], [0, 0], 'd2W', { - lines: [''], cursor: [0, 0], mode: 'NORMAL', register: 'foo\nbar', - registerIsLinewise: true, - }], - ['y2w through EOF remains charwise', ['foo', 'bar'], [0, 0], 'y2w', { - lines: ['foo', 'bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo\nbar', - }], - ['dw is exclusive of the next word', ['foo bar'], [0, 0], 'dw', { - lines: ['bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo ', - }], - ['de is inclusive of the word end', ['foo bar'], [0, 0], 'de', { - lines: [' bar'], cursor: [0, 0], mode: 'NORMAL', register: 'foo', - }], - ['d$ deletes through the end of the line', ['foo bar'], [0, 4], 'd$', { - lines: ['foo '], cursor: [0, 3], mode: 'NORMAL', register: 'bar', - }], - ['dd deletes one whole line', ['one', 'two'], [0, 1], 'dd', { - lines: ['two'], cursor: [0, 0], mode: 'NORMAL', register: 'one', - registerIsLinewise: true, - }], - ['3dd deletes three whole lines', ['one', 'two', 'three', 'four'], [0, 0], '3dd', { - lines: ['four'], cursor: [0, 0], mode: 'NORMAL', register: 'one\ntwo\nthree', - registerIsLinewise: true, - }], - ['counts on both sides multiply', ['one two three four five six seven'], [0, 0], '2d3w', { - lines: ['seven'], cursor: [0, 0], mode: 'NORMAL', - register: 'one two three four five six ', - }], - ['dj is linewise', ['one', ' two', 'three'], [0, 1], 'dj', { - lines: ['three'], cursor: [0, 0], mode: 'NORMAL', register: 'one\n two', - registerIsLinewise: true, - }], - ['dk is linewise in the backward direction', ['one', 'two', 'three'], [1, 1], 'dk', { - lines: ['three'], cursor: [0, 0], mode: 'NORMAL', register: 'one\ntwo', - registerIsLinewise: true, - }], - ['dG deletes through the final line', ['one', 'two', 'three'], [1, 1], 'dG', { - lines: ['one'], cursor: [0, 0], mode: 'NORMAL', register: 'two\nthree', - registerIsLinewise: true, - }], - ['dgg deletes through the first line', ['one', 'two', 'three'], [2, 1], 'dgg', { - lines: [''], cursor: [0, 0], mode: 'NORMAL', register: 'one\ntwo\nthree', - registerIsLinewise: true, - }], - ['cw behaves as ce on a non-blank', ['foo bar'], [0, 0], 'cw', { - lines: [' bar'], cursor: [0, 0], mode: 'INSERT', register: 'foo', - }], - ['cc keeps leading indentation and enters INSERT there', [' foo', 'bar'], [0, 4], 'cc', { - lines: [' ', 'bar'], cursor: [0, 2], mode: 'INSERT', register: ' foo', - registerIsLinewise: true, - }], - ['x deletes the character at end of line', ['abc'], [0, 2], 'x', { - lines: ['ab'], cursor: [0, 1], mode: 'NORMAL', register: 'c', - }], - ['x on an empty line is a no-op', [''], [0, 0], 'x', { - lines: [''], cursor: [0, 0], mode: 'NORMAL', - }], - ['X at column zero is a no-op', ['abc'], [0, 0], 'X', { - lines: ['abc'], cursor: [0, 0], mode: 'NORMAL', - }], - ['s deletes a character and enters INSERT', ['abc'], [0, 1], 's', { - lines: ['ac'], cursor: [0, 1], mode: 'INSERT', register: 'b', - }], - ['S clears a line but keeps its indentation', [' abc'], [0, 3], 'S', { - lines: [' '], cursor: [0, 2], mode: 'INSERT', register: ' abc', - registerIsLinewise: true, - }], - ['o opens an indented line below', [' one', 'two'], [0, 1], 'o', { - lines: [' one', ' ', 'two'], cursor: [1, 2], mode: 'INSERT', - }], - ['O opens an indented line above', ['one', ' two'], [1, 2], 'O', { - lines: ['one', ' ', ' two'], cursor: [1, 2], mode: 'INSERT', - }], - ['o opens below an empty final line', ['x', ''], [1, 0], 'o', { - lines: ['x', '', ''], cursor: [2, 0], mode: 'INSERT', - }], - ['O opens above the first line', ['one'], [0, 0], 'O', { - lines: ['', 'one'], cursor: [0, 0], mode: 'INSERT', - }], - ['D is d$', ['abc def'], [0, 4], 'D', { - lines: ['abc '], cursor: [0, 3], mode: 'NORMAL', register: 'def', - }], - ['C is c$', ['abc def'], [0, 4], 'C', { - lines: ['abc '], cursor: [0, 4], mode: 'INSERT', register: 'def', - }], - ['Y then p yanks and pastes a whole line below', [' one', 'two'], [0, 2], 'Yp', { - lines: [' one', ' one', 'two'], cursor: [1, 2], mode: 'NORMAL', register: ' one', - registerIsLinewise: true, - }], - ['charwise p inserts after the cursor and lands on the last pasted character', ['abc'], [0, 1], 'p', { - lines: ['abXYc'], cursor: [0, 3], mode: 'NORMAL', register: 'XY', - initialRegister: { content: 'XY', linewise: false }, - }], - ['charwise P inserts before the cursor and lands on the last pasted character', ['abc'], [0, 1], 'P', { - lines: ['aXYbc'], cursor: [0, 2], mode: 'NORMAL', register: 'XY', - initialRegister: { content: 'XY', linewise: false }, - }], - ['linewise p inserts below and lands on its first non-blank', ['one', 'two'], [0, 0], 'p', { - lines: ['one', ' alpha', 'beta', 'two'], cursor: [1, 2], mode: 'NORMAL', - register: ' alpha\nbeta', registerIsLinewise: true, - initialRegister: { content: ' alpha\nbeta', linewise: true }, - }], - ['linewise P inserts above and lands on its first non-blank', ['one', 'two'], [1, 0], 'P', { - lines: ['one', ' alpha', 'beta', 'two'], cursor: [1, 2], mode: 'NORMAL', - register: ' alpha\nbeta', registerIsLinewise: true, - initialRegister: { content: ' alpha\nbeta', linewise: true }, - }], - ['linewise p pastes an empty register as one empty line', ['one', 'two'], [0, 0], 'p', { - lines: ['one', '', 'two'], cursor: [1, 0], mode: 'NORMAL', - register: '', registerIsLinewise: true, - initialRegister: { content: '', linewise: true }, - }], - ['linewise P pastes an empty register as one empty line', ['one', 'two'], [1, 0], 'P', { - lines: ['one', '', 'two'], cursor: [1, 0], mode: 'NORMAL', - register: '', registerIsLinewise: true, - initialRegister: { content: '', linewise: true }, - }], - ['diw deletes the word under the cursor', ['one two'], [0, 5], 'diw', { - lines: ['one '], cursor: [0, 3], mode: 'NORMAL', register: 'two', - }], - ['daw includes trailing whitespace', ['one two three'], [0, 5], 'daw', { - lines: ['one three'], cursor: [0, 4], mode: 'NORMAL', register: 'two ', - }], - ['daw includes leading whitespace when there is no trailing whitespace', ['one two'], [0, 5], 'daw', { - lines: ['one'], cursor: [0, 2], mode: 'NORMAL', register: ' two', - }], - ['di" deletes inside quotes', ['say "hello" now'], [0, 7], 'di"', { - lines: ['say "" now'], cursor: [0, 5], mode: 'NORMAL', register: 'hello', - }], - ['da" includes quotes and one trailing space', ['say "hello" now'], [0, 7], 'da"', { - lines: ['say now'], cursor: [0, 4], mode: 'NORMAL', register: '"hello" ', - }], - ['di( selects the innermost nested pair', ['f(g(x))'], [0, 4], 'di(', { - lines: ['f(g())'], cursor: [0, 4], mode: 'NORMAL', register: 'x', - }], - ['da{ deletes a bracket object spanning lines', ['a {', ' b', '} c'], [1, 2], 'da{', { - lines: ['a c'], cursor: [0, 2], mode: 'NORMAL', register: '{\n b\n}', - }], - ['a missing text object cancels without modifying the buffer', ['say "open'], [0, 6], 'di"', { - lines: ['say "open'], cursor: [0, 6], mode: 'NORMAL', - }], - ['deleting the only line leaves one empty line', ['only'], [0, 0], 'dd', { - lines: [''], cursor: [0, 0], mode: 'NORMAL', register: 'only', - registerIsLinewise: true, - }], - ['Escape cancels a pending operator', ['abc'], [0, 1], 'd\u001B', { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['Escape cancels an operator count', ['abc'], [0, 1], 'd2\u001B', { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['Escape cancels an operator find', ['abc'], [0, 1], 'df\u001B', { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['Escape cancels an operator text object', ['abc'], [0, 1], 'di\u001B', { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['Escape cancels operator g', ['one', 'two'], [1, 0], 'dg\u001B', { - lines: ['one', 'two'], cursor: [1, 0], mode: 'NORMAL', - }], - ['a different operator cancels the pending operator', ['abc'], [0, 1], 'dc', { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['i after an operator means inner rather than INSERT', ['one two'], [0, 5], 'di', { - lines: ['one two'], cursor: [0, 5], mode: 'NORMAL', - command: { type: 'operatorTextObj', op: 'delete', count: 1, scope: 'inner' }, - }], - ['df is inclusive of the target character', ['a-b-c'], [0, 0], 'dfb', { - lines: ['-c'], cursor: [0, 0], mode: 'NORMAL', register: 'a-b', - lastFind: { type: 'f', char: 'b' }, - }], - ['dt excludes the target character', ['a-b-c'], [0, 0], 'dtb', { - lines: ['b-c'], cursor: [0, 0], mode: 'NORMAL', register: 'a-', - lastFind: { type: 't', char: 'b' }, - }], - ['semicolon repeats a find recorded by an operator', ['a x b x c x'], [0, 0], 'dfx;', { - lines: [' b x c x'], cursor: [0, 3], mode: 'NORMAL', register: 'a x', - lastFind: { type: 'f', char: 'x' }, - }], - ['dh deletes backward without deleting the cursor character', ['abc'], [0, 1], 'dh', { - lines: ['bc'], cursor: [0, 0], mode: 'NORMAL', register: 'a', - }], - ['d0 deletes backward to the line start', ['abc'], [0, 2], 'd0', { - lines: ['c'], cursor: [0, 0], mode: 'NORMAL', register: 'ab', - }], - ['yank does not modify the buffer', ['one two'], [0, 0], 'yw', { - lines: ['one two'], cursor: [0, 0], mode: 'NORMAL', register: 'one ', - }], - ['x removes an emoji as one character', ['a\u{1F600}b'], [0, 1], 'x', { - lines: ['ab'], cursor: [0, 1], mode: 'NORMAL', register: '\u{1F600}', - }], - ['x removes a combining sequence as one grapheme', ['ae\u0301b'], [0, 1], 'x', { - lines: ['ab'], cursor: [0, 1], mode: 'NORMAL', register: 'e\u0301', - }], - ['x removes a flag as one grapheme', ['a🇺🇸b'], [0, 1], 'x', { - lines: ['ab'], cursor: [0, 1], mode: 'NORMAL', register: '🇺🇸', - }], - ['x removes a ZWJ emoji as one grapheme', ['a👩‍💻b'], [0, 1], 'x', { - lines: ['ab'], cursor: [0, 1], mode: 'NORMAL', register: '👩‍💻', - }], - ['dw across CJK code points does not split a character', ['\u732B \u72D7 bird'], [0, 0], 'dw', { - lines: ['\u72D7 bird'], cursor: [0, 0], mode: 'NORMAL', register: '\u732B ', - }], - ['diw selects an emoji word object whole', ['a \u{1F600} b'], [0, 2], 'diw', { - lines: ['a b'], cursor: [0, 2], mode: 'NORMAL', register: '\u{1F600}', - }], -]; - -describe('vim operators', () => { - function expectedVimState( - expected: ExpectedState, - ): VimState | { readonly mode: 'INSERT' } { - return expected.mode === 'INSERT' - ? { mode: 'INSERT' } - : { - mode: 'NORMAL', - command: expected.command ?? { type: 'idle' }, - }; - } - - function expectedPersistentState( - expected: ExpectedState, - ): Omit<PersistentState, 'lastChange'> { - return { - lastFind: expected.lastFind ?? null, - desiredColumn: null, - register: expected.register ?? expected.initialRegister?.content ?? '', - registerIsLinewise: - expected.registerIsLinewise - ?? expected.initialRegister?.linewise - ?? false, - }; - } - - function legacyVimState( - state: VimState, - ): Exclude<VimState, { readonly mode: 'INSERT' }> - | { readonly mode: 'INSERT' } { - switch (state.mode) { - case 'INSERT': - return { mode: 'INSERT' }; - case 'NORMAL': - case 'VISUAL': - return state; - } - } - - function legacyPersistentState( - persistent: PersistentState, - ): Omit<PersistentState, 'lastChange'> { - return { - lastFind: persistent.lastFind, - desiredColumn: persistent.desiredColumn, - register: persistent.register, - registerIsLinewise: persistent.registerIsLinewise, - }; - } - - it.each(cases)('%s', (_name, initialLines, cursor, keys, expected) => { - const frozenLines = Object.freeze([...initialLines]); - const frozenCursor = Object.freeze([...cursor]) as Cursor; - const beforeLines = [...frozenLines]; - const beforeCursor = [...frozenCursor]; - - const result = runKeys( - frozenLines, - frozenCursor, - keys, - expected.initialRegister, - ); - - expect(result.buffer).toEqual({ - lines: expected.lines, - line: expected.cursor[0], - column: expected.cursor[1], - }); - expect(legacyVimState(result.state)).toEqual(expectedVimState(expected)); - expect(legacyPersistentState(result.persistent)).toEqual( - expectedPersistentState(expected), - ); - expect(result.handled).toBe(true); - expect(frozenLines).toEqual(beforeLines); - expect(frozenCursor).toEqual(beforeCursor); - }); - - it('is pure for fully frozen operator inputs', () => { - const state = Object.freeze({ - mode: 'NORMAL' as const, - command: Object.freeze({ - type: 'operator' as const, - op: 'delete' as const, - count: 1, - }), - }); - const persistent = Object.freeze({ - ...createInitialPersistent(), - }); - const lines = Object.freeze(['one two']); - const buffer = Object.freeze({ lines, line: 0, column: 0 }); - - const first = applyKey(state, persistent, buffer, 'w'); - const second = applyKey(state, persistent, buffer, 'w'); - - expect(first).toEqual(second); - expect(state.command).toEqual({ type: 'operator', op: 'delete', count: 1 }); - expect(persistent).toEqual({ - ...createInitialPersistent(), - }); - expect(buffer).toEqual({ lines: ['one two'], line: 0, column: 0 }); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/state-machine.test.ts b/apps/pythinker-code/test/tui/editor/vim/state-machine.test.ts deleted file mode 100644 index 89e2390e..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/state-machine.test.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - applyKey, - createInitialPersistent, - createInitialState, - type CommandState, - type PersistentState, - type VimBuffer, - type VimMode, - type VimState, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -interface ExpectedState { - readonly lines?: readonly string[]; - readonly initialMode?: VimMode; - readonly initialDesiredColumn?: number | null; - readonly cursor: Cursor; - readonly mode: VimMode; - readonly command?: CommandState; - readonly lastFind?: PersistentState['lastFind']; - readonly desiredColumn?: number | null; - readonly handled?: boolean; -} -type StateCase = readonly [ - name: string, - initialLines: readonly string[], - cursor: Cursor, - keys: string, - expected: ExpectedState, -]; - -function initialState( - mode: VimMode, - lines: readonly string[], - cursor: Cursor, -): VimState { - return mode === 'INSERT' - ? { - mode: 'INSERT', - entry: { - pendingRepeat: null, - snapshotLines: [...lines], - snapshotCursor: { line: cursor[0], column: cursor[1] }, - }, - } - : createInitialState(); -} - -function runKeys( - lines: readonly string[], - cursor: Cursor, - keys: string, - mode: VimMode, - desiredColumn: number | null, -): ReturnType<typeof applyKey> { - let state = initialState(mode, lines, cursor); - let persistent: PersistentState = { - ...createInitialPersistent(), - desiredColumn, - }; - let buffer: VimBuffer = { lines, line: cursor[0], column: cursor[1] }; - let handled = true; - - for (const key of Array.from(keys)) { - const result = applyKey(state, persistent, buffer, key); - state = result.state; - persistent = result.persistent; - buffer = result.buffer; - handled = result.handled; - } - - return { state, persistent, buffer, handled }; -} - -const cases: readonly StateCase[] = [ - ['i enters INSERT at the cursor', ['abc'], [0, 1], 'i', { cursor: [0, 1], mode: 'INSERT' }], - ['I enters INSERT at first non-blank', [' abc'], [0, 4], 'I', { cursor: [0, 2], mode: 'INSERT' }], - ['a enters INSERT one character right', ['abc'], [0, 1], 'a', { cursor: [0, 2], mode: 'INSERT' }], - ['a enters INSERT after the final character', ['abc'], [0, 2], 'a', { cursor: [0, 3], mode: 'INSERT' }], - ['A enters INSERT at line end', ['abc'], [0, 0], 'A', { cursor: [0, 3], mode: 'INSERT' }], - ['o opens a blank line below before entering INSERT', ['abc'], [0, 1], 'o', { lines: ['abc', ''], cursor: [1, 0], mode: 'INSERT' }], - ['O opens a blank line above before entering INSERT', ['abc'], [0, 1], 'O', { lines: ['', 'abc'], cursor: [0, 0], mode: 'INSERT' }], - ['entering INSERT resets the desired column', ['abc'], [0, 1], 'i', { initialDesiredColumn: 4, cursor: [0, 1], mode: 'INSERT' }], - ['Escape leaves INSERT and moves left', ['abc'], [0, 2], '\u001B', { initialMode: 'INSERT', cursor: [0, 1], mode: 'NORMAL' }], - ['Escape leaves INSERT clamped at zero', ['abc'], [0, 0], '\u001B', { initialMode: 'INSERT', cursor: [0, 0], mode: 'NORMAL' }], - ['Escape clamps an INSERT cursor before moving left', ['abc'], [0, 20], '\u001B', { initialMode: 'INSERT', cursor: [0, 1], mode: 'NORMAL' }], - ['Escape clamps an empty INSERT line to zero', [''], [0, 20], '\u001B', { initialMode: 'INSERT', cursor: [0, 0], mode: 'NORMAL' }], - ['INSERT delegates every non-Escape key', ['abc'], [0, 1], 'x', { initialMode: 'INSERT', cursor: [0, 1], mode: 'INSERT', handled: false }], - ['3w applies a word count', ['one two three four'], [0, 0], '3w', { cursor: [0, 14], mode: 'NORMAL' }], - ['a direct motion resets the desired column', ['one two'], [0, 0], 'w', { initialDesiredColumn: 5, cursor: [0, 4], mode: 'NORMAL' }], - ['12j applies a multi-digit count', Array.from({ length: 15 }, (_, index) => `${index}`), [0, 0], '12j', { cursor: [12, 0], mode: 'NORMAL', desiredColumn: 0 }], - ['sequential vertical motions preserve the desired column', ['abcdef', 'x', 'abcdef'], [0, 4], 'jj', { cursor: [2, 4], mode: 'NORMAL', desiredColumn: 4 }], - ['a vertical motion reuses a saved desired column', ['abcdef', 'x', 'abcdef'], [1, 0], 'j', { initialDesiredColumn: 4, cursor: [2, 4], mode: 'NORMAL', desiredColumn: 4 }], - ['a horizontal motion resets vertical column preservation', ['abcdef', 'x', 'abcdef'], [0, 4], 'jhj', { cursor: [2, 0], mode: 'NORMAL', desiredColumn: 0 }], - ['$ then j aims for the end of the next line', ['abc', 'abcdefgh'], [0, 0], '$j', { cursor: [1, 7], mode: 'NORMAL', desiredColumn: Number.POSITIVE_INFINITY }], - ['0 is a motion without a pending count', ['abc'], [0, 2], '0', { cursor: [0, 0], mode: 'NORMAL' }], - ['0 extends an existing count in 10j', Array.from({ length: 12 }, () => 'x'), [0, 0], '10j', { cursor: [10, 0], mode: 'NORMAL', desiredColumn: 0 }], - ['g enters its pending state', ['a', 'b'], [1, 0], 'g', { cursor: [1, 0], mode: 'NORMAL', command: { type: 'g', count: 1 } }], - ['gg moves to the first line', ['a', 'b'], [1, 0], 'gg', { cursor: [0, 0], mode: 'NORMAL' }], - ['3gg moves to a counted line', ['a', 'b', ' c', 'd'], [0, 0], '3gg', { cursor: [2, 2], mode: 'NORMAL' }], - ['3G moves to a counted line', ['a', 'b', ' c', 'd'], [0, 0], '3G', { cursor: [2, 2], mode: 'NORMAL' }], - ['G without a count moves to the final line', ['a', 'b', ' c'], [0, 0], 'G', { cursor: [2, 2], mode: 'NORMAL' }], - ['find plus semicolon and comma repeats and reverses', ['a x x'], [0, 0], 'fx;,', { cursor: [0, 2], mode: 'NORMAL', lastFind: { type: 'f', char: 'x' } }], - ['repeated t advances past each adjacent target', ['a x x x'], [0, 0], 'tx;;', { cursor: [0, 5], mode: 'NORMAL', lastFind: { type: 't', char: 'x' } }], - ['repeated T advances past each adjacent target', ['x x x a'], [0, 6], 'Tx;;', { cursor: [0, 1], mode: 'NORMAL', lastFind: { type: 'T', char: 'x' } }], - ['comma reverses T and advances past an adjacent target', ['x a x x x'], [0, 2], 'Tx,,', { cursor: [0, 5], mode: 'NORMAL', lastFind: { type: 'T', char: 'x' } }], - ['semicolon before a find is a no-op', ['a x'], [0, 0], ';', { cursor: [0, 0], mode: 'NORMAL', lastFind: null }], - ['a counted find selects the nth target', ['a x x'], [0, 0], '2fx', { cursor: [0, 4], mode: 'NORMAL', lastFind: { type: 'f', char: 'x' } }], - ['an unknown g continuation cancels to idle', ['abc'], [0, 1], 'gx', { cursor: [0, 1], mode: 'NORMAL' }], - ['an unknown counted key cancels to idle', ['abc'], [0, 1], '3q', { cursor: [0, 1], mode: 'NORMAL' }], - ['Escape cancels a pending command', ['abc'], [0, 1], 'g\u001B', { cursor: [0, 1], mode: 'NORMAL' }], - ['emoji can be a find target', ['a😀b😀'], [0, 0], '2f😀', { cursor: [0, 3], mode: 'NORMAL', lastFind: { type: 'f', char: '😀' } }], -]; - -describe('vim state machine', () => { - function runCase( - initialLines: readonly string[], - cursor: Cursor, - keys: string, - expected: ExpectedState, - ): ReturnType<typeof applyKey> { - return runKeys( - initialLines, - cursor, - keys, - expected.initialMode ?? 'NORMAL', - expected.initialDesiredColumn ?? null, - ); - } - - function expectedVimState( - expected: ExpectedState, - ): VimState | { readonly mode: 'INSERT' } { - if (expected.mode === 'INSERT') { - return { mode: 'INSERT' }; - } - if (expected.command !== undefined) { - return { mode: 'NORMAL', command: expected.command }; - } - return createInitialState(); - } - - function expectedPersistentState( - expected: ExpectedState, - ): Omit<PersistentState, 'lastChange'> { - return { - lastFind: expected.lastFind ?? null, - desiredColumn: expected.desiredColumn ?? null, - register: '', - registerIsLinewise: false, - }; - } - - function legacyVimState( - state: VimState, - ): Exclude<VimState, { readonly mode: 'INSERT' }> - | { readonly mode: 'INSERT' } { - switch (state.mode) { - case 'INSERT': - return { mode: 'INSERT' }; - case 'NORMAL': - case 'VISUAL': - return state; - } - } - - function legacyPersistentState( - persistent: PersistentState, - ): Omit<PersistentState, 'lastChange'> { - return { - lastFind: persistent.lastFind, - desiredColumn: persistent.desiredColumn, - register: persistent.register, - registerIsLinewise: persistent.registerIsLinewise, - }; - } - - function expectedHandled(expected: ExpectedState): boolean { - return expected.handled ?? true; - } - - it.each(cases)('%s', (_name, initialLines, cursor, keys, expected) => { - const result = runCase(initialLines, cursor, keys, expected); - - expect(result.buffer).toEqual({ - lines: expected.lines ?? initialLines, - line: expected.cursor[0], - column: expected.cursor[1], - }); - expect(legacyVimState(result.state)).toEqual(expectedVimState(expected)); - expect(legacyPersistentState(result.persistent)).toEqual( - expectedPersistentState(expected), - ); - expect(result.handled).toBe(expectedHandled(expected)); - }); - - it.each([ - ['combining sequence', 'ae\u0301b', 'e\u0301'], - ['flag', 'a🇺🇸b', '🇺🇸'], - ['ZWJ emoji', 'a👩‍💻b', '👩‍💻'], - ])('accepts a %s as one find target grapheme', (_name, line, target) => { - const result = applyKey( - { mode: 'NORMAL', command: { type: 'find', find: 'f', count: 1 } }, - createInitialPersistent(), - { lines: [line], line: 0, column: 0 }, - target, - ); - - expect(result).toEqual({ - state: { mode: 'NORMAL', command: { type: 'idle' } }, - persistent: { - ...createInitialPersistent(), - lastFind: { type: 'f', char: target }, - }, - buffer: { lines: [line], line: 0, column: 1 }, - handled: true, - }); - }); - - it.each(['ArrowRight', 'bc'])( - 'cancels a find pending state when %s is not one grapheme', - (key) => { - const result = applyKey( - { mode: 'NORMAL', command: { type: 'find', find: 'f', count: 1 } }, - createInitialPersistent(), - { lines: ['abc'], line: 0, column: 0 }, - key, - ); - - expect(result).toEqual({ - state: { mode: 'NORMAL', command: { type: 'idle' } }, - persistent: createInitialPersistent(), - buffer: { lines: ['abc'], line: 0, column: 0 }, - handled: true, - }); - }, - ); - - it('never leaks a NORMAL-mode key to the editor', () => { - const keys = ['h', 'q', 'x', '\n', 'ArrowLeft', '😀']; - for (const key of keys) { - const result = applyKey( - { mode: 'NORMAL', command: { type: 'idle' } }, - createInitialPersistent(), - { lines: ['abc'], line: 0, column: 1 }, - key, - ); - expect(result.handled, key).toBe(true); - } - }); - - it('is pure for frozen inputs', () => { - const state = Object.freeze({ - mode: 'NORMAL' as const, - command: Object.freeze({ type: 'count' as const, digits: '2' }), - }); - const lastFind = Object.freeze({ type: 'f' as const, char: 'x' }); - const persistent = Object.freeze({ - ...createInitialPersistent(), - lastFind, - }); - const lines = Object.freeze(['one two three']); - const buffer = Object.freeze({ lines, line: 0, column: 0 }); - - const first = applyKey(state, persistent, buffer, 'w'); - const second = applyKey(state, persistent, buffer, 'w'); - - expect(first).toEqual(second); - expect(state).toEqual({ - mode: 'NORMAL', - command: { type: 'count', digits: '2' }, - }); - expect(persistent).toEqual({ - ...createInitialPersistent(), - lastFind: { type: 'f', char: 'x' }, - }); - expect(buffer).toEqual({ lines: ['one two three'], line: 0, column: 0 }); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/text-objects.test.ts b/apps/pythinker-code/test/tui/editor/vim/text-objects.test.ts deleted file mode 100644 index bbf5d1a2..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/text-objects.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - findTextObject, - type OperatorRange, - type TextObjScope, - type VimBuffer, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -type TextObjectCase = readonly [ - name: string, - lines: readonly string[], - cursor: Cursor, - scope: TextObjScope, - kind: string, - expected: OperatorRange | null, -]; - -const cases: readonly TextObjectCase[] = [ - ['iw selects a keyword run', ['one two'], [0, 5], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 7, - }], - ['iw selects a whitespace run', ['one two'], [0, 4], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 3, endLine: 0, endColumn: 6, - }], - ['aw includes trailing whitespace', ['one two three'], [0, 5], 'around', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 8, - }], - ['aw includes leading whitespace when trailing whitespace is absent', ['one two'], [0, 5], 'around', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 3, endLine: 0, endColumn: 7, - }], - ['inner quote excludes its delimiters', ['say "hello" now'], [0, 7], 'inner', '"', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 5, endLine: 0, endColumn: 10, - }], - ['around quote includes delimiters and a trailing space', ['say "hello" now'], [0, 7], 'around', '"', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 12, - }], - ['single quotes are line-local', ["'one' and 'two'"], [0, 12], 'inner', "'", { - kind: 'charwise-exclusive', startLine: 0, startColumn: 11, endLine: 0, endColumn: 14, - }], - ['backticks are supported', ['use `name` now'], [0, 6], 'around', '`', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 11, - }], - ['an unmatched quote returns null', ['say "open'], [0, 6], 'inner', '"', null], - ['quotes never span lines', ['"open', 'close"'], [0, 2], 'inner', '"', null], - ['inner parentheses choose the innermost nested pair', ['f(g(x))'], [0, 4], 'inner', '(', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 5, - }], - ['the closing parenthesis key selects parentheses', ['f(g(x))'], [0, 4], 'around', ')', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 3, endLine: 0, endColumn: 6, - }], - ['b aliases parentheses', ['f(g(x))'], [0, 4], 'inner', 'b', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 4, endLine: 0, endColumn: 5, - }], - ['square brackets may be nested', ['a[b[c]d]e'], [0, 4], 'around', ']', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 3, endLine: 0, endColumn: 6, - }], - ['around braces may span lines', ['a {', ' b', '} c'], [1, 2], 'around', '{', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 2, endLine: 2, endColumn: 1, - }], - ['B aliases braces', ['{x}'], [0, 1], 'inner', 'B', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 1, endLine: 0, endColumn: 2, - }], - ['angle brackets are supported', ['a <b> c'], [0, 3], 'around', '>', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 2, endLine: 0, endColumn: 5, - }], - ['an unmatched bracket returns null', ['a (b'], [0, 3], 'inner', '(', null], - ['a cursor outside a paired bracket returns null', ['(a) b'], [0, 4], 'inner', '(', null], - ['a CJK word is selected by grapheme boundaries', ['\u732B\u72D7 bird'], [0, 1], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 0, endLine: 0, endColumn: 2, - }], - ['a combining sequence stays inside its keyword run', ['e\u0301x y'], [0, 0], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 0, endLine: 0, endColumn: 2, - }], - ['a flag is selected as one grapheme', ['a 🇺🇸 b'], [0, 2], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 2, endLine: 0, endColumn: 3, - }], - ['a ZWJ emoji before a quote occupies one column', ['👩‍💻 "e\u0301"'], [0, 3], 'inner', '"', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 3, endLine: 0, endColumn: 4, - }], - ['an emoji punctuation run is selected whole', ['a \u{1F600} b'], [0, 2], 'inner', 'w', { - kind: 'charwise-exclusive', startLine: 0, startColumn: 2, endLine: 0, endColumn: 3, - }], -]; - -describe('vim text objects', () => { - it.each(cases)('%s', (_name, lines, cursor, scope, kind, expected) => { - const frozenLines = Object.freeze([...lines]); - const buffer: VimBuffer = Object.freeze({ - lines: frozenLines, - line: cursor[0], - column: cursor[1], - }); - const before = { lines: [...frozenLines], line: buffer.line, column: buffer.column }; - - expect(findTextObject(buffer, scope, kind)).toEqual(expected); - expect(buffer).toEqual(before); - }); -}); diff --git a/apps/pythinker-code/test/tui/editor/vim/visual.test.ts b/apps/pythinker-code/test/tui/editor/vim/visual.test.ts deleted file mode 100644 index aec57fb5..00000000 --- a/apps/pythinker-code/test/tui/editor/vim/visual.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - applyKey, - createInitialPersistent, - createInitialState, - type PersistentState, - type VimBuffer, - type VimState, -} from '../../../../src/tui/editor/vim'; - -type Cursor = readonly [line: number, column: number]; -interface InitialRegister { - readonly content: string; - readonly linewise: boolean; -} -interface ExpectedVisual { - readonly lines: readonly string[]; - readonly cursor: Cursor; - readonly mode: VimState['mode']; - readonly register?: string; - readonly registerIsLinewise?: boolean; - readonly visualKind?: 'char' | 'line'; -} -type VisualCase = readonly [ - name: string, - initialLines: readonly string[], - cursor: Cursor, - keys: string, - initialRegister: InitialRegister | null, - expected: ExpectedVisual, -]; - -function runKeys( - lines: readonly string[], - cursor: Cursor, - keys: string, - initialRegister: InitialRegister | null = null, -): ReturnType<typeof applyKey> { - let state: VimState = createInitialState(); - let persistent: PersistentState = { - ...createInitialPersistent(), - register: initialRegister?.content ?? '', - registerIsLinewise: initialRegister?.linewise ?? false, - }; - let buffer: VimBuffer = { lines, line: cursor[0], column: cursor[1] }; - let handled = true; - - for (const key of Array.from(keys)) { - const result = applyKey(state, persistent, buffer, key); - state = result.state; - persistent = result.persistent; - buffer = result.buffer; - handled = result.handled; - } - - return { state, persistent, buffer, handled }; -} - -function expectedState( - expected: ExpectedVisual, -): - | { readonly mode: VimState['mode'] } - | { readonly mode: VimState['mode']; readonly kind: 'char' | 'line' } { - return expected.visualKind === undefined - ? { mode: expected.mode } - : { mode: expected.mode, kind: expected.visualKind }; -} - -function expectedRegister( - expected: ExpectedVisual, - initialRegister: InitialRegister | null, -): InitialRegister { - return { - content: expected.register ?? initialRegister?.content ?? '', - linewise: - expected.registerIsLinewise - ?? initialRegister?.linewise - ?? false, - }; -} - -const cases: readonly VisualCase[] = [ - ['charwise visual selection is inclusive', ['abc'], [0, 0], 'vld', null, { - lines: ['c'], cursor: [0, 0], mode: 'NORMAL', register: 'ab', - }], - ['visual delete removes the character under the cursor', ['abc'], [0, 1], 'vd', null, { - lines: ['ac'], cursor: [0, 1], mode: 'NORMAL', register: 'b', - }], - ['a backward visual selection is normalised', ['abc'], [0, 1], 'vhd', null, { - lines: ['c'], cursor: [0, 0], mode: 'NORMAL', register: 'ab', - }], - ['linewise visual delete removes a whole line', ['one', 'two'], [0, 1], 'Vd', null, { - lines: ['two'], cursor: [0, 0], mode: 'NORMAL', register: 'one', - registerIsLinewise: true, - }], - ['a counted linewise motion extends the selection', ['one', 'two', 'three', 'four', 'five'], [0, 0], 'V3jd', null, { - lines: ['five'], cursor: [0, 0], mode: 'NORMAL', - register: 'one\ntwo\nthree\nfour', registerIsLinewise: true, - }], - ['o swaps the visual ends before extending', ['abcde'], [0, 2], 'vhold', null, { - lines: ['ae'], cursor: [0, 1], mode: 'NORMAL', register: 'bcd', - }], - ['v then V switches visual kind without exiting', ['abc'], [0, 1], 'vV', null, { - lines: ['abc'], cursor: [0, 1], mode: 'VISUAL', visualKind: 'line', - }], - ['v exits charwise visual mode', ['abc'], [0, 1], 'vv', null, { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['V exits linewise visual mode', ['abc'], [0, 1], 'VV', null, { - lines: ['abc'], cursor: [0, 1], mode: 'NORMAL', - }], - ['v switches linewise visual mode to charwise', ['abc'], [0, 1], 'Vv', null, { - lines: ['abc'], cursor: [0, 1], mode: 'VISUAL', visualKind: 'char', - }], - ['a counted word motion works in visual mode', ['one two three four'], [0, 0], 'v3wd', null, { - lines: ['our'], cursor: [0, 0], mode: 'NORMAL', register: 'one two three f', - }], - ['a visual text object selects the word under the cursor', ['one two'], [0, 5], 'viwd', null, { - lines: ['one '], cursor: [0, 3], mode: 'NORMAL', register: 'two', - }], - ['a null visual text object leaves an empty buffer untouched', [''], [0, 0], 'viwd', null, { - lines: [''], cursor: [0, 0], mode: 'NORMAL', - }], - ['visual yank feeds a following paste', ['abc'], [0, 0], 'vyp', null, { - lines: ['aabc'], cursor: [0, 1], mode: 'NORMAL', register: 'a', - }], - ['visual paste replaces the selection', ['abcde'], [0, 1], 'vlp', { - content: 'XY', linewise: false, - }, { - lines: ['aXYde'], cursor: [0, 2], mode: 'NORMAL', register: 'bc', - }], - ['visual paste preserves an end-of-line insertion boundary', ['abcde'], [0, 3], 'vlp', { - content: 'XY', linewise: false, - }, { - lines: ['abcXY'], cursor: [0, 4], mode: 'NORMAL', register: 'de', - }], - ['a linewise register replaces a charwise selection as whole lines', ['abcde'], [0, 1], 'vlp', { - content: 'XX\nYY', linewise: true, - }, { - lines: ['a', 'XX', 'YY', 'de'], cursor: [1, 0], mode: 'NORMAL', - register: 'bc', registerIsLinewise: false, - }], - ['a charwise register replaces a linewise selection as its own line', ['one', 'two'], [0, 0], 'Vp', { - content: 'XY', linewise: false, - }, { - lines: ['XY', 'two'], cursor: [0, 0], mode: 'NORMAL', register: 'one', - registerIsLinewise: true, - }], - ['a linewise register replaces a linewise selection as whole lines', ['one', 'two'], [0, 0], 'Vp', { - content: 'XX\nYY', linewise: true, - }, { - lines: ['XX', 'YY', 'two'], cursor: [0, 0], mode: 'NORMAL', - register: 'one', registerIsLinewise: true, - }], - ['an empty linewise register replaces a charwise selection with an empty line', ['abcde'], [0, 1], 'vlp', { - content: '', linewise: true, - }, { - lines: ['a', '', 'de'], cursor: [1, 0], mode: 'NORMAL', - register: 'bc', registerIsLinewise: false, - }], - ['Escape leaves the visual selection untouched', ['abc'], [0, 1], 'vl\u001B', null, { - lines: ['abc'], cursor: [0, 2], mode: 'NORMAL', - }], - ['delete lands at the normalised selection start', ['abcdef'], [0, 3], 'v2hd', null, { - lines: ['aef'], cursor: [0, 1], mode: 'NORMAL', register: 'bcd', - }], - ['a charwise visual delete may span lines', ['abc', 'def'], [0, 1], 'vjd', null, { - lines: ['af'], cursor: [0, 1], mode: 'NORMAL', register: 'bc\nde', - }], - ['visual delete does not apply operator-motion d-special', [' a', 'b ', 'c'], [0, 2], 'vjd', null, { - lines: [' ', 'c'], cursor: [0, 1], mode: 'NORMAL', register: 'a\nb ', - }], - ['CJK and emoji are selected as whole graphemes', ['\u732B\u{1F600}x'], [0, 0], 'vld', null, { - lines: ['x'], cursor: [0, 0], mode: 'NORMAL', register: '\u732B\u{1F600}', - }], -]; - -describe('vim visual mode', () => { - it.each(cases)( - '%s', - (_name, initialLines, cursor, keys, initialRegister, expected) => { - const result = runKeys( - initialLines, - cursor, - keys, - initialRegister, - ); - - expect(result.buffer).toEqual({ - lines: expected.lines, - line: expected.cursor[0], - column: expected.cursor[1], - }); - expect(result.state).toMatchObject(expectedState(expected)); - expect({ - content: result.persistent.register, - linewise: result.persistent.registerIsLinewise, - }).toEqual(expectedRegister(expected, initialRegister)); - expect(result.handled).toBe(true); - }, - ); - - it('is pure for a frozen visual anchor', () => { - const anchor = Object.freeze({ line: 0, column: 0 }); - const command = Object.freeze({ type: 'idle' as const }); - const state = Object.freeze({ - mode: 'VISUAL' as const, - kind: 'char' as const, - anchor, - command, - }); - const persistent = Object.freeze({ - ...createInitialPersistent(), - }); - const lines = Object.freeze(['abc']); - const buffer = Object.freeze({ lines, line: 0, column: 1 }); - - const first = applyKey(state, persistent, buffer, 'd'); - const second = applyKey(state, persistent, buffer, 'd'); - - expect(first).toEqual(second); - expect(state).toEqual({ - mode: 'VISUAL', - kind: 'char', - anchor: { line: 0, column: 0 }, - command: { type: 'idle' }, - }); - expect(persistent).toEqual({ - ...createInitialPersistent(), - }); - expect(buffer).toEqual({ lines: ['abc'], line: 0, column: 1 }); - }); -}); diff --git a/apps/pythinker-code/test/tui/export-markdown.test.ts b/apps/pythinker-code/test/tui/export-markdown.test.ts index c87ed5df..1a11565d 100644 --- a/apps/pythinker-code/test/tui/export-markdown.test.ts +++ b/apps/pythinker-code/test/tui/export-markdown.test.ts @@ -316,6 +316,54 @@ describe('buildExportMarkdown', () => { expect(md).toContain('deep thought'); }); + it('renders an uploaded image daemon ref as [image] in the exported user message', () => { + // An uploaded image persists as a self-contained `pythinker-file://` part — + // the export keeps the real text and `[image]`, never the materialization + // path or the internal url. + const msgs: ContextMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'what is this? ' }, + { + type: 'image_url', + imageUrl: { url: 'pythinker-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png' }, + }, + ], + toolCalls: [], + origin: { kind: 'user' }, + }, + assistantMsg('a screenshot'), + ]; + const md = buildExportMarkdown({ + sessionId: 'ses_test', + workDir: '/tmp', + history: msgs, + tokenCount: 0, + now, + }); + expect(md).toContain('what is this?'); + expect(md).toContain('[image]'); + expect(md).not.toContain('/Users/alice'); + expect(md).not.toContain('pythinker-file'); + expect(md).not.toContain('<image path='); + }); + + it('keeps an unpaired standalone <media path> tag as user text in the export', () => { + const msgs: ContextMessage[] = [ + userMsg('<image path="/tmp/shot.png">', { kind: 'user' }), + assistantMsg('ok'), + ]; + const md = buildExportMarkdown({ + sessionId: 'ses_test', + workDir: '/tmp', + history: msgs, + tokenCount: 0, + now, + }); + expect(md).toContain('<image path="/tmp/shot.png">'); + }); + it('renders tool calls and results', () => { const tc = makeToolCall('c1', 'Read', { file_path: '/foo.ts' }); const msgs: ContextMessage[] = [ diff --git a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts new file mode 100644 index 00000000..016a1710 --- /dev/null +++ b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts @@ -0,0 +1,170 @@ +/** + * Fullscreen layout contract tests: the docked chrome must keep the editor's + * full height (top border / input / bottom border) even when the transcript + * far exceeds the screen. Regression: the dock used to participate in VStack + * shrink distribution with no minSize, so a tall transcript crushed it and + * the editor's bottom border row was clipped off screen. + */ +import { describe, expect, it, vi } from 'vitest'; + +import { Spacer, type Terminal, TuiAltScreen } from '@pymodel/pi-tui'; +import { VirtualTerminal } from '../../../../packages/pi-tui/test/virtual-terminal'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StatusMessageComponent } from '#/tui/components/messages/status-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { createTUIState, type PythinkerTUIOptions } from '#/tui/pythinker-tui'; +import type { AppState } from '#/tui/types'; + +const WIDTH = 120; +const HEIGHT = 30; + +function fakeInitialAppState(): AppState { + return { + model: 'test-model', + workDir: '/tmp/pythinker-test', + additionalDirs: [], + sessionId: 'sess-1', + permissionMode: 'manual', + planMode: false, + inputMode: 'prompt', + dynamicWorkflowMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + stepRetry: null, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + mcpServersSummary: null, + }; +} + +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); +} + +const LONG_MARKDOWN = Array.from( + { length: 40 }, + (_, i) => `### Section ${i + 1}\n\nSome **bold** and \`code\` content in paragraph ${i + 1}.\n`, +).join('\n'); + +async function mountFullscreen(): Promise<{ + state: ReturnType<typeof createTUIState>; + vt: VirtualTerminal; +}> { + const opts: PythinkerTUIOptions = { + initialAppState: fakeInitialAppState(), + startup: { continueLast: false, yolo: false, auto: false, plan: false }, + }; + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + const state = createTUIState(opts); + vi.unstubAllEnvs(); + const vt = new VirtualTerminal(WIDTH, HEIGHT); + (state.ui as { terminal: Terminal }).terminal = vt; + + // Footer is mounted into the dock after init (mirrors mountFooter()). + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(state.footer); + state.dockContainer?.addChild(footerWrap, { shrink: 1, minSize: 1 }); + state.editorContainer.addChild(state.editor); + state.ui.setFocus(state.editor); + state.ui.start(); + await vt.waitForRender(); + return { state, vt }; +} + +describe('fullscreen layout', () => { + it('keeps the editor bottom border visible after a streaming grow/shrink cycle', async () => { + const { state, vt } = await mountFullscreen(); + expect(state.ui).toBeInstanceOf(TuiAltScreen); + + const screenRows = (): string[] => { + const rows: string[] = []; + for (let i = 0; i < HEIGHT; i++) rows.push(stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + return rows; + }; + + // User message, then a streaming assistant message with the activity pane up. + state.transcriptContainer.addChild(new UserMessageComponent('\u5206\u6790\u4E0B\u8FD9\u4E2A\u9879\u76EE')); + const spinner = new MoonLoader(state.ui); + state.activityContainer.addChild( + new ActivityPaneComponent({ mode: 'tool', spinner, tip: 'streaming' }), + ); + const assistant = new AssistantMessageComponent(); + state.transcriptContainer.addChild(assistant); + assistant.updateContent(LONG_MARKDOWN, { transient: true }); + state.ui.requestRender(true); + await vt.waitForRender(); + + // Streaming ends: final highlight, spinner -> one-row placeholder, debug line. + assistant.updateContent(LONG_MARKDOWN, { transient: false }); + state.activityContainer.clear(); + state.activityContainer.addChild(new Spacer(1)); + state.transcriptContainer.addChild( + new StatusMessageComponent('[Debug] TTFT: 4.3s | TPS: 203 tok/s'), + ); + state.ui.requestRender(true); + await vt.waitForRender(); + + const rows = screenRows(); + const promptRow = rows.findIndex((line) => /│\s*>/.test(line)); + expect(promptRow).toBeGreaterThan(0); + expect(rows[promptRow + 1]).toContain('╰'); + + state.ui.stop(); + }); + + it('jumps between prompts with Ctrl-Shift-Up/Down (OSC 133 zones survive the chain)', async () => { + const { state, vt } = await mountFullscreen(); + + state.transcriptContainer.addChild(new UserMessageComponent('\u7B2C\u4E00\u8F6E\u63D0\u95EE')); + const first = new AssistantMessageComponent(); + state.transcriptContainer.addChild(first); + first.updateContent(`\u56DE\u7B54\u4E00\n\n${LONG_MARKDOWN}`); + state.transcriptContainer.addChild(new UserMessageComponent('\u7B2C\u4E8C\u8F6E\u63D0\u95EE')); + const second = new AssistantMessageComponent(); + state.transcriptContainer.addChild(second); + second.updateContent(`\u56DE\u7B54\u4E8C\n\n${LONG_MARKDOWN}`); + state.ui.requestRender(true); + await vt.waitForRender(); + + const alt = state.ui as TuiAltScreen; + expect(alt.isFollowingOutput).toBe(true); + + const topRows = (): string[] => + Array.from({ length: 6 }, (_, i) => stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + + // Zones anchor every user/assistant message, so the nearest previous zone + // below the fold is the current turn's assistant message, then the user + // message that started the turn. + vt.sendInput('\x1b[1;6A'); // ctrl+shift+up = previous prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('\u56DE\u7B54\u4E8C'); + + vt.sendInput('\x1b[1;6A'); + await vt.waitForRender(); + expect(topRows()[1]).toContain('\u7B2C\u4E8C\u8F6E\u63D0\u95EE'); + + vt.sendInput('\x1b[1;6B'); // ctrl+shift+down = next prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('\u56DE\u7B54\u4E8C'); + + state.ui.stop(); + }); +}); diff --git a/apps/pythinker-code/test/tui/input/image-attachment-store.test.ts b/apps/pythinker-code/test/tui/input/image-attachment-store.test.ts index cb3e88c5..27038b7c 100644 --- a/apps/pythinker-code/test/tui/input/image-attachment-store.test.ts +++ b/apps/pythinker-code/test/tui/input/image-attachment-store.test.ts @@ -49,14 +49,146 @@ describe('ImageAttachmentStore', () => { expect(att.mime).toBe('image/jpeg'); }); + it('completes a pending image without changing its attachment id', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 20); + + const completed = s.completeImage(att, { + bytes: new Uint8Array([2, 3]), + mime: 'image/jpeg', + width: 30, + height: 40, + fileId: 'file-2', + }); + + expect(completed).toBe(att); + expect(att.id).toBe(1); + expect(att.bytes).toEqual(new Uint8Array([2, 3])); + expect(att.mime).toBe('image/jpeg'); + expect(att.placeholder).toBe('[image #1 (30×40)]'); + const stale = att; + s.clear(); + const fresh = s.addImage(new Uint8Array([9]), 'image/png', 2, 2); + expect(s.completeImage(stale, { + bytes: new Uint8Array([8]), + mime: 'image/png', + width: 3, + height: 3, + })).toBeUndefined(); + expect(fresh.bytes).toEqual(new Uint8Array([9])); + }); + + it('records the daemon file-store id when the paste was uploaded (v2)', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 20, undefined, 'file-abc'); + expect(att.fileId).toBe('file-abc'); + }); + + it('leaves fileId undefined for attachments that were not uploaded', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 20); + expect(att.fileId).toBeUndefined(); + }); + it('clear() resets ids and empties storage', () => { const s = new ImageAttachmentStore(); - s.addImage(new Uint8Array(), 'image/png', 10, 10); + s.addImage(new Uint8Array(), 'image/png', 10, 10, undefined, 'file-1'); s.addImage(new Uint8Array(), 'image/png', 10, 10); expect(s.size()).toBe(2); - s.clear(); + expect(s.clear()).toEqual(['file-1']); expect(s.size()).toBe(0); const next = s.addImage(new Uint8Array(), 'image/png', 10, 10); expect(next.id).toBe(1); }); + + it('remove() drops a single attachment without resetting ids', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + expect(s.size()).toBe(2); + s.remove(a.id); + expect(s.size()).toBe(1); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(b.id)).toBe(b); + // Unlike clear(), remove() must not reset the id counter. + const next = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + expect(next.id).toBe(3); + }); + + it('removeMany() drops many attachments at once', () => { + const s = new ImageAttachmentStore(); + const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10); + const c = s.addImage(new Uint8Array([3]), 'image/png', 10, 10); + s.removeMany([a.id, c.id]); + expect(s.size()).toBe(1); + expect(s.get(b.id)).toBe(b); + expect(s.get(a.id)).toBeUndefined(); + expect(s.get(c.id)).toBeUndefined(); + }); + + it('transfers staging file ownership without dropping thumbnail bytes', () => { + const s = new ImageAttachmentStore(); + const bytes = new Uint8Array([1, 2, 3]); + const att = s.addImage(bytes, 'image/png', 10, 10, undefined, 'file-1'); + + expect(s.takeFileIds([att.id])).toEqual(['file-1']); + expect(att.fileId).toBeUndefined(); + expect(att.bytes).toBe(bytes); + expect(s.takeFileIds([att.id])).toEqual([]); + }); + + it('keeps a daemon upload until every extracted message releases it', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 10, undefined, 'file-1'); + + s.retainFileIds([att.id]); + s.retainFileIds([att.id]); + expect(s.takeFileIds([att.id])).toEqual([]); + expect(att.fileId).toBe('file-1'); + expect(s.takeFileIds([att.id])).toEqual(['file-1']); + expect(att.fileId).toBeUndefined(); + }); + + it('releaseRetains consumes the retain but keeps the staged upload on the attachment', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 10, undefined, 'file-1'); + + s.retainFileIds([att.id]); + s.releaseRetains([att.id]); + expect(att.fileId).toBe('file-1'); + // The retain is gone: a later take consumes the upload immediately. + expect(s.takeFileIds([att.id])).toEqual(['file-1']); + expect(att.fileId).toBeUndefined(); + }); + + it('releaseRetains leaves retains held by other submissions untouched', () => { + const s = new ImageAttachmentStore(); + const att = s.addImage(new Uint8Array([1]), 'image/png', 10, 10, undefined, 'file-1'); + + s.retainFileIds([att.id]); // submission A queues + s.retainFileIds([att.id]); // submission B queues + s.releaseRetains([att.id]); // A is recalled into the editor + s.retainFileIds([att.id]); // A's restored draft resubmits + // A's consuming turn ends: one retain (B's) is still outstanding, so the + // upload survives. + expect(s.takeFileIds([att.id])).toEqual([]); + expect(att.fileId).toBe('file-1'); + // B's turn ends: the last retain is gone, the upload is taken. + expect(s.takeFileIds([att.id])).toEqual(['file-1']); + expect(att.fileId).toBeUndefined(); + }); + + it('rebaseVideoSource repoints a recalled video at its staged cache copy', () => { + const s = new ImageAttachmentStore(); + const att = s.addVideo('video/mp4', '/tmp/original.mp4'); + + s.rebaseVideoSource(att.id, '/cache/original.mp4'); + expect(att.sourcePath).toBe('/cache/original.mp4'); + + // Images and unknown ids are ignored. + const image = s.addImage(new Uint8Array([1]), 'image/png', 10, 10); + s.rebaseVideoSource(image.id, '/cache/nope'); + expect(s.get(image.id)).toBe(image); + }); }); diff --git a/apps/pythinker-code/test/tui/input/image-placeholder.test.ts b/apps/pythinker-code/test/tui/input/image-placeholder.test.ts index cdc74e91..74d25293 100644 --- a/apps/pythinker-code/test/tui/input/image-placeholder.test.ts +++ b/apps/pythinker-code/test/tui/input/image-placeholder.test.ts @@ -1,7 +1,29 @@ +/** + * Media placeholder expansion and rewrite contracts, including dispatch-time + * fallback from expiring daemon uploads to bytes retained by the TUI. + */ + +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { describe, it, expect } from 'vitest'; +import { parseDaemonFileUrl } from '@pymodel/pythinker-code-sdk'; + +import { PYTHINKER_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { + extractMediaAttachments, + makeExtractionResendable, + pendingImageIngestions, + persistOriginalImageSync, + refreshExpiringImageFileRefs, + resolveOriginalCaptions, + rewriteMediaPlaceholders, +} from '#/tui/utils/image-placeholder'; +import { getCacheDir } from '#/utils/paths'; function storeWith( bytes: Uint8Array, @@ -13,6 +35,37 @@ function storeWith( return { store, placeholder: att.placeholder }; } +/** Point `getCacheDir()` at a fresh temp home for the duration of a test. */ +function setupTempCache(): { cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), 'pythinker-home-')); + const prev = process.env[PYTHINKER_CODE_HOME_ENV]; + process.env[PYTHINKER_CODE_HOME_ENV] = home; + return { + cleanup: () => { + if (prev === undefined) delete process.env[PYTHINKER_CODE_HOME_ENV]; + else process.env[PYTHINKER_CODE_HOME_ENV] = prev; + rmSync(home, { recursive: true, force: true }); + }, + }; +} + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), 'pythinker-src-')); +} + +type VideoUrlPart = { type: 'video_url'; videoUrl: { url: string } }; + +// Prompt-attached videos are emitted as a `video_url` part whose url is a +// local `file://` reference to the cache copy; decode it back to a filesystem +// path for assertions. +function videoPathFromParts(parts: unknown[]): string { + const part = parts.find( + (p): p is VideoUrlPart => (p as VideoUrlPart).type === 'video_url', + ); + if (!part) throw new Error(`no video_url part found in: ${JSON.stringify(parts)}`); + return fileURLToPath(part.videoUrl.url); +} + describe('extractMediaAttachments', () => { it('returns no parts and hasMedia=false for plain text', () => { const store = new ImageAttachmentStore(); @@ -52,18 +105,30 @@ describe('extractMediaAttachments', () => { }); it('keeps matched-placeholder order with mixed image and video attachments', () => { - const store = new ImageAttachmentStore(); - const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); - const vid = store.addVideo('video/quicktime', '/tmp/clip.mov'); - const text = `first ${img.placeholder} then ${vid.placeholder} end`; - const r = extractMediaAttachments(text, store); - expect(r.imageAttachmentIds).toEqual([1]); - expect(r.videoAttachmentIds).toEqual([2]); - expect(r.parts).toEqual([ - { type: 'text', text: 'first ' }, - { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } }, - { type: 'text', text: ' then <video path="/tmp/clip.mov"></video> end' }, - ]); + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const vid = store.addVideo('video/quicktime', srcVideo); + const text = `first ${img.placeholder} then ${vid.placeholder} end`; + const r = extractMediaAttachments(text, store); + expect(r.imageAttachmentIds).toEqual([1]); + expect(r.videoAttachmentIds).toEqual([2]); + expect(r.parts[0]).toEqual({ type: 'text', text: 'first ' }); + expect(r.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQ==' }, + }); + const cachePath = videoPathFromParts(r.parts); + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(readFileSync(cachePath, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } }); it('leaves unresolved (typed by hand) placeholders as literal text', () => { @@ -84,21 +149,709 @@ describe('extractMediaAttachments', () => { }); }); - it('escapes media paths in generated tags', () => { + it('keeps the video label (including special chars) in the cache path', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'source.mp4'); + writeFileSync(srcVideo, 'x'); + const store = new ImageAttachmentStore(); + // The filename drives the cache label; `&` is a valid path char the cache + // copy keeps verbatim (the engine escapes it if it later renders a tag). + const att = store.addVideo('video/mp4', srcVideo, 'a&b.mp4'); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.parts).toHaveLength(1); + expect((r.parts[0] as VideoUrlPart).type).toBe('video_url'); + expect(videoPathFromParts(r.parts).endsWith('a&b.mp4')).toBe(true); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('copies video placeholders into the cache and emits a file:// video_url part', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'sample.mp4'); + writeFileSync(srcVideo, 'video-data'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/mp4', srcVideo); + const r = extractMediaAttachments(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const part = r.parts[0] as VideoUrlPart; + expect(part.type).toBe('video_url'); + expect(part.videoUrl.url.startsWith('file:')).toBe(true); + const cachePath = videoPathFromParts(r.parts); + // The part points at the cache copy, not the original source path. + expect(cachePath.startsWith(getCacheDir())).toBe(true); + expect(cachePath).not.toBe(srcVideo); + expect(readFileSync(cachePath, 'utf8')).toBe('video-data'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('expands a compressed paste without a caption — captions are authored at dispatch', () => { const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/a&"<>.mp4', 'sample.mp4'); - const r = extractMediaAttachments(att.placeholder, store); + const att = store.addImage(new Uint8Array([1, 2, 3]), 'image/png', 2000, 2000, { + bytes: new Uint8Array([9, 8, 7]), + width: 2600, + height: 2600, + byteLength: 3, + mime: 'image/png', + }); + + const r = extractMediaAttachments(`look ${att.placeholder}`, store); + + // Extraction stays persistence-free: no caption part, no original path. expect(r.parts).toEqual([ - { type: 'text', text: '<video path="/tmp/a&"<>.mp4"></video>' }, + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQID' } }, ]); + expect(att.original?.path).toBeUndefined(); + }); + + it('adds no caption for an uncompressed image attachment', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa])); + const r = extractMediaAttachments(placeholder, store); + expect(r.parts).toHaveLength(1); + expect(r.parts[0]?.type).toBe('image_url'); + }); + + it('expands an uploaded (fileId) image into a bare pythinker-file reference', () => { + const { cleanup } = setupTempCache(); + try { + const store = new ImageAttachmentStore(); + const att = store.addImage(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), 'image/png', 640, 480, undefined, 'file-1'); + const r = extractMediaAttachments(`describe ${att.placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + // No tag text part and no `?path=`: the engine's prompt intake + // materializes the session copy and rewrites the reference with its + // path — the part is self-contained, no paired tag is authored. + expect(r.parts).toEqual([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-1' } }, + { type: 'text', text: ' please' }, + ]); + expect(parseDaemonFileUrl('pythinker-file://file-1')).toEqual({ fileId: 'file-1' }); + // The edge stages no local copy for an uploaded image — the cache dir + // is never even created. + expect(r.stagingPaths).toEqual([]); + expect(existsSync(getCacheDir())).toBe(false); + } finally { + cleanup(); + } }); - it('expands video placeholders backed by local files to readMediaFile video tags', () => { + it('falls back to retained bytes when an uploaded image is too close to expiry', () => { const store = new ImageAttachmentStore(); - const att = store.addVideo('video/mp4', '/tmp/sample.mp4'); - const r = extractMediaAttachments(att.placeholder, store); - expect(r.hasMedia).toBe(true); - expect(r.videoAttachmentIds).toEqual([1]); - expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]); + const att = store.addImage( + new Uint8Array([0x89, 0x50, 0x4e, 0x47]), + 'image/png', + 640, + 480, + undefined, + 'file-1', + 1_060_000, + ); + + const parts = refreshExpiringImageFileRefs( + [{ type: 'image_url', imageUrl: { url: 'pythinker-file://file-1' } }], + [att.id], + store, + 1_000_000, + ); + + expect(parts).toEqual([ + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,iVBORw==' } }, + ]); + expect(att.fileId).toBeUndefined(); + expect(att.fileExpiresAt).toBeUndefined(); + }); + + it('rebuilds an uploaded image as inline bytes for a new-session resend', () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const store = new ImageAttachmentStore(); + const att = store.addImage(bytes, 'image/png', 640, 480, undefined, 'file-1'); + const extraction = extractMediaAttachments(att.placeholder, store); + + const resend = makeExtractionResendable(extraction); + + expect(resend.imageAttachmentIds).toEqual([]); + expect(resend.parts).toContainEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,iVBORw==' }, + }); + expect(resend.stagingPaths).toHaveLength(0); + } finally { + cleanup(); + } + }); + + it('rebuilds a compressed paste with its caption and original for a new-session resend', () => { + const dir = makeTempDir(); + try { + const store = new ImageAttachmentStore(); + const att = store.addImage( + new Uint8Array([1, 2, 3]), + 'image/png', + 2000, + 1000, + { + bytes: new Uint8Array([9, 8, 7, 6]), + width: 2600, + height: 2600, + byteLength: 4, + mime: 'image/png', + }, + 'file-1', + ); + // The session reset clears the store, so the snapshot is the only place + // the original survives — the resend must persist it into the NEW + // session's originals dir and author the caption itself. + const extraction = extractMediaAttachments(att.placeholder, store); + + const resend = makeExtractionResendable(extraction, dir); + + expect(resend.imageAttachmentIds).toEqual([]); + expect(resend.parts).toHaveLength(2); + const caption = resend.parts[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('2600x2600'); + const files = readdirSync(dir); + expect(files).toHaveLength(1); + expect(caption.text).toContain(join(dir, files[0]!)); + expect(resend.parts[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQID' }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps expanding an uploaded image as a bare reference when the cache dir is broken', () => { + const { cleanup } = setupTempCache(); + try { + // A file at the cache dir path breaks local cache copies, but neither + // form stages one: an uploaded image expands to a bare reference and + // the inline (no fileId) form embeds its bytes. + writeFileSync(getCacheDir(), 'occupied'); + const store = new ImageAttachmentStore(); + const uploaded = store.addImage(new Uint8Array([1]), 'image/png', 10, 10, undefined, 'file-1'); + const plain = store.addImage(new Uint8Array([2]), 'image/png', 20, 20); + const r = extractMediaAttachments(`${uploaded.placeholder} and ${plain.placeholder}`, store); + expect(r.imageAttachmentIds).toEqual([1, 2]); + expect(r.parts).toEqual([ + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-1' } }, + { type: 'text', text: ' and ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,Ag==' } }, + ]); + } finally { + cleanup(); + } + }); + + it('rolls back cache copies when a later attachment cannot be materialized', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const firstPath = join(srcDir, 'first.mp4'); + writeFileSync(firstPath, 'video-bytes'); + const store = new ImageAttachmentStore(); + const first = store.addVideo('video/mp4', firstPath); + const missing = store.addVideo('video/mp4', join(srcDir, 'missing.mp4')); + + expect(() => + extractMediaAttachments(`${first.placeholder} ${missing.placeholder}`, store), + ).toThrow(); + expect(readdirSync(getCacheDir())).toEqual([]); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); +}); + +describe('resolveOriginalCaptions', () => { + function storeWithOriginal( + original?: { + bytes: Uint8Array; + width: number; + height: number; + byteLength: number; + mime: string; + path?: string; + }, + fileId?: string, + ) { + const store = new ImageAttachmentStore(); + const att = store.addImage( + new Uint8Array([1, 2, 3]), + 'image/png', + 2000, + 1000, + original, + fileId, + ); + return { store, att }; + } + + it('persists the original into the given dir and inserts the caption before the image', () => { + const dir = makeTempDir(); + try { + const originalBytes = new Uint8Array([9, 8, 7, 6]); + const { store, att } = storeWithOriginal({ + bytes: originalBytes, + width: 2600, + height: 2600, + byteLength: originalBytes.length, + mime: 'image/png', + }); + const r = extractMediaAttachments(`look ${att.placeholder}`, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(att.original?.path?.startsWith(dir)).toBe(true); + expect(readFileSync(att.original!.path!)).toEqual(Buffer.from(originalBytes)); + expect(resolved).toHaveLength(3); + const caption = resolved[1]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain(att.original!.path!); + expect(resolved[2]).toEqual({ + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,AQID' }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('releases the in-memory original bytes once persistence succeeds', () => { + const dir = makeTempDir(); + try { + const originalBytes = new Uint8Array([9, 8, 7, 6]); + const { store, att } = storeWithOriginal({ + bytes: originalBytes, + width: 2600, + height: 2600, + byteLength: originalBytes.length, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + // The on-disk copy is the original from here on; the caption still + // renders the original size from the retained metadata. + expect(att.original?.bytes).toBeUndefined(); + const again = resolveOriginalCaptions( + r.parts, + r.imageAttachmentIds, + store, + dir, + ); + const caption = again[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('2600x2600'); + expect(caption.text).toContain('4 B'); + expect(caption.text).toContain(att.original!.path!); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('authors the caption before the bare pythinker-file reference', () => { + const dir = makeTempDir(); + try { + const { store, att } = storeWithOriginal( + { bytes: new Uint8Array([9, 9]), width: 2600, height: 2600, byteLength: 2, mime: 'image/png' }, + 'file-2', + ); + const r = extractMediaAttachments(att.placeholder, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(resolved).toHaveLength(2); + const caption = resolved[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain('Image compressed'); + expect(caption.text).toContain(att.original!.path!); + expect(resolved[1]).toEqual({ + type: 'image_url', + imageUrl: { url: 'pythinker-file://file-2' }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refreshes an already-authored caption in place instead of duplicating it', () => { + const dir = makeTempDir(); + try { + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([9]), + width: 2600, + height: 2600, + byteLength: 1, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + const once = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + const twice = resolveOriginalCaptions(once, r.imageAttachmentIds, store, dir); + + expect(twice).toHaveLength(2); + expect(twice[0]?.type).toBe('text'); + expect(twice[1]?.type).toBe('image_url'); + // The content-addressed original was persisted exactly once. + expect(att.original?.path?.startsWith(dir)).toBe(true); + expect(readdirSync(dir)).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('reuses an already-persisted original path without rewriting the file', () => { + const dir = makeTempDir(); + try { + const existing = join(dir, 'already.png'); + writeFileSync(existing, 'orig'); + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([7, 7, 7]), + width: 2600, + height: 2600, + byteLength: 3, + mime: 'image/png', + path: existing, + }); + const r = extractMediaAttachments(att.placeholder, store); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + const caption = resolved[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toContain(existing); + expect(readFileSync(existing, 'utf8')).toBe('orig'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('notes an unpreserved original when persistence fails, then retries at a later dispatch', () => { + const dir = makeTempDir(); + try { + // A file where the target directory must be created breaks persistence. + const occupied = join(dir, 'occupied'); + writeFileSync(occupied, 'x'); + const { store, att } = storeWithOriginal({ + bytes: new Uint8Array([5, 5]), + width: 2600, + height: 2600, + byteLength: 2, + mime: 'image/png', + }); + const r = extractMediaAttachments(att.placeholder, store); + + const failed = resolveOriginalCaptions( + r.parts, + r.imageAttachmentIds, + store, + join(occupied, 'sub'), + ); + + const caption = failed[0]; + if (caption?.type !== 'text') throw new Error('expected caption text part'); + expect(caption.text).toMatch(/not preserved/i); + // The failure is not terminal: the path stays unset and the bytes are + // retained, so a later dispatch retries the write. + expect(att.original?.path).toBeUndefined(); + expect(att.original?.bytes).toBeDefined(); + + const retried = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(att.original?.path?.startsWith(dir)).toBe(true); + const retryCaption = retried[0]; + if (retryCaption?.type !== 'text') throw new Error('expected caption text part'); + expect(retryCaption.text).toContain(att.original!.path!); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('skips the caption when ingestion landed after extraction (stale inline part)', () => { + const dir = makeTempDir(); + try { + const store = new ImageAttachmentStore(); + const rawBytes = new Uint8Array([1, 2, 3, 4]); + // Extraction raced the background ingestion: the part encodes the raw + // paste bytes… + const att = store.addImage(rawBytes, 'image/png', 2600, 2600); + const r = extractMediaAttachments(att.placeholder, store); + // …then ingestion completed, recording the compressed form. Captioning + // now would describe an image the model did not receive. + store.completeImage(att, { + bytes: new Uint8Array([1, 2, 3]), + mime: 'image/png', + width: 2000, + height: 2000, + original: { bytes: rawBytes, width: 2600, height: 2600, byteLength: 4, mime: 'image/png' }, + }); + + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, dir); + + expect(resolved).toHaveLength(1); + expect(resolved[0]?.type).toBe('image_url'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('leaves images without an original untouched', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa])); + const r = extractMediaAttachments(placeholder, store); + const resolved = resolveOriginalCaptions(r.parts, r.imageAttachmentIds, store, undefined); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.type).toBe('image_url'); + }); +}); + +describe('persistOriginalImageSync', () => { + it('evicts the oldest originals once the store exceeds the size cap', () => { + const dir = makeTempDir(); + try { + const first = persistOriginalImageSync(new Uint8Array(6).fill(1), 'image/png', dir); + expect(first).not.toBeNull(); + // Pin the first file far into the past so eviction order is deterministic. + const old = new Date(Date.now() - 60_000); + utimesSync(first!, old, old); + + const second = persistOriginalImageSync(new Uint8Array(6).fill(2), 'image/png', dir, 10); + + expect(second).not.toBeNull(); + expect(existsSync(first!)).toBe(false); + expect(existsSync(second!)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe('rewriteMediaPlaceholders', () => { + it('returns plain text untouched with hasMedia=false', () => { + const store = new ImageAttachmentStore(); + const r = rewriteMediaPlaceholders('just some args', store); + expect(r.text).toBe('just some args'); + expect(r.hasMedia).toBe(false); + expect(r.imageAttachmentIds).toEqual([]); + expect(r.videoAttachmentIds).toEqual([]); + }); + + it('rewrites an image placeholder into a cache-path image tag', () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholders(`look at ${placeholder} please`, store); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + const m = /^look at <image path="([^"]+)"><\/image> please$/.exec(r.text); + if (!m) throw new Error(`no image tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(m[1]!.endsWith('.png')).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it('rewrites a video placeholder into a cache-path video tag', () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + const m = /<video path="([^"]+)"><\/video>/.exec(r.text); + if (!m) throw new Error(`no video tag found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it('leaves unresolved (typed by hand) placeholders as literal text', () => { + const store = new ImageAttachmentStore(); + const text = 'try [image #999 (1×1)] and [video #42 clip.mov] now'; + const r = rewriteMediaPlaceholders(text, store); + expect(r.text).toBe(text); + expect(r.hasMedia).toBe(false); + }); + + it('preserves surrounding text verbatim across multiple attachments', () => { + const { cleanup } = setupTempCache(); + try { + const store = new ImageAttachmentStore(); + const a = store.addImage(new Uint8Array([1]), 'image/png', 10, 10); + const b = store.addImage(new Uint8Array([2]), 'image/jpeg', 20, 20); + const r = rewriteMediaPlaceholders( + `first ${a.placeholder} then ${b.placeholder} end`, + store, + ); + expect(r.imageAttachmentIds).toEqual([1, 2]); + const tags = [...r.text.matchAll(/<image path="([^"]+)"><\/image>/g)]; + expect(tags).toHaveLength(2); + expect(r.text.startsWith('first <image path=')).toBe(true); + expect(r.text).toContain('> then <image path='); + expect(r.text.endsWith('> end')).toBe(true); + expect(new Uint8Array(readFileSync(tags[0]![1]!))).toEqual(new Uint8Array([1])); + expect(new Uint8Array(readFileSync(tags[1]![1]!))).toEqual(new Uint8Array([2])); + } finally { + cleanup(); + } + }); + + it("rewrites an image placeholder into an escape-proof plain reference in 'plain' style", () => { + const { cleanup } = setupTempCache(); + try { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const { store, placeholder } = storeWith(bytes); + const r = rewriteMediaPlaceholders(`look at ${placeholder}`, store, 'plain'); + expect(r.hasMedia).toBe(true); + expect(r.imageAttachmentIds).toEqual([1]); + // Skill args pass through XML escaping, so the reference must not + // contain any tag/attribute boundary characters. + expect(r.text).not.toMatch(/[<>&"]/); + const m = + /^look at Attached image file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(m[1]!.startsWith(getCacheDir())).toBe(true); + expect(new Uint8Array(readFileSync(m[1]!))).toEqual(bytes); + } finally { + cleanup(); + } + }); + + it("rewrites a video placeholder into an escape-proof plain reference in 'plain' style", () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + const srcVideo = join(srcDir, 'clip.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store, 'plain'); + expect(r.hasMedia).toBe(true); + expect(r.videoAttachmentIds).toEqual([1]); + expect(r.text).not.toMatch(/[<>&"]/); + const m = /^Attached video file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); + + it("sanitizes XML boundary chars out of plain-style video cache names", () => { + const { cleanup } = setupTempCache(); + const srcDir = makeTempDir(); + try { + // The video label keeps the original filename, and sanitizeVideoLabel + // allows `<>&"`; skill args are XML-escaped, so the plain reference + // would point at a path that no longer matches the file on disk. + const srcVideo = join(srcDir, 'clip<1>&.mov'); + writeFileSync(srcVideo, 'video-bytes'); + const store = new ImageAttachmentStore(); + const att = store.addVideo('video/quicktime', srcVideo); + const r = rewriteMediaPlaceholders(att.placeholder, store, 'plain'); + expect(r.text).not.toMatch(/[<>&"]/); + const m = /^Attached video file: (\S+) \(open it with ReadMediaFile\)$/.exec(r.text); + if (!m) throw new Error(`no plain reference found in: ${r.text}`); + expect(readFileSync(m[1]!, 'utf8')).toBe('video-bytes'); + } finally { + cleanup(); + rmSync(srcDir, { recursive: true, force: true }); + } + }); +}); + +describe('pendingImageIngestions', () => { + it('returns undefined for text without image placeholders', () => { + const store = new ImageAttachmentStore(); + expect(pendingImageIngestions('hello world', store, 5)).toBeUndefined(); + }); + + it('returns undefined when no referenced image has a pending ingestion', () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa, 0xbb])); + expect(pendingImageIngestions(`describe ${placeholder}`, store, 5)).toBeUndefined(); + }); + + it('waits for a pending ingestion so extraction can use the daemon-ref form', async () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa, 0xbb])); + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + let finish!: () => void; + att.pending = new Promise<void>((resolve) => { + finish = () => { + // Complete like the background ingestion would: land the upload id, + // then resolve and clear the pending marker. + att.fileId = 'file-1'; + att.fileExpiresAt = Date.now() + 60 * 60 * 1000; + att.pending = undefined; + resolve(); + }; + }); + + const waited = pendingImageIngestions(`describe ${placeholder}`, store, 1_000); + if (waited === undefined) throw new Error('expected a pending wait'); + let settled = false; + void waited.then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(settled).toBe(false); + + finish(); + await waited; + expect(settled).toBe(true); + + const r = extractMediaAttachments(`describe ${placeholder}`, store); + const part = r.parts.find((p) => p.type === 'image_url'); + expect(part?.type).toBe('image_url'); + if (part?.type !== 'image_url') throw new Error('expected an image part'); + expect(parseDaemonFileUrl(part.imageUrl.url)?.fileId).toBe('file-1'); + }); + + it('bounds the wait by the timeout so a slow ingestion extracts to the inline form', async () => { + const { store, placeholder } = storeWith(new Uint8Array([0xaa, 0xbb])); + const att = store.get(1); + if (att?.kind !== 'image') throw new Error('expected image attachment'); + att.pending = new Promise<void>(() => undefined); // never settles + + const start = Date.now(); + const waited = pendingImageIngestions(`describe ${placeholder}`, store, 20); + if (waited === undefined) throw new Error('expected a pending wait'); + await waited; + expect(Date.now() - start).toBeLessThan(1_000); + + const r = extractMediaAttachments(`describe ${placeholder}`, store); + const part = r.parts.find((p) => p.type === 'image_url'); + if (part?.type !== 'image_url') throw new Error('expected an image part'); + expect(part.imageUrl.url.startsWith('data:image/png;base64,')).toBe(true); }); }); diff --git a/apps/pythinker-code/test/tui/keybindings.test.ts b/apps/pythinker-code/test/tui/keybindings.test.ts deleted file mode 100644 index 8355ef8c..00000000 --- a/apps/pythinker-code/test/tui/keybindings.test.ts +++ /dev/null @@ -1,563 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import type { SlashCommandHost } from '#/tui/commands'; -import { handleKeybindingsCommand } from '#/tui/commands/config'; -import { PythinkerTUI } from '#/tui/pythinker-tui'; -import { - defaultKeybindings, - editorShortcutHelp, - generateKeybindingsTemplate, - isKeybindingAware, - KeybindingResolver, - keybindingDisplayText, - loadKeybindings, - parseKeybindingBlocks, - watchKeybindings, -} from '#/tui/keybindings'; - -const fsMocks = vi.hoisted(() => ({ - unwatchFile: vi.fn(), - watchFile: vi.fn(), -})); - -vi.mock('node:fs', async (importOriginal) => ({ - ...(await importOriginal<typeof import('node:fs')>()), - ...fsMocks, -})); - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - vi.clearAllMocks(); - vi.unstubAllEnvs(); - await Promise.all( - temporaryDirectories.splice(0).map((directory) => - rm(directory, { recursive: true, force: true }), - ), - ); -}); - -describe('TUI keybindings', () => { - it('provides every supported context and its default binding block', () => { - const defaults = defaultKeybindings(); - - expect([...new Set(defaults.map((binding) => binding.context))]).toEqual([ - 'Global', - 'Chat', - 'Autocomplete', - 'Confirmation', - 'Help', - 'HistorySearch', - 'Tabs', - 'Footer', - 'MessageSelector', - 'MessageActions', - 'ModelPicker', - 'Select', - 'Plugin', - ]); - expect(defaults.some((binding) => binding.action === 'messageActions:enter')).toBe(true); - }); - - it('binds shift+tab to cycle thinking effort, not plan mode, by default', () => { - const defaults = defaultKeybindings(); - const shiftTab = defaults.find( - (binding) => binding.context === 'Chat' && binding.chord.join(' ') === 'shift+tab', - ); - expect(shiftTab?.action).toBe('chat:thinkingToggle'); - }); - - it('dispatches specific contexts before Global and supports OpenTUI key IDs', () => { - const defaults = defaultKeybindings(); - const resolver = new KeybindingResolver(defaults); - const calls: string[] = []; - - expect( - resolver.dispatch('\u001B[A', ['Select'], { - 'select:previous': () => { - calls.push('select'); - }, - }), - ).toBe(true); - expect(calls).toEqual(['select']); - - const overridden = parseKeybindingBlocks([ - { context: 'Global', bindings: { 'alt+k': 'app:redraw' } }, - { context: 'Select', bindings: { 'alt+k': 'select:previous' } }, - ]); - const contextResolver = new KeybindingResolver(overridden); - expect( - contextResolver.dispatch('\u001Bk', ['Select'], { - 'app:redraw': () => { - calls.push('global'); - }, - 'select:previous': () => { - calls.push('specific'); - }, - }), - ).toBe(true); - expect(calls.at(-1)).toBe('specific'); - expect( - resolver.dispatchKeyId('up', ['Select'], { - 'select:previous': () => { - calls.push('key-id'); - }, - }), - ).toBe(true); - expect(calls.at(-1)).toBe('key-id'); - }); - - it('honors null bindings, ignores unhandled actions, and clears chords on context changes', () => { - const resolver = new KeybindingResolver( - parseKeybindingBlocks([ - { - context: 'Select', - bindings: { - 'alt+x': null, - 'alt+y': 'select:accept', - 'ctrl+k ctrl+g': 'select:accept', - }, - }, - ]), - ); - - expect(resolver.dispatch('\u001Bx', ['Select'], {})).toBe(true); - expect(resolver.dispatch('\u001By', ['Select'], {})).toBe(false); - expect(resolver.dispatch('\u000B', ['Select'], {})).toBe(true); - expect(resolver.dispatch('\u0007', ['Chat'], { 'select:accept': () => {} })).toBe(false); - expect(resolver.dispatch('\u001B[A', ['Select'], {})).toBe(false); - }); - - it('reprocesses a pending-chord mismatch as a fresh key', () => { - const resolver = new KeybindingResolver( - parseKeybindingBlocks([ - { - context: 'Select', - bindings: { - 'ctrl+k ctrl+g': 'select:accept', - x: 'select:cancel', - }, - }, - ]), - ); - const onCancel = vi.fn(); - - expect(resolver.dispatchKeyId('ctrl+k', ['Select'], {})).toBe(true); - expect( - resolver.dispatchKeyId('x', ['Select'], { - 'select:cancel': onCancel, - }), - ).toBe(true); - expect(onCancel).toHaveBeenCalledOnce(); - - expect(resolver.dispatchKeyId('ctrl+k', ['Select'], {})).toBe(true); - expect(resolver.dispatchKeyId('q', ['Select'], {})).toBe(false); - }); - - it('uses effective last-wins bindings for display text and recognizes aware components', () => { - const bindings = parseKeybindingBlocks([ - { context: 'Select', bindings: { up: 'select:previous', k: 'select:previous' } }, - { context: 'Select', bindings: { up: null } }, - ]); - const aware = { setKeybindings: vi.fn() }; - - expect(keybindingDisplayText(bindings, 'Select', 'select:previous')).toBe('k'); - expect(isKeybindingAware(aware)).toBe(true); - expect(isKeybindingAware({ setKeybindings: true })).toBe(false); - }); - - it('watches keybindings.json and removes the watcher on cleanup', () => { - const onChange = vi.fn(); - const stop = watchKeybindings('/tmp/pythinker-home', onChange); - - expect(fsMocks.watchFile).toHaveBeenCalledWith( - '/tmp/pythinker-home/keybindings.json', - { persistent: false }, - onChange, - ); - - stop(); - - expect(fsMocks.unwatchFile).toHaveBeenCalledWith( - '/tmp/pythinker-home/keybindings.json', - onChange, - ); - }); - - it('silences successful watched reloads but reports warnings', () => { - let warnings: readonly string[] = []; - const showStatus = vi.fn(); - const host = { - stopKeybindingsWatcher: undefined, - harness: { homeDir: '/tmp/pythinker-home' }, - reloadKeybindings: vi.fn(() => warnings), - showStatus, - }; - const startKeybindingsWatcher = ( - PythinkerTUI.prototype as unknown as { - startKeybindingsWatcher(this: typeof host): void; - } - ).startKeybindingsWatcher; - - startKeybindingsWatcher.call(host); - const onChange = fsMocks.watchFile.mock.calls[0]?.[2] as () => void; - - onChange(); - expect(showStatus).not.toHaveBeenCalled(); - - warnings = ['Invalid keybinding override.']; - onChange(); - expect(showStatus).toHaveBeenCalledWith( - 'Keybindings reloaded with warnings: Invalid keybinding override.', - 'warning', - ); - }); - - it('loads user overrides, unbinds defaults, and resolves two-key chords', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Chat', - bindings: { - 'ctrl+t': null, - 'alt+t': 'chat:thinkingToggle', - 'ctrl+k ctrl+g': 'chat:externalEditor', - }, - }, - ], - }), - 'utf8', - ); - - const loaded = loadKeybindings(homeDir); - const resolver = new KeybindingResolver(loaded.bindings); - const calls: string[] = []; - - expect(loaded.warnings).toEqual([]); - expect(loaded.valid).toBe(true); - expect(resolver.dispatch('\u0014', ['Chat'], {})).toBe(true); - expect( - resolver.dispatch('\u001Bt', ['Chat'], { - 'chat:thinkingToggle': () => { - calls.push('thinking'); - }, - }), - ).toBe(true); - expect(resolver.dispatch('\u000B', ['Chat'], {})).toBe(true); - expect( - resolver.dispatch('\u0007', ['Chat'], { - 'chat:externalEditor': () => { - calls.push('editor'); - }, - }), - ).toBe(true); - expect(calls).toEqual(['thinking', 'editor']); - }); - - it('accepts slash-command keybindings', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Chat', - bindings: { - 'alt+h': 'command:help', - }, - }, - ], - }), - 'utf8', - ); - - const loaded = loadKeybindings(homeDir); - const resolver = new KeybindingResolver(loaded.bindings); - const commands: string[] = []; - - expect(loaded.warnings).toEqual([]); - expect( - resolver.dispatch('\u001Bh', ['Chat'], {}, { onCommand: (command) => commands.push(command) }), - ).toBe(true); - expect(commands).toEqual(['help']); - }); - - it('accepts MessageActions bindings and distinguishes missing, malformed, and invalid files', async () => { - const homeDir = await temporaryHome(); - expect(loadKeybindings(join(homeDir, 'missing')).valid).toBe(true); - - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [{ context: 'MessageActions', bindings: { enter: 'messageActions:enter' } }], - }), - 'utf8', - ); - expect(loadKeybindings(homeDir)).toMatchObject({ valid: true, warnings: [] }); - - await writeFile(join(homeDir, 'keybindings.json'), '{"bindings":', 'utf8'); - expect(loadKeybindings(homeDir)).toMatchObject({ valid: false }); - - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ bindings: [{ context: 'Chat', bindings: { 'alt+x': 'not:an-action' } }] }), - 'utf8', - ); - expect(loadKeybindings(homeDir)).toMatchObject({ valid: false }); - }); - - it('uses source-compatible redraw, history-search, and model-picker defaults', () => { - const loaded = loadKeybindings('/missing-keybindings-home'); - const resolver = new KeybindingResolver(loaded.bindings); - const calls: string[] = []; - - expect( - resolver.dispatch('\u000C', ['Chat'], { - 'app:redraw': () => { - calls.push('redraw'); - }, - }), - ).toBe(true); - expect( - resolver.dispatch('\u0012', ['Chat'], { - 'history:search': () => { - calls.push('history'); - }, - }), - ).toBe(true); - expect( - resolver.dispatch('\u001Bp', ['Chat'], { - 'chat:modelPicker': () => { - calls.push('model'); - }, - }), - ).toBe(true); - expect( - resolver.dispatch('\u001B[1;2A', ['Chat'], { - 'chat:messageActions': () => { - calls.push('messages'); - }, - }), - ).toBe(true); - expect(calls).toEqual(['redraw', 'history', 'model', 'messages']); - }); - - it('keeps Ctrl-C and Ctrl-D reserved while reporting invalid overrides', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Global', - bindings: { - 'ctrl+c': 'chat:thinkingToggle', - 'ctrl+d': null, - }, - }, - ], - }), - 'utf8', - ); - - const loaded = loadKeybindings(homeDir); - const resolver = new KeybindingResolver(loaded.bindings); - const calls: string[] = []; - - expect(loaded.warnings).toHaveLength(2); - expect( - resolver.dispatch('\u0003', ['Chat'], { - 'app:interrupt': () => { - calls.push('interrupt'); - }, - }), - ).toBe(true); - expect( - resolver.dispatch('\u0004', ['Chat'], { - 'app:exit': () => { - calls.push('exit'); - }, - }), - ).toBe(true); - expect(calls).toEqual(['interrupt', 'exit']); - }); - - it('warns about terminal-reserved shortcuts without rejecting them', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Chat', - bindings: { - 'ctrl+z': 'chat:thinkingToggle', - }, - }, - ], - }), - 'utf8', - ); - - const loaded = loadKeybindings(homeDir); - const resolver = new KeybindingResolver(loaded.bindings); - const calls: string[] = []; - - expect(loaded.warnings).toContain( - 'ctrl+z may be intercepted by the terminal: Unix process suspend (SIGTSTP).', - ); - expect( - resolver.dispatch('\u001A', ['Chat'], { - 'chat:thinkingToggle': () => { - calls.push('thinking'); - }, - }), - ).toBe(true); - expect(calls).toEqual(['thinking']); - }); - - it('warns when a bindings block contains duplicate JSON keys', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - `{ - "bindings": [{ - "context": "Chat", - "bindings": { - "ctrl+t": "chat:thinkingToggle", - "ctrl+t": null - } - }] - }`, - 'utf8', - ); - - expect(loadKeybindings(homeDir).warnings).toContain( - 'Duplicate key "ctrl+t" in Chat bindings; the last value wins.', - ); - }); - - it('warns when normalized shortcuts conflict across blocks', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Chat', - bindings: { - 'control+t': 'chat:thinkingToggle', - }, - }, - { - context: 'Chat', - bindings: { - 'ctrl+t': null, - }, - }, - ], - }), - 'utf8', - ); - - expect(loadKeybindings(homeDir).warnings).toContain( - 'Duplicate binding "ctrl+t" in Chat bindings; the last value wins.', - ); - }); - - it('warns about inactive keybinding contexts instead of silently accepting them', async () => { - const homeDir = await temporaryHome(); - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Unknown', - bindings: { - 'alt+t': 'chat:thinkingToggle', - }, - }, - ], - }), - 'utf8', - ); - - const loaded = loadKeybindings(homeDir); - - expect(loaded.warnings).toContain( - 'Unknown keybinding context: Unknown. Supported contexts: Global, Chat, Autocomplete, Confirmation, Help, HistorySearch, Tabs, Footer, MessageSelector, MessageActions, ModelPicker, Select, Plugin.', - ); - expect(loaded.bindings).not.toContainEqual( - expect.objectContaining({ context: 'Unknown' }), - ); - }); - - it('falls back to defaults for malformed configuration and drives help labels from bindings', async () => { - const homeDir = await temporaryHome(); - await writeFile(join(homeDir, 'keybindings.json'), '{"bindings": "bad"}', 'utf8'); - - const loaded = loadKeybindings(homeDir); - const help = editorShortcutHelp(loaded.bindings); - - expect(loaded.warnings).toHaveLength(1); - expect(loaded.valid).toBe(false); - expect(help).toContainEqual({ - keys: 'ctrl+o', - description: 'Toggle tool output expansion', - }); - }); - - it('generates a valid template without non-rebindable shortcuts', async () => { - const template = generateKeybindingsTemplate(); - const homeDir = await temporaryHome(); - await writeFile(join(homeDir, 'keybindings.json'), template, 'utf8'); - - expect(JSON.parse(template)).toMatchObject({ - bindings: expect.any(Array), - }); - expect(template).not.toContain('"ctrl+c"'); - expect(template).not.toContain('"ctrl+d"'); - expect(loadKeybindings(homeDir).warnings).toEqual([]); - expect(loadKeybindings(homeDir).valid).toBe(true); - }); - - it('creates the keybindings file and reports how to configure an editor', async () => { - vi.stubEnv('VISUAL', ''); - vi.stubEnv('EDITOR', ''); - const homeDir = await temporaryHome(); - const reloadKeybindings = vi.fn(() => []); - const showNotice = vi.fn(); - const host = { - harness: { homeDir }, - state: { appState: { editorCommand: null } }, - reloadKeybindings, - showNotice, - showError: vi.fn(), - } as unknown as SlashCommandHost; - - await handleKeybindingsCommand(host, ''); - - expect(JSON.parse(await readFile(join(homeDir, 'keybindings.json'), 'utf8'))).toMatchObject({ - bindings: expect.any(Array), - }); - expect(reloadKeybindings).toHaveBeenCalledOnce(); - expect(showNotice).toHaveBeenCalledWith( - expect.stringContaining('Created'), - expect.stringContaining('No editor configured'), - ); - }); -}); - -async function temporaryHome(): Promise<string> { - const directory = await mkdtemp(join(tmpdir(), 'pythinker-keybindings-')); - temporaryDirectories.push(directory); - await mkdir(directory, { recursive: true }); - return directory; -} diff --git a/apps/pythinker-code/test/tui/media-url.test.ts b/apps/pythinker-code/test/tui/media-url.test.ts index 79c73b9f..9e1dfc32 100644 --- a/apps/pythinker-code/test/tui/media-url.test.ts +++ b/apps/pythinker-code/test/tui/media-url.test.ts @@ -9,6 +9,15 @@ describe('mediaUrlPartToText', () => { ); }); + it('renders an internal daemon file reference as a bare placeholder', () => { + // `pythinker-file://…?path=…` resolves nowhere for the user and carries the + // materialization path — never render the wire form. + expect( + mediaUrlPartToText('image', 'pythinker-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png'), + ).toBe('[image]'); + expect(mediaUrlPartToText('video', 'pythinker-file://f_2')).toBe('[video]'); + }); + it('summarizes base64 data URLs without returning the payload', () => { expect(mediaUrlPartToText('image', 'data:image/png;base64,qrs=')).toBe( '[image image/png, 2 B]', diff --git a/apps/pythinker-code/test/tui/message-replay.test.ts b/apps/pythinker-code/test/tui/message-replay.test.ts index 7a484a76..ac3d9e73 100644 --- a/apps/pythinker-code/test/tui/message-replay.test.ts +++ b/apps/pythinker-code/test/tui/message-replay.test.ts @@ -1,6 +1,5 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -import chalk from 'chalk'; import type { AgentReplayRecord, BackgroundTaskInfo, @@ -14,15 +13,20 @@ import type { } from '@pymodel/pythinker-code-sdk'; import { describe, expect, it, vi } from 'vitest'; -import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; -import { ReadGroupComponent } from '#/tui/components/messages/read-group'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; +import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; -import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; -import { darkColors } from '#/tui/theme/colors'; -import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; -import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; +import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { ReadGroupComponent } from '#/tui/components/messages/read-group'; +import { replayBackgroundProjection } from '#/tui/utils/message-replay'; +import type { TaskNotificationOrigin } from '#/tui/utils/message-replay'; vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); @@ -47,7 +51,6 @@ function makeStartupInput(): PythinkerTUIStartupInput { cliOptions: { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -55,15 +58,16 @@ function makeStartupInput(): PythinkerTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', - layout: 'inline', - copyFullResponse: false, + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', @@ -76,7 +80,7 @@ function message( extra: { readonly toolCalls?: readonly ToolCall[]; readonly toolCallId?: string; - readonly origin?: PromptOrigin; + readonly origin?: PromptOrigin | TaskNotificationOrigin; readonly isError?: boolean; } = {}, ): AgentReplayRecord { @@ -88,7 +92,7 @@ function message( content: [...content], toolCalls: [...(extra.toolCalls ?? [])], toolCallId: extra.toolCallId, - origin: extra.origin, + origin: extra.origin as PromptOrigin | undefined, isError: extra.isError, }, }; @@ -159,7 +163,7 @@ function baseAgentState( tool_use: true, max_context_tokens: 100, }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 0 }, @@ -186,7 +190,7 @@ function makeSession( summary: { title: null }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -216,7 +220,7 @@ function makeHarness(initialSession: Session) { return { getConfig: vi.fn(async () => ({ models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), setConfig: vi.fn(async () => ({ providers: {} })), @@ -239,7 +243,7 @@ function makeHarness(initialSession: Session) { login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), - submitFeedback: vi.fn(async () => ({ kind: 'ok' })), + submitFeedback: vi.fn(async () => ({ kind: 'ok', feedbackId: 3 })), }, }; } @@ -297,31 +301,6 @@ function backgroundTask( } describe('PythinkerTUI resume message replay', () => { - it('limits goal replay to the most recent continuation rounds', async () => { - const replay: AgentReplayRecord[] = []; - for (let index = 0; index < 25; index += 1) { - replay.push( - message( - 'user', - [{ type: 'text', text: 'Continue working toward the active goal.' }], - { origin: { kind: 'system_trigger', name: 'goal_continuation' } }, - ), - message('assistant', [{ type: 'text', text: `round ${String(index)} summary` }]), - ); - } - - const driver = await replayIntoDriver(replay); - const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); - - expect(transcript).not.toContain('Continue working toward the active goal'); - expect(transcript).not.toContain('round 14 summary'); - expect(transcript).toContain('round 15 summary'); - expect(transcript).toContain('round 24 summary'); - expect( - driver.state.transcriptEntries.filter((entry) => entry.kind === 'assistant'), - ).toHaveLength(REPLAY_TURN_LIMIT); - }); - it('does not render legacy goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -341,6 +320,75 @@ describe('PythinkerTUI resume message replay', () => { expect(transcript).not.toContain('Goal complete'); }); + it('renders an uploaded image daemon ref as a bare placeholder on replay', async () => { + // An uploaded image persists as a self-contained `pythinker-file://` part; on + // replay it renders as a bare `[image]` placeholder — neither the + // materialization path nor the internal url may surface. + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'what is this? ' }, + { + type: 'image_url', + imageUrl: { url: 'pythinker-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png' }, + }, + ], + { origin: { kind: 'user' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('what is this?'); + expect(transcript).toContain('[image]'); + expect(transcript).not.toContain('/Users/alice'); + expect(transcript).not.toContain('pythinker-file'); + }); + + it('keeps the tag of a legacy upload pair as user text on replay', async () => { + // Legacy history paired the daemon ref with an `<image path>` tag. The + // pairing is gone: the tag is plain user text and replays verbatim while + // the ref still renders as `[image]`. + const driver = await replayIntoDriver([ + message( + 'user', + [ + { type: 'text', text: 'what is this? ' }, + { type: 'text', text: '<image path="/Users/alice/media/f_1.png"></image>' }, + { + type: 'image_url', + imageUrl: { url: 'pythinker-file://f_1?path=%2FUsers%2Falice%2Fmedia%2Ff_1.png' }, + }, + ], + { origin: { kind: 'user' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('what is this?'); + expect(transcript).toContain('[image]'); + expect(transcript).toContain('<image path="/Users/alice/media/f_1.png"></image>'); + expect(transcript).not.toContain('pythinker-file'); + }); + + it('unescapes bash tag delimiters when replaying shell output', async () => { + const driver = await replayIntoDriver([ + message( + 'user', + [ + { + type: 'text', + text: '<bash-stdout>pre</bash-stdout>post</bash-stdout><bash-stderr></bash-stderr>', + }, + ], + { origin: { kind: 'shell_command', phase: 'output' } }, + ), + ]); + + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).toContain('pre</bash-stdout>post'); + }); + it('does not render neutral goal completion context reminders as transcript messages', async () => { const driver = await replayIntoDriver([ message( @@ -516,16 +564,26 @@ describe('PythinkerTUI resume message replay', () => { expect(content).not.toContain('Write a concise final message for the user'); }); - it('does not replay any system-trigger prompt as a user message', async () => { + it('does not replay system-trigger prompts such as goal continuation as user messages', async () => { const driver = await replayIntoDriver([ message( 'user', - [{ type: 'text', text: 'Continue working toward the active goal.' }], + [ + { + type: 'text', + text: 'Continue working toward the active goal. Keep the self-audit brief.', + }, + ], { origin: { kind: 'system_trigger', name: 'goal_continuation' } }, ), message( 'user', - [{ type: 'text', text: '<system-reminder>The goal was cancelled.</system-reminder>' }], + [ + { + type: 'text', + text: '<system-reminder>\nThe goal was cancelled.\n</system-reminder>', + }, + ], { origin: { kind: 'system_trigger', name: 'goal_cancelled' } }, ), message('assistant', [{ type: 'text', text: 'Working on it.' }]), @@ -667,34 +725,24 @@ describe('PythinkerTUI resume message replay', () => { }), ]; - const previousLevel = chalk.level; - chalk.level = 3; - - try { - const driver = await replayIntoDriver(replay); - const group = driver.state.transcriptContainer.children.find( - (child) => child instanceof ReadGroupComponent, - ); + const driver = await replayIntoDriver(replay); + const group = driver.state.transcriptContainer.children.find( + (child) => child instanceof ReadGroupComponent, + ); - expect(group).toBeInstanceOf(ReadGroupComponent); - expect((group as ReadGroupComponent).size()).toBe(2); - const rawTranscript = driver.state.transcriptContainer.render(120).join('\n'); - expect(rawTranscript).toContain(chalk.hex(darkColors.textStrong).bold('Read 2 files')); - expect(rawTranscript).not.toContain(chalk.hex(darkColors.primary).bold('Read 2 files')); - expect(driver.streamingUI.hasPendingReadGroup()).toBe(false); - expect(driver.streamingUI.getToolComponent('call_read_1')).toBeUndefined(); - expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); - } finally { - chalk.level = previousLevel; - } + expect(group).toBeInstanceOf(ReadGroupComponent); + expect((group as ReadGroupComponent).size()).toBe(2); + expect(driver.streamingUI.hasPendingReadGroup()).toBe(false); + expect(driver.streamingUI.getToolComponent('call_read_1')).toBeUndefined(); + expect(driver.streamingUI.getToolComponent('call_read_2')).toBeUndefined(); }); - it('renders replayed DynamicWorkflow calls as compact result summaries', async () => { + it('renders replayed AgentDynamicWorkflow calls as compact result summaries', async () => { const replay: AgentReplayRecord[] = [ - message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('user', [{ type: 'text', text: 'review files with a dynamic_workflow' }]), message('assistant', [], { toolCalls: [ - toolCall('call_swarm', 'DynamicWorkflow', { + toolCall('call_dynamic_workflow', 'AgentDynamicWorkflow', { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'], }), @@ -705,32 +753,32 @@ describe('PythinkerTUI resume message replay', () => { [{ type: 'text', text: [ - '<dynamic_workflow_result>', + '<agent_dynamic_workflow_result>', '<summary>completed: 1, failed: 1</summary>', '<subagent index="1" outcome="completed">Reviewed src/a.ts.</subagent>', '<subagent index="2" outcome="failed">Agent timed out.</subagent>', - '</dynamic_workflow_result>', + '</agent_dynamic_workflow_result>', ].join('\n'), }], - { toolCallId: 'call_swarm' }, + { toolCallId: 'call_dynamic_workflow' }, ), ]; const driver = await replayIntoDriver(replay); const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); - expect(transcript).toContain('Dynamic Workflow: ✓ 1 completed · ✗ 1 failed'); - expect(transcript).not.toContain('<dynamic_workflow_result>'); + expect(transcript).toContain('Agent dynamic_workflow: ✓ 1 completed · ✗ 1 failed'); + expect(transcript).not.toContain('<agent_dynamic_workflow_result>'); expect(transcript).not.toContain('Reviewed src/a.ts.'); expect(transcript).not.toContain('Agent timed out.'); }); - it('does not show no-index replayed DynamicWorkflow failures as completed', async () => { + it('does not show no-index replayed AgentDynamicWorkflow failures as completed', async () => { const replay: AgentReplayRecord[] = [ - message('user', [{ type: 'text', text: 'review files with a swarm' }]), + message('user', [{ type: 'text', text: 'review files with a dynamic_workflow' }]), message('assistant', [], { toolCalls: [ - toolCall('call_swarm', 'DynamicWorkflow', { + toolCall('call_dynamic_workflow', 'AgentDynamicWorkflow', { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'], }), @@ -741,52 +789,27 @@ describe('PythinkerTUI resume message replay', () => { [{ type: 'text', text: [ - '<dynamic_workflow_result>', + '<agent_dynamic_workflow_result>', '<summary>failed: 1, aborted: 1</summary>', - '<resume_hint>Call DynamicWorkflow with resume_agent_ids using the agent_id values ' + + '<resume_hint>Call AgentDynamicWorkflow with resume_agent_ids using the agent_id values ' + 'in this result to continue unfinished work.</resume_hint>', '<subagent agent_id="agent-1" item="src/a.ts" outcome="failed">' + 'Agent timed out.</subagent>', '<subagent agent_id="agent-2" item="src/b.ts" outcome="aborted">' + 'User interrupted.</subagent>', - '</dynamic_workflow_result>', + '</agent_dynamic_workflow_result>', ].join('\n'), }], - { toolCallId: 'call_swarm' }, + { toolCallId: 'call_dynamic_workflow' }, ), ]; const driver = await replayIntoDriver(replay); const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); - expect(transcript).toContain('Dynamic Workflow: ✗ 1 failed · ⊘ 1 aborted'); - expect(transcript).not.toContain('Dynamic Workflow: ✓ Completed.'); - expect(transcript).not.toContain('<dynamic_workflow_result>'); - }); - - it('keeps replayed AgentSwarm calls generic', async () => { - const replay: AgentReplayRecord[] = [ - message('user', [{ type: 'text', text: 'review files with a legacy tool' }]), - message('assistant', [], { - toolCalls: [ - toolCall('call_removed_swarm', 'AgentSwarm', { - description: 'Review changed files', - }), - ], - }), - message( - 'tool', - [{ type: 'text', text: 'legacy AgentSwarm output' }], - { toolCallId: 'call_removed_swarm' }, - ), - ]; - - const driver = await replayIntoDriver(replay); - const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); - - expect(transcript).toContain('Used AgentSwarm'); - expect(transcript).toContain('legacy AgentSwarm output'); - expect(transcript).not.toContain('Dynamic Workflow:'); + expect(transcript).toContain('Agent dynamic_workflow: ✗ 1 failed · ⊘ 1 aborted'); + expect(transcript).not.toContain('Agent dynamic_workflow: ✓ Completed.'); + expect(transcript).not.toContain('<agent_dynamic_workflow_result>'); }); it('hydrates todo and background snapshot state from resumed main agent', async () => { @@ -910,58 +933,6 @@ describe('PythinkerTUI resume message replay', () => { ).toBe(false); }); - it('rejects an ambiguous parentless terminal event after a resumed agent id is reused', async () => { - const driver = await replayIntoDriver([], { - background: [ - { - taskId: 'task-bg-old', - kind: 'agent', - agentId: 'agent-bg-reused', - subagentType: 'coder', - description: 'Old resumed work', - status: 'running', - startedAt: 1, - endedAt: null, - }, - ], - }); - - driver.sessionEventHandler.handleEvent( - { - type: 'subagent.spawned', - agentId: 'main', - sessionId: 'ses-replay', - subagentId: 'agent-bg-reused', - subagentName: 'coder', - parentToolCallId: 'call_fresh_background', - description: 'Fresh background work', - runInBackground: true, - }, - () => {}, - ); - driver.sessionEventHandler.handleEvent( - { - type: 'subagent.completed', - agentId: 'main', - sessionId: 'ses-replay', - subagentId: 'agent-bg-reused', - resultSummary: 'Ambiguous terminal result', - }, - () => {}, - ); - - expect( - driver.sessionEventHandler.subAgentEventHandler.backgroundAgentMetadata.get( - 'agent-bg-reused', - )?.parentToolCallId, - ).toBe('call_fresh_background'); - expect( - driver.state.transcriptEntries.some( - (entry) => entry.backgroundAgentStatus?.phase === 'completed', - ), - ).toBe(false); - }); - it('renders replayed bash background notifications as bash tasks', async () => { const driver = await replayIntoDriver( [ @@ -988,6 +959,46 @@ describe('PythinkerTUI resume message replay', () => { expect(status?.backgroundAgentStatus?.headline).not.toContain('agent'); }); + it('renders replayed v2 task notifications (task origin) as bash tasks', async () => { + const driver = await replayIntoDriver( + [ + message( + 'user', + [ + { + type: 'text', + text: '<notification id="task:bash-done0000:completed" category="task" type="task.completed" source_kind="background_task" source_id="bash-done0000">\nTitle: Background process completed\n</notification>', + }, + ], + { + origin: { + kind: 'task', + taskId: 'bash-done0000', + status: 'completed', + notificationId: 'task:bash-done0000:completed', + }, + }, + ), + ], + { + background: [backgroundTask('bash-done0000', 'Codex comment poller', 'completed')], + }, + ); + + const status = driver.state.transcriptEntries.find( + (entry) => entry.backgroundAgentStatus !== undefined, + ); + + expect(status?.backgroundAgentStatus?.headline).toBe('bash task completed in background'); + expect(status?.backgroundAgentStatus?.detail).toContain('Codex comment poller'); + // The raw notification XML must not leak into the visible transcript. + expect( + driver.state.transcriptEntries.some( + (entry) => entry.kind === 'user' && entry.content.includes('<notification'), + ), + ).toBe(false); + }); + it('renders only the most recent ten visible user turns', async () => { const replay = Array.from({ length: 12 }, (_, index) => [ message('user', [{ type: 'text', text: `prompt ${index}` }]), @@ -1102,46 +1113,17 @@ describe('PythinkerTUI resume message replay', () => { skillName: 'review', skillArgs: 'src/app.ts', trigger: 'user-slash', - checkpointId: 'checkpoint-skill', }, }, ); - const previousLevel = chalk.level; - chalk.level = 3; - - try { - const driver = await replayIntoDriver([activation, activation]); - const rawTranscript = driver.state.transcriptContainer.render(120).join('\n'); - const transcript = stripAnsi(rawTranscript); - - expect(transcript).toContain('review'); - expect(transcript).toContain('src/app.ts'); - expect(transcript).not.toContain('Review the requested file'); - expect(rawTranscript).toContain(chalk.hex(darkColors.textStrong).bold('▶ Activated skill: ')); - expect(rawTranscript).toContain(chalk.hex(darkColors.textStrong).bold('review')); - expect(rawTranscript).not.toContain(chalk.hex(darkColors.primary).bold('▶ Activated skill: ')); - expect(rawTranscript).not.toContain(chalk.hex(darkColors.roleUser).bold('review')); - expect(driver.sessionEventHandler.renderedSkillActivationIds.has('act-review')).toBe(true); - expect( - driver.state.transcriptEntries.find((entry) => entry.kind === 'skill_activation'), - ).toMatchObject({ checkpointId: 'checkpoint-skill' }); - } finally { - chalk.level = previousLevel; - } - }); - - it('keeps persisted checkpoint IDs on replayed user prompts', async () => { - const driver = await replayIntoDriver([ - message('user', [{ type: 'text', text: 'change files' }], { - origin: { kind: 'user', checkpointId: 'checkpoint-user' }, - }), - ]); + const driver = await replayIntoDriver([activation, activation]); + const transcript = driver.state.transcriptContainer.render(120).join('\n'); - expect(driver.state.transcriptEntries.find((entry) => entry.kind === 'user')).toMatchObject({ - content: 'change files', - checkpointId: 'checkpoint-user', - }); + expect(transcript).toContain('review'); + expect(transcript).toContain('src/app.ts'); + expect(transcript).not.toContain('Review the requested file'); + expect(driver.sessionEventHandler.renderedSkillActivationIds.has('act-review')).toBe(true); }); it('renders replayed hook results as assistant transcript entries', async () => { @@ -1183,15 +1165,43 @@ describe('PythinkerTUI resume message replay', () => { (entry) => entry.compactionData !== undefined, ); expect(compactionEntry?.compactionData).toEqual({ + summary: 'Compacted transcript summary.', tokensBefore: 120, tokensAfter: 24, instruction: 'preserve implementation notes', }); + const collapsed = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).toContain('120 → 24 tokens'); + expect(collapsed).toContain('preserve implementation notes'); + expect(collapsed).not.toContain('Compacted transcript summary.'); + + driver.state.editor.onToggleToolExpand?.(); + const expanded = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); + expect(expanded).toContain('Compacted transcript summary.'); + }); + + it('initializes replayed compaction blocks as expanded when tool output is already expanded', async () => { + const initial = makeSession([]); + const resumed = makeSession([ + { + time: REPLAY_TIME, + type: 'compaction', + result: { + summary: 'Compacted transcript summary.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + }, + ]); + const driver = await makeDriver(initial); + driver.state.toolOutputExpanded = true; + await driver.switchToSession(resumed, 'Resumed session (ses-replay).'); + const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); - expect(transcript).toContain('Compacted'); - expect(transcript).toContain('120 → 24 tokens'); - expect(transcript).toContain('preserve implementation notes'); - expect(transcript).not.toContain('Compacted transcript summary.'); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Compacted transcript summary.'); }); it('renders replayed cancelled compaction records as cancelled compaction blocks', async () => { @@ -1216,7 +1226,7 @@ describe('PythinkerTUI resume message replay', () => { const transcript = stripAnsi(driver.state.transcriptContainer.render(120).join('\n')); expect(transcript).toContain('Compaction cancelled'); expect(transcript).toContain('preserve implementation notes'); - expect(transcript).not.toContain('Compacted'); + expect(transcript).not.toContain('Compaction complete'); }); it('renders plan permission and approval replay notices', async () => { @@ -1311,16 +1321,143 @@ describe('PythinkerTUI resume message replay', () => { expect(transcript).not.toContain('Plan rejected by user.'); expect(transcript).not.toContain('Plan mode: OFF'); }); + + it('trims goal sessions to the most recent goal turns and hides continuation prompts', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + for (let i = 0; i < 25; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + + // Continuation prompts are model-facing and never render as user bubbles. + expect(transcript).not.toContain('Continue working toward the active goal.'); + // Only the most recent REPLAY_TURN_LIMIT goal turns are replayed. + expect(transcript).not.toContain('round 0 summary'); + expect(transcript).not.toContain('round 14 summary'); + expect(transcript).toContain('round 15 summary'); + expect(transcript).toContain('round 24 summary'); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ToolCallComponent, + ), + ).toHaveLength(10); + }); + + it('folds oversized goal rounds even though continuation boundaries are hidden', async () => { + const replay: AgentReplayRecord[] = [goalReplay(goalSnapshot(), { kind: 'created' })]; + // Ten continuation rounds — exactly at the replay turn limit, so nothing + // is trimmed and only folding can bound the oversized final round. + for (let i = 0; i < 9; i++) { + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + message('assistant', [{ type: 'text', text: `round ${i} summary` }], { + toolCalls: [toolCall(`call_${i}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `call_${i}` }), + ); + } + // Final round: 40 tool calls and 5 assistant texts in one continuation turn. + replay.push( + message('user', [{ type: 'text', text: 'Continue working toward the active goal.' }], { + origin: { kind: 'system_trigger', name: 'goal_continuation' }, + }), + ); + for (let t = 0; t < 40; t++) { + replay.push( + message('assistant', t < 5 ? [{ type: 'text', text: `final text ${t}` }] : [], { + toolCalls: [toolCall(`final_${t}`, 'Bash', { command: 'ls' })], + }), + message('tool', [{ type: 'text', text: 'ok' }], { toolCallId: `final_${t}` }), + ); + } + + const driver = await replayIntoDriver(replay); + const children = driver.state.transcriptContainer.children; + + // The oversized round folds to the per-turn caps even with no visible + // boundary component mounted for the continuation prompt. + const tools = children.filter((child) => child instanceof ToolCallComponent); + expect(tools).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_STEPS); + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(9 + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripAnsi(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${40 - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${5 - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); + + // The folded content is gone from view; the latest work stays. + const transcript = stripAnsi(driver.state.transcriptContainer.render(140).join('\n')); + expect(transcript).not.toContain('final text 0'); + expect(transcript).toContain('final text 4'); + }); }); -describe('message replay feature parity baseline', () => { - it('links live-versus-replay behavior to active parity scenarios', () => { - const linked = PARITY_CASES.filter( - ({ legacyTest }) => legacyTest === LEGACY_TEST_PATHS.replay, +describe('replayBackgroundProjection', () => { + function agentTask(overrides: Record<string, unknown> = {}): BackgroundTaskInfo { + return { + taskId: 'agent-task1', + kind: 'agent', + agentId: 'agent-1', + description: 'background job', + status: 'running', + startedAt: 1, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; + } + + it('threads the persisted model (catalog-mapped) and concrete effort into the metadata', () => { + const projection = replayBackgroundProjection( + [agentTask({ model: 'k2-cheap', thinkingEffort: 'low' })], + { + 'k2-cheap': { + provider: 'managed:pythinker-code', + model: 'kimi-k2-cheap', + displayName: 'Kimi K2 Cheap', + }, + } as never, ); - expect(linked.length).toBeGreaterThan(0); - expect( - linked.every(({ status, scenarioId }) => status === 'active' && scenarioId.length > 0), - ).toBe(true); + expect(projection.backgroundAgentMetadata.get('agent-1')).toMatchObject({ + model: 'Kimi K2 Cheap', + effort: 'low', + }); + }); + + it('falls back to the raw alias and drops boolean effort states', () => { + const projection = replayBackgroundProjection([ + agentTask({ model: 'k2-cheap', thinkingEffort: 'on' }), + agentTask({ + taskId: 'agent-task2', + agentId: 'agent-2', + model: 'k2-cheap', + thinkingEffort: 'off', + }), + ]); + expect(projection.backgroundAgentMetadata.get('agent-1')).toMatchObject({ + model: 'k2-cheap', + effort: undefined, + }); + expect(projection.backgroundAgentMetadata.get('agent-2')?.effort).toBeUndefined(); + }); + + it('omits model and effort for records that predate the fields', () => { + const projection = replayBackgroundProjection([agentTask()]); + const meta = projection.backgroundAgentMetadata.get('agent-1'); + expect(meta?.model).toBeUndefined(); + expect(meta?.effort).toBeUndefined(); }); }); diff --git a/apps/pythinker-code/test/tui/parity/feature-matrix.ts b/apps/pythinker-code/test/tui/parity/feature-matrix.ts deleted file mode 100644 index f299742c..00000000 --- a/apps/pythinker-code/test/tui/parity/feature-matrix.ts +++ /dev/null @@ -1,347 +0,0 @@ -export const LEGACY_TEST_PATHS = { - startup: 'apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts', - messageFlow: 'apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts', - replay: 'apps/pythinker-code/test/tui/message-replay.test.ts', - signals: 'apps/pythinker-code/test/tui/signal-handlers.test.ts', -} as const; - -export type LegacyTestPath = (typeof LEGACY_TEST_PATHS)[keyof typeof LEGACY_TEST_PATHS]; -export type TerminalSize = '80x24' | '120x40' | '200x60'; -export type SupportedPlatform = 'darwin' | 'linux' | 'win32'; -export type EvidenceChannel = 'unit' | 'headless-renderer' | 'pty' | 'npm' | 'native'; -export type EvidenceStatus = 'verified' | 'required' | 'not-applicable'; - -export type VerificationMode = - | { readonly kind: 'automated' } - | { readonly kind: 'manual-only'; readonly justification: string }; - -export interface ParityCase { - readonly id: string; - readonly area: string; - readonly legacyTest: LegacyTestPath; - readonly scenarioId: string; - readonly terminalSizes: readonly TerminalSize[]; - readonly platforms: readonly SupportedPlatform[]; - readonly requiredEvidence: readonly EvidenceChannel[]; - readonly evidenceStatus: Readonly<Record<EvidenceChannel, EvidenceStatus>>; - readonly verification: VerificationMode; - readonly status: 'active' | 'skip' | 'todo'; - readonly commands?: readonly string[]; - readonly sessionEvents?: readonly string[]; - readonly transcriptEntries?: readonly string[]; - readonly dialogRoutes?: readonly string[]; -} - -const ALL_SIZES = ['80x24', '120x40', '200x60'] as const satisfies readonly TerminalSize[]; -const ALL_PLATFORMS = ['darwin', 'linux', 'win32'] as const satisfies readonly SupportedPlatform[]; - -const AUTOMATED_STATUS = { - unit: 'verified', - 'headless-renderer': 'required', - pty: 'required', - npm: 'not-applicable', - native: 'not-applicable', -} as const satisfies Readonly<Record<EvidenceChannel, EvidenceStatus>>; - -function automatedCase( - row: Omit< - ParityCase, - 'terminalSizes' | 'platforms' | 'evidenceStatus' | 'verification' | 'status' - > & - Partial<Pick<ParityCase, 'terminalSizes' | 'platforms' | 'evidenceStatus'>>, -): ParityCase { - return { - terminalSizes: ALL_SIZES, - platforms: ALL_PLATFORMS, - evidenceStatus: AUTOMATED_STATUS, - ...row, - verification: { kind: 'automated' }, - status: 'active', - }; -} - -const SESSION_EVENTS = [ - 'turn.started', - 'turn.ended', - 'turn.step.started', - 'turn.step.interrupted', - 'turn.step.completed', - 'turn.step.retrying', - 'tool.progress', - 'assistant.delta', - 'hook.result', - 'hook.status', - 'thinking.delta', - 'tool.call.started', - 'tool.call.delta', - 'tool.result', - 'agent.status.updated', - 'session.meta.updated', - 'goal.updated', - 'skill.activated', - 'error', - 'warning', - 'workflow.warning', - 'compaction.started', - 'compaction.completed', - 'compaction.blocked', - 'compaction.cancelled', - 'subagent.spawned', - 'subagent.started', - 'subagent.suspended', - 'subagent.completed', - 'subagent.failed', - 'background.task.started', - 'background.task.terminated', - 'cron.fired', - 'mcp.server.status', - 'tool.list.updated', - 'advisor.status', -] as const; - -const TRANSCRIPT_ENTRY_KINDS = [ - 'welcome', - 'user', - 'assistant', - 'tool_call', - 'thinking', - 'status', - 'skill_activation', - 'cron', - 'goal', -] as const; - -const DIALOG_VIEW_ROUTES = [ - 'ApiKeyInputDialogComponent', - 'ApprovalPanelComponent', - 'ApprovalPreviewViewer', - 'ChoicePickerComponent', - 'CompactionComponent', - 'CustomRegistryImportDialogComponent', - 'EditorSelectorComponent', - 'EffortSelectorComponent', - 'ExperimentsSelectorComponent', - 'FeedbackInputDialogComponent', - 'GoalQueueEditDialogComponent', - 'GoalQueueManagerComponent', - 'GoalStartPermissionPromptComponent', - 'HelpPanelComponent', - 'ModelSelectorComponent', - 'PermissionSelectorComponent', - 'PlatformSelectorComponent', - 'PluginMarketplaceSelectorComponent', - 'PluginMcpSelectorComponent', - 'PluginRemoveConfirmComponent', - 'PluginsOverviewSelectorComponent', - 'ProviderManagerComponent', - 'QuestionDialogComponent', - 'SessionPickerComponent', - 'SettingsSelectorComponent', - 'StartPermissionPromptComponent', - 'DynamicWorkflowStartPermissionPromptComponent', - 'TabbedModelSelectorComponent', - 'TaskOutputViewer', - 'TasksBrowserApp', - 'ThemeSelectorComponent', - 'UndoSelectorComponent', - 'UpdatePreferenceSelectorComponent', -] as const; - -const COMMANDS = [ - 'add-dir', - 'advisor', - 'agents', - 'yolo', - 'auto', - 'permission', - 'permissions', - 'settings', - 'plan', - 'workflow', - 'model', - 'effort', - 'fast', - 'provider', - 'btw', - 'colors', - 'commit', - 'commit-push-pr', - 'context', - 'copy', - 'cost', - 'debug', - 'diff', - 'doctor', - 'files', - 'heapdump', - 'help', - 'hooks', - 'keybindings', - 'memory', - 'new', - 'sessions', - 'tasks', - 'mcp', - 'plugins', - 'pr-comments', - 'privacy-settings', - 'experiments', - 'reload', - 'release-notes', - 'reload-plugins', - 'reload-tui', - 'review', - 'security-review', - 'compact', - 'goal', - 'init', - 'init-verifiers', - 'fork', - 'title', - 'usage', - 'status', - 'feedback', - 'output-style', - 'skills', - 'tag', - 'terminal-setup', - 'undo', - 'update', - 'editor', - 'theme', - 'vim', - 'logout', - 'login', - 'export-md', - 'export-debug-zip', - 'web', - 'exit', - 'version', -] as const; - -const commandCases: readonly ParityCase[] = COMMANDS.map((command) => - automatedCase({ - id: `command-${command}`, - area: `slash command /${command}`, - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: `legacy.command.${command.replaceAll('-', '_')}`, - requiredEvidence: ['unit', 'headless-renderer'], - commands: [command], - }), -); - -export const PARITY_CASES: readonly ParityCase[] = [ - automatedCase({ - id: 'lifecycle-auth-error-recovery', - area: 'lifecycle, authentication, and startup error recovery', - legacyTest: LEGACY_TEST_PATHS.startup, - scenarioId: 'legacy.lifecycle.startup_auth_recovery', - requiredEvidence: ['unit', 'headless-renderer', 'pty'], - }), - automatedCase({ - id: 'session-create-resume-fork-replay', - area: 'session creation, resume, fork, replay, and cwd scoping', - legacyTest: LEGACY_TEST_PATHS.replay, - scenarioId: 'legacy.session.live_resume_fork_replay', - requiredEvidence: ['unit', 'headless-renderer'], - }), - automatedCase({ - id: 'input-history-autocomplete-media-keybindings', - area: 'input, history, autocomplete, attachments, clipboard, and keybindings', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.input.edit_history_complete_media_keys', - requiredEvidence: ['unit', 'headless-renderer', 'pty'], - }), - automatedCase({ - id: 'transcript-streaming-tools-grouping', - area: 'transcript ordering, streaming completion, tools, and grouping', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.transcript.stream_complete_tools_grouping', - requiredEvidence: ['unit', 'headless-renderer'], - sessionEvents: SESSION_EVENTS.slice(0, 23), - transcriptEntries: TRANSCRIPT_ENTRY_KINDS, - }), - automatedCase({ - id: 'transcript-message-actions', - area: 'keyboard transcript selection, copying, tool-input extraction, and prompt editing', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.transcript.message_actions', - requiredEvidence: ['unit', 'headless-renderer'], - }), - automatedCase({ - id: 'transcript-partial-compaction', - area: 'selected-range conversation compaction and prompt restoration', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.transcript.partial_compaction', - requiredEvidence: ['unit', 'headless-renderer'], - }), - automatedCase({ - id: 'approvals-questions', - area: 'approval choices, previews, structured questions, and focus restoration', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.reverse_rpc.approvals_questions', - requiredEvidence: ['unit', 'headless-renderer', 'pty'], - }), - automatedCase({ - id: 'dialogs-settings-routes', - area: 'dialogs, selectors, full-screen views, providers, plugins, and settings', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.views.dialog_settings_routes', - requiredEvidence: ['unit', 'headless-renderer'], - dialogRoutes: DIALOG_VIEW_ROUTES, - }), - automatedCase({ - id: 'goals-tasks-queue', - area: 'goals, background tasks, upcoming goals, todo state, and input queue', - legacyTest: LEGACY_TEST_PATHS.replay, - scenarioId: 'legacy.work.goals_tasks_todo_queue', - requiredEvidence: ['unit', 'headless-renderer'], - }), - automatedCase({ - id: 'agents-dynamic-workflow-cron-mcp-hooks', - area: 'agents, Dynamic Workflow, cron, MCP, skills, and hooks', - legacyTest: LEGACY_TEST_PATHS.messageFlow, - scenarioId: 'legacy.integrations.agents_dynamic_workflow_cron_mcp_hooks', - requiredEvidence: ['unit', 'headless-renderer'], - sessionEvents: SESSION_EVENTS.slice(23), - }), - automatedCase({ - id: 'themes-media-cjk-terminal', - area: 'themes, images, diff/code media, CJK width, resize, and terminal capabilities', - legacyTest: LEGACY_TEST_PATHS.startup, - scenarioId: 'legacy.terminal.theme_media_cjk_resize', - requiredEvidence: ['unit', 'headless-renderer', 'pty'], - }), - automatedCase({ - id: 'shutdown-signals-terminal-restoration', - area: 'shutdown, signals, stream errors, cleanup, and terminal restoration', - legacyTest: LEGACY_TEST_PATHS.signals, - scenarioId: 'legacy.shutdown.signals_restore_terminal', - requiredEvidence: ['unit', 'pty'], - }), - ...commandCases, - { - id: 'distribution-development-npm-native-nix', - area: 'development launch, npm package, native binary, and Nix distribution paths', - legacyTest: LEGACY_TEST_PATHS.signals, - scenarioId: 'legacy.distribution.dev_npm_native_nix', - terminalSizes: ALL_SIZES, - platforms: ALL_PLATFORMS, - requiredEvidence: ['npm', 'native', 'pty'], - evidenceStatus: { - unit: 'not-applicable', - 'headless-renderer': 'not-applicable', - pty: 'required', - npm: 'required', - native: 'required', - }, - verification: { - kind: 'manual-only', - justification: - 'Published npm tarballs, native artifacts, and Nix builds are produced outside the unit-test sandbox and must be exercised as release artifacts.', - }, - status: 'active', - }, -] as const; - -export const PARITY_CASE_IDS = PARITY_CASES.map(({ id }) => id); diff --git a/apps/pythinker-code/test/tui/parity/feature-parity.test.ts b/apps/pythinker-code/test/tui/parity/feature-parity.test.ts deleted file mode 100644 index bea6b45c..00000000 --- a/apps/pythinker-code/test/tui/parity/feature-parity.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; - -import { describe, expect, it } from 'vitest'; - -import { BUILTIN_SLASH_COMMANDS } from '#/tui/commands/registry'; - -import { LEGACY_FEATURE_FIXTURES } from './legacy-feature-fixtures'; -import { - LEGACY_TEST_PATHS, - PARITY_CASES, - type EvidenceChannel, -} from './feature-matrix'; - -const APP_ROOT = new URL('../../../', import.meta.url); -const REPOSITORY_ROOT = new URL('../../', APP_ROOT); -const EVENT_HANDLER_URL = new URL('src/tui/controllers/session-event-handler.ts', APP_ROOT); -const TYPES_URL = new URL('src/tui/types.ts', APP_ROOT); -const DIALOGS_URL = new URL('src/tui/components/dialogs/', APP_ROOT); - -function uniqueSorted(values: readonly string[]): string[] { - return [...new Set(values)].toSorted(); -} - -function coverageValues(key: 'commands' | 'sessionEvents' | 'transcriptEntries' | 'dialogRoutes') { - return uniqueSorted(PARITY_CASES.flatMap((row) => row[key] ?? [])); -} - -function handledSessionEventKinds(): string[] { - const source = readFileSync(EVENT_HANDLER_URL, 'utf8'); - const switchStart = source.indexOf('switch (event.type)'); - const switchEnd = source.indexOf('disposeMcpServerStatusRows', switchStart); - expect(switchStart).toBeGreaterThanOrEqual(0); - expect(switchEnd).toBeGreaterThan(switchStart); - return uniqueSorted( - [...source.slice(switchStart, switchEnd).matchAll(/case '([^']+)'/g)].map( - (match) => match[1] ?? '', - ), - ); -} - -function transcriptEntryKinds(): string[] { - const source = readFileSync(TYPES_URL, 'utf8'); - const typeStart = source.indexOf('export type TranscriptEntryKind ='); - const typeEnd = source.indexOf(';', typeStart); - expect(typeStart).toBeGreaterThanOrEqual(0); - expect(typeEnd).toBeGreaterThan(typeStart); - return uniqueSorted( - [...source.slice(typeStart, typeEnd).matchAll(/'([^']+)'/g)].map( - (match) => match[1] ?? '', - ), - ); -} - -function dialogViewRoutes(): string[] { - return uniqueSorted( - readdirSync(DIALOGS_URL) - .filter((name) => name.endsWith('.ts')) - .flatMap((name) => { - const source = readFileSync(new URL(name, DIALOGS_URL), 'utf8'); - return [...source.matchAll(/export class\s+([A-Za-z0-9_]+)/g)].map( - (match) => match[1] ?? '', - ); - }), - ); -} - -describe('legacy pi-tui feature parity inventory', () => { - it('covers every registered built-in slash command', () => { - expect(uniqueSorted(coverageValues('commands'))).toEqual( - uniqueSorted(BUILTIN_SLASH_COMMANDS.map(({ name }) => name)), - ); - }); - - it('covers every session event dispatched by SessionEventHandler', () => { - expect(uniqueSorted(coverageValues('sessionEvents'))).toEqual( - uniqueSorted(handledSessionEventKinds()), - ); - }); - - it('covers every TranscriptEntryKind', () => { - expect(uniqueSorted(coverageValues('transcriptEntries'))).toEqual( - uniqueSorted(transcriptEntryKinds()), - ); - }); - - it('covers every exported dialog and view implementation', () => { - expect(uniqueSorted(coverageValues('dialogRoutes'))).toEqual(uniqueSorted(dialogViewRoutes())); - }); - - it('keeps every case active, deterministic, linked, and fully classified', () => { - const allowedLegacyTests = new Set<string>(Object.values(LEGACY_TEST_PATHS)); - const evidenceChannels = new Set<EvidenceChannel>([ - 'unit', - 'headless-renderer', - 'pty', - 'npm', - 'native', - ]); - - expect(PARITY_CASES.length).toBeGreaterThan(0); - expect(uniqueSorted(PARITY_CASES.map(({ id }) => id))).toHaveLength(PARITY_CASES.length); - expect(uniqueSorted(PARITY_CASES.map(({ scenarioId }) => scenarioId))).toHaveLength( - PARITY_CASES.length, - ); - - for (const parityCase of PARITY_CASES) { - expect(parityCase.status, parityCase.id).toBe('active'); - expect(parityCase.scenarioId.trim(), parityCase.id).not.toBe(''); - expect(parityCase.terminalSizes.length, parityCase.id).toBeGreaterThan(0); - expect(parityCase.platforms.length, parityCase.id).toBeGreaterThan(0); - expect(parityCase.requiredEvidence.length, parityCase.id).toBeGreaterThan(0); - expect(allowedLegacyTests.has(parityCase.legacyTest), parityCase.id).toBe(true); - expect(existsSync(new URL(parityCase.legacyTest, REPOSITORY_ROOT)), parityCase.id).toBe(true); - for (const channel of parityCase.requiredEvidence) { - expect(evidenceChannels.has(channel), `${parityCase.id}:${channel}`).toBe(true); - expect(parityCase.evidenceStatus[channel], `${parityCase.id}:${channel}`).not.toBe( - 'not-applicable', - ); - } - // Only manual-only cases carry a justification; the placeholder keeps the - // assertion unconditional so the lint rule against conditional expects holds. - const justification = - parityCase.verification.kind === 'manual-only' - ? parityCase.verification.justification.trim() - : 'automated'; - expect(justification, parityCase.id).not.toBe(''); - } - }); - - it('records semantic fixtures for each baseline terminal size', () => { - expect(Object.keys(LEGACY_FEATURE_FIXTURES).toSorted()).toEqual([ - '120x40', - '200x60', - '80x24', - ]); - for (const fixture of Object.values(LEGACY_FEATURE_FIXTURES)) { - expect(fixture.content.length, fixture.terminalSize).toBeGreaterThan(0); - expect(fixture.ordering.length, fixture.terminalSize).toBeGreaterThan(0); - expect(fixture.activeView, fixture.terminalSize).toBe('conversation'); - expect(fixture.focus, fixture.terminalSize).toBe('editor'); - expect(fixture.keyActions.length, fixture.terminalSize).toBeGreaterThan(0); - } - }); -}); diff --git a/apps/pythinker-code/test/tui/parity/legacy-feature-fixtures.ts b/apps/pythinker-code/test/tui/parity/legacy-feature-fixtures.ts deleted file mode 100644 index 0afcb4a6..00000000 --- a/apps/pythinker-code/test/tui/parity/legacy-feature-fixtures.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { TerminalSize } from './feature-matrix'; - -export interface SemanticFeatureFixture { - readonly terminalSize: TerminalSize; - readonly viewport: { readonly columns: number; readonly rows: number }; - readonly activeView: 'conversation'; - readonly focus: 'editor'; - readonly content: readonly string[]; - readonly ordering: readonly string[]; - readonly keyActions: readonly { - readonly keys: string; - readonly effect: string; - }[]; -} - -export const LEGACY_FEATURE_FIXTURES: Readonly<Record<TerminalSize, SemanticFeatureFixture>> = { - '80x24': { - terminalSize: '80x24', - viewport: { columns: 80, rows: 24 }, - activeView: 'conversation', - focus: 'editor', - content: ['welcome', 'user prompt', 'assistant response', 'completed tool summary', 'footer'], - ordering: ['welcome', 'user', 'thinking', 'tool_call', 'assistant', 'status', 'editor', 'footer'], - keyActions: [ - { keys: 'Enter', effect: 'submit editor text or accept the focused dialog choice' }, - { keys: 'Up', effect: 'recall input history when autocomplete is closed' }, - { keys: 'Ctrl+C', effect: 'clear input first, then request exit' }, - ], - }, - '120x40': { - terminalSize: '120x40', - viewport: { columns: 120, rows: 40 }, - activeView: 'conversation', - focus: 'editor', - content: ['welcome and banner', 'user prompt', 'streamed assistant text', 'activity', 'footer'], - ordering: ['welcome', 'banner', 'user', 'thinking', 'tool_call', 'assistant', 'editor', 'footer'], - keyActions: [ - { keys: 'Tab', effect: 'advance autocomplete or structured-question focus' }, - { keys: 'Ctrl+O', effect: 'toggle thinking detail visibility' }, - { keys: 'Ctrl+G', effect: 'open the configured external editor' }, - ], - }, - '200x60': { - terminalSize: '200x60', - viewport: { columns: 200, rows: 60 }, - activeView: 'conversation', - focus: 'editor', - content: ['welcome and banner', 'grouped tools', 'assistant response', 'queue pane', 'footer'], - ordering: ['welcome', 'banner', 'user', 'grouped_tools', 'assistant', 'queue', 'editor', 'footer'], - keyActions: [ - { keys: 'Shift+Tab', effect: 'move backward through structured-question focus' }, - { keys: 'Escape', effect: 'close the active dialog and restore editor focus' }, - { keys: 'Ctrl+D', effect: 'request exit when the editor is empty' }, - ], - }, -}; diff --git a/apps/pythinker-code/test/tui/presentation/dialog-list-model.test.ts b/apps/pythinker-code/test/tui/presentation/dialog-list-model.test.ts deleted file mode 100644 index e4d319d8..00000000 --- a/apps/pythinker-code/test/tui/presentation/dialog-list-model.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - DialogListModel, - type DialogRow, -} from '../../../src/tui/presentation/dialog-list-model'; - -const navigationRows: DialogRow[] = [ - { id: 'start', label: 'Start', disabled: true }, - { id: 'one', label: 'One' }, - { id: 'disabled-a', label: 'Disabled A', disabled: true }, - { id: 'disabled-b', label: 'Disabled B', disabled: true }, - { id: 'two', label: 'Two' }, - { id: 'end', label: 'End', disabled: true }, -]; - -function selectedId(model: DialogListModel): string | undefined { - const view = model.toViewModel(); - return view.rows[view.selectedIndex]?.id; -} - -describe('DialogListModel', () => { - it('filters by case-insensitive ordered subsequences and preserves input order', () => { - const rows: DialogRow[] = [ - { id: 'sol', label: 'GPT-5.6 Sol' }, - { id: 'other', label: 'Other model' }, - { id: 'mini', label: 'GPT Mini' }, - ]; - const model = new DialogListModel({ title: 'Models', rows }); - - for (const char of 'gpt') { - model.handleKey({ kind: 'char', char }); - } - expect(model.toViewModel().rows).toEqual([rows[0], rows[2]]); - - model.handleKey({ kind: 'escape' }); - for (const char of 'gtp') { - model.handleKey({ kind: 'char', char }); - } - expect(model.toViewModel().rows).toEqual([]); - }); - - it('uses AND semantics for whitespace and slash-separated query tokens', () => { - const rows: DialogRow[] = [ - { id: 'claude-model', label: 'Model', description: 'Claude Sonnet' }, - { id: 'claude-chat', label: 'Chat', description: 'Claude Sonnet' }, - { id: 'openai-model', label: 'Model', description: 'OpenAI' }, - ]; - const model = new DialogListModel({ title: 'Models', rows }); - - for (const char of 'model/claude') { - model.handleKey({ kind: 'char', char }); - } - expect(model.toViewModel().rows).toEqual([rows[0]]); - }); - - it('keeps selection in the filtered rows and on an enabled row', () => { - const rows: DialogRow[] = [ - { id: 'alpha-disabled', label: 'Alpha', disabled: true }, - { id: 'beta', label: 'Beta' }, - { id: 'alpha-enabled', label: 'Alpha Two' }, - ]; - const model = new DialogListModel({ title: 'Rows', rows }); - - for (const char of 'alp') { - model.handleKey({ kind: 'char', char }); - } - expect(selectedId(model)).toBe('alpha-enabled'); - expect(model.toViewModel().selectedIndex).toBe(1); - }); - - it('constructs with the first enabled row selected, or zero when none exists', () => { - expect(selectedId(new DialogListModel({ title: 'Rows', rows: navigationRows }))).toBe('one'); - - const disabledRows: DialogRow[] = [ - { id: 'a', label: 'A', disabled: true }, - { id: 'b', label: 'B', disabled: true }, - ]; - expect( - new DialogListModel({ title: 'Rows', rows: disabledRows }).toViewModel().selectedIndex, - ).toBe(0); - expect(new DialogListModel({ title: 'Rows', rows: [] }).toViewModel().selectedIndex).toBe(0); - }); - - it('moves up and down to enabled rows without wrapping', () => { - const model = new DialogListModel({ title: 'Rows', rows: navigationRows }); - - expect(model.handleKey({ kind: 'up' })).toEqual({ type: 'consumed' }); - expect(selectedId(model)).toBe('one'); - model.handleKey({ kind: 'down' }); - expect(selectedId(model)).toBe('two'); - model.handleKey({ kind: 'down' }); - expect(selectedId(model)).toBe('two'); - model.handleKey({ kind: 'up' }); - expect(selectedId(model)).toBe('one'); - }); - - it('moves home and end to the first and last enabled rows', () => { - const model = new DialogListModel({ title: 'Rows', rows: navigationRows }); - - expect(model.handleKey({ kind: 'end' })).toEqual({ type: 'consumed' }); - expect(selectedId(model)).toBe('two'); - expect(model.handleKey({ kind: 'home' })).toEqual({ type: 'consumed' }); - expect(selectedId(model)).toBe('one'); - - const disabledRows: DialogRow[] = [ - { id: 'a', label: 'A', disabled: true }, - { id: 'b', label: 'B', disabled: true }, - ]; - const disabledModel = new DialogListModel({ title: 'Rows', rows: disabledRows }); - disabledModel.handleKey({ kind: 'end' }); - expect(disabledModel.toViewModel().selectedIndex).toBe(1); - disabledModel.handleKey({ kind: 'home' }); - expect(disabledModel.toViewModel().selectedIndex).toBe(0); - }); - - it('pages by clamping and scanning in the primary then fallback direction', () => { - const rows: DialogRow[] = [ - { id: 'zero', label: 'Zero' }, - { id: 'one', label: 'One', disabled: true }, - { id: 'two', label: 'Two', disabled: true }, - { id: 'three', label: 'Three' }, - { id: 'four', label: 'Four', disabled: true }, - { id: 'five', label: 'Five', disabled: true }, - ]; - const model = new DialogListModel({ title: 'Rows', rows, pageSize: 2 }); - - model.handleKey({ kind: 'page-down' }); - expect(selectedId(model)).toBe('three'); - model.handleKey({ kind: 'page-down' }); - expect(selectedId(model)).toBe('three'); - model.handleKey({ kind: 'page-up' }); - expect(selectedId(model)).toBe('zero'); - - const disabledModel = new DialogListModel({ - title: 'Rows', - rows: rows.map((row) => ({ ...row, disabled: true })), - pageSize: 2, - }); - disabledModel.handleKey({ kind: 'page-down' }); - expect(disabledModel.toViewModel().selectedIndex).toBe(0); - }); - - it('appends characters, recomputes filtering, and resets selection', () => { - const rows: DialogRow[] = [ - { id: 'disabled', label: 'Gamma', disabled: true }, - { id: 'gamma', label: 'Gamma Enabled' }, - { id: 'alpha', label: 'Alpha' }, - ]; - const model = new DialogListModel({ title: 'Rows', rows }); - model.handleKey({ kind: 'end' }); - - expect(model.handleKey({ kind: 'char', char: 'g' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel().query).toBe('g'); - expect(selectedId(model)).toBe('gamma'); - }); - - it('backspaces one character, resets selection, and consumes an empty-query no-op', () => { - const model = new DialogListModel({ title: 'Rows', rows: navigationRows }); - - expect(model.handleKey({ kind: 'backspace' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel().query).toBeUndefined(); - model.handleKey({ kind: 'char', char: 't' }); - model.handleKey({ kind: 'char', char: 'w' }); - model.handleKey({ kind: 'backspace' }); - expect(model.toViewModel().query).toBe('t'); - expect(selectedId(model)).toBe('two'); - }); - - it('clears an active query on first escape and cancels without mutation on second escape', () => { - const model = new DialogListModel({ title: 'Rows', rows: navigationRows }); - model.handleKey({ kind: 'char', char: 't' }); - - expect(model.handleKey({ kind: 'escape' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel().query).toBeUndefined(); - expect(selectedId(model)).toBe('one'); - const beforeCancel = model.toViewModel(); - expect(model.handleKey({ kind: 'escape' })).toEqual({ type: 'cancel' }); - expect(model.toViewModel()).toEqual(beforeCancel); - }); - - it('selects an enabled row and consumes enter for empty or disabled selections', () => { - const enabledModel = new DialogListModel({ title: 'Rows', rows: navigationRows }); - expect(enabledModel.handleKey({ kind: 'enter' })).toEqual({ - type: 'select', - row: navigationRows[1], - }); - - const emptyModel = new DialogListModel({ title: 'Rows', rows: [] }); - expect(emptyModel.handleKey({ kind: 'enter' })).toEqual({ type: 'consumed' }); - - const disabledModel = new DialogListModel({ - title: 'Rows', - rows: [{ id: 'disabled', label: 'Disabled', disabled: true }], - }); - expect(disabledModel.handleKey({ kind: 'enter' })).toEqual({ type: 'consumed' }); - }); - - it('returns the current page with a relative selection and view metadata', () => { - const rows: DialogRow[] = Array.from({ length: 10 }, (_, index) => ({ - id: String(index), - label: `Row ${index}`, - })); - const model = new DialogListModel({ title: 'Paged rows', rows, pageSize: 3 }); - - for (let index = 0; index < 7; index += 1) { - model.handleKey({ kind: 'down' }); - } - expect(model.toViewModel()).toEqual({ - title: 'Paged rows', - rows: rows.slice(6, 9), - selectedIndex: 1, - query: undefined, - hint: undefined, - }); - - const emptyModel = new DialogListModel({ - title: 'Empty', - rows, - emptyHint: 'Nothing found', - }); - emptyModel.handleKey({ kind: 'char', char: 'z' }); - expect(emptyModel.toViewModel()).toEqual({ - title: 'Empty', - rows: [], - selectedIndex: 0, - query: 'z', - hint: 'Nothing found', - }); - }); - - it('preserves row object references in views and selection results', () => { - const row: DialogRow & { provider: string } = { - id: 'model', - label: 'Model', - provider: 'example', - }; - const model = new DialogListModel({ title: 'Rows', rows: [row] }); - - expect(model.toViewModel().rows[0]).toBe(row); - const result = model.handleKey({ kind: 'enter' }); - expect(result).toEqual({ type: 'select', row }); - expect((result as { type: 'select'; row: typeof row }).row.provider).toBe('example'); - }); -}); diff --git a/apps/pythinker-code/test/tui/presentation/task-output-model.test.ts b/apps/pythinker-code/test/tui/presentation/task-output-model.test.ts deleted file mode 100644 index ed370551..00000000 --- a/apps/pythinker-code/test/tui/presentation/task-output-model.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { TaskOutputModel } from '../../../src/tui/presentation/task-output-model'; - -function createModel(complete?: boolean): TaskOutputModel { - return new TaskOutputModel({ - taskId: 'task-1', - title: 'Task output', - complete, - }); -} - -describe('TaskOutputModel', () => { - it('starts empty, following, at the top, and defaults complete to false', () => { - const model = createModel(); - - expect(model.toViewModel(3)).toEqual({ - taskId: 'task-1', - title: 'Task output', - lines: [], - follow: true, - complete: false, - }); - expect(createModel(true).toViewModel(3).complete).toBe(true); - }); - - it('computes the scroll bound with at least one viewable row', () => { - const model = createModel(); - model.setOutput('a\nb\nc'); - model.toViewModel(0); - model.handleKey({ kind: 'home' }); - model.handleKey({ kind: 'page-down' }); - - expect(model.toViewModel(1).lines).toEqual(['b']); - }); - - it('replaces output, follows new output, preserves manual position, and clamps after shrink', () => { - const following = createModel(); - following.setOutput('a\nb\nc\nd'); - expect(following.toViewModel(2).lines).toEqual(['c', 'd']); - following.setOutput('a\nb\nc\nd\ne\nf'); - expect(following.toViewModel(2).lines).toEqual(['e', 'f']); - - const manual = createModel(); - manual.setOutput('a\nb\nc\nd\ne\nf'); - manual.toViewModel(3); - manual.handleKey({ kind: 'up' }); - manual.setOutput('a\nb\nc\nd\ne\nf\ng\nh'); - expect(manual.toViewModel(3)).toMatchObject({ - lines: ['c', 'd', 'e'], - follow: false, - }); - - manual.setOutput('a\nb\nc'); - expect(manual.toViewModel(3)).toMatchObject({ - lines: ['a', 'b', 'c'], - follow: false, - }); - }); - - it('updates completion without changing output, position, or follow state', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd'); - model.toViewModel(2); - model.handleKey({ kind: 'up' }); - const before = model.toViewModel(2); - - model.setComplete(true); - - expect(model.toViewModel(2)).toEqual({ ...before, complete: true }); - }); - - it('moves up one row and always disengages follow', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd'); - model.toViewModel(2); - - expect(model.handleKey({ kind: 'up' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel(2)).toMatchObject({ - lines: ['b', 'c'], - follow: false, - }); - }); - - it('moves down one row and re-engages follow only at the exact bottom', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd\ne'); - model.toViewModel(2); - model.handleKey({ kind: 'home' }); - - model.handleKey({ kind: 'down' }); - expect(model.toViewModel(2)).toMatchObject({ lines: ['b', 'c'], follow: false }); - model.handleKey({ kind: 'down' }); - expect(model.toViewModel(2)).toMatchObject({ lines: ['c', 'd'], follow: false }); - model.handleKey({ kind: 'down' }); - expect(model.toViewModel(2)).toMatchObject({ lines: ['d', 'e'], follow: true }); - }); - - it('moves page-up by viewport rows minus one and disengages follow', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd\ne\nf'); - model.toViewModel(3); - - expect(model.handleKey({ kind: 'page-up' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel(3)).toMatchObject({ - lines: ['b', 'c', 'd'], - follow: false, - }); - }); - - it('moves page-down by viewport rows minus one and follows only at the bottom', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd\ne\nf\ng\nh'); - model.toViewModel(3); - model.handleKey({ kind: 'home' }); - - model.handleKey({ kind: 'page-down' }); - expect(model.toViewModel(3)).toMatchObject({ lines: ['c', 'd', 'e'], follow: false }); - model.handleKey({ kind: 'page-down' }); - expect(model.toViewModel(3)).toMatchObject({ lines: ['e', 'f', 'g'], follow: false }); - model.handleKey({ kind: 'page-down' }); - expect(model.toViewModel(3)).toMatchObject({ lines: ['f', 'g', 'h'], follow: true }); - }); - - it('jumps home and always disengages follow', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd'); - model.toViewModel(2); - - expect(model.handleKey({ kind: 'home' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel(2)).toMatchObject({ - lines: ['a', 'b'], - follow: false, - }); - }); - - it('jumps end and always re-engages follow', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd'); - model.toViewModel(2); - model.handleKey({ kind: 'home' }); - - expect(model.handleKey({ kind: 'end' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel(2)).toMatchObject({ - lines: ['c', 'd'], - follow: true, - }); - }); - - it('returns close without changing state', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd'); - model.toViewModel(2); - model.handleKey({ kind: 'up' }); - const before = model.toViewModel(2); - - expect(model.handleKey({ kind: 'close' })).toEqual({ type: 'close' }); - expect(model.toViewModel(2)).toEqual(before); - }); - - it('uses the latest viewport, clamps a manual window, and pins a followed resize', () => { - const manual = createModel(); - manual.setOutput('a\nb\nc\nd\ne\nf'); - manual.toViewModel(4); - manual.handleKey({ kind: 'up' }); - expect(manual.toViewModel(2)).toMatchObject({ - lines: ['b', 'c'], - follow: false, - }); - manual.handleKey({ kind: 'page-down' }); - expect(manual.toViewModel(2)).toMatchObject({ - lines: ['c', 'd'], - follow: false, - }); - - const following = createModel(); - following.setOutput('a\nb\nc\nd\ne\nf'); - expect(following.toViewModel(2).lines).toEqual(['e', 'f']); - expect(following.toViewModel(4)).toMatchObject({ - lines: ['c', 'd', 'e', 'f'], - follow: true, - }); - }); - - it('clamps with the standard formula and never returns more than viewportRows lines', () => { - const model = createModel(); - model.setOutput('a\nb\nc\nd\ne'); - - expect(model.toViewModel(2).lines).toHaveLength(2); - expect(model.toViewModel(0).lines).toEqual([]); - expect(model.toViewModel(-1).lines).toEqual([]); - }); -}); diff --git a/apps/pythinker-code/test/tui/presentation/tasks-browser-model.test.ts b/apps/pythinker-code/test/tui/presentation/tasks-browser-model.test.ts deleted file mode 100644 index 1f7f42b0..00000000 --- a/apps/pythinker-code/test/tui/presentation/tasks-browser-model.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - TasksBrowserModel, - type BackgroundTaskStatus, - type TaskRow, -} from '../../../src/tui/presentation/tasks-browser-model'; - -function task( - taskId: string, - status: BackgroundTaskStatus = 'running', - startedAt = 0, - endedAt: number | null = null, -): TaskRow { - return { - taskId, - description: `Description for ${taskId}`, - status, - startedAt, - endedAt, - }; -} - -function rowIds(model: TasksBrowserModel): string[] { - return model.toViewModel().rows.map((row) => row.taskId); -} - -describe('TasksBrowserModel', () => { - it('maps every status to its exact label and exposes the current view model', () => { - const statuses: BackgroundTaskStatus[] = [ - 'running', - 'completed', - 'failed', - 'timed_out', - 'killed', - 'lost', - ]; - const model = new TasksBrowserModel( - statuses.map((status, index) => task(status, status, index, index + 10)), - ); - - expect( - Object.fromEntries(model.toViewModel().rows.map((row) => [row.status, row.statusLabel])), - ).toEqual({ - running: 'running', - completed: 'completed', - failed: 'failed', - timed_out: 'timed out', - killed: 'killed', - lost: 'lost', - }); - expect(model.toViewModel()).toMatchObject({ - selectedIndex: 0, - filter: 'all', - stopPendingTaskId: undefined, - }); - }); - - it('treats every status except running as terminal and active filtering removes all of them', () => { - const model = new TasksBrowserModel( - [ - task('completed', 'completed'), - task('running-a', 'running', 2), - task('failed', 'failed'), - task('lost', 'lost'), - task('timed-out', 'timed_out'), - task('killed', 'killed'), - task('running-b', 'running', 1), - ], - 'active', - ); - - expect(rowIds(model)).toEqual(['running-b', 'running-a']); - }); - - it('keeps all-filter input order before sorting and uses a stable comparator for ties', () => { - const model = new TasksBrowserModel([ - task('running-first', 'running', 5), - task('running-second', 'running', 5), - task('terminal-first', 'failed', 1, 20), - task('terminal-second', 'lost', 2, 20), - ]); - - expect(rowIds(model)).toEqual([ - 'running-first', - 'running-second', - 'terminal-first', - 'terminal-second', - ]); - }); - - it('sorts running tasks first by ascending start and terminal tasks by descending end fallback', () => { - const model = new TasksBrowserModel([ - task('terminal-old', 'completed', 2, 20), - task('running-late', 'running', 9), - task('terminal-fallback', 'failed', 30, null), - task('running-early', 'running', 3), - task('terminal-new', 'lost', 1, 40), - ]); - - expect(rowIds(model)).toEqual([ - 'running-early', - 'running-late', - 'terminal-new', - 'terminal-fallback', - 'terminal-old', - ]); - }); - - it('initializes the requested filter, selection, and stop state', () => { - const model = new TasksBrowserModel([task('terminal', 'completed')], 'active'); - - expect(model.toViewModel()).toEqual({ - rows: [], - selectedIndex: 0, - filter: 'active', - stopPendingTaskId: undefined, - }); - expect(model.isStopPending()).toBe(false); - }); - - it('preserves selection by task id across task updates', () => { - const model = new TasksBrowserModel([ - task('first', 'running', 1), - task('selected', 'running', 2), - ]); - model.handleKey({ kind: 'down' }); - - model.setTasks([ - task('selected', 'running', 3), - task('new-first', 'running', 1), - task('new-last', 'running', 4), - ]); - - expect(model.toViewModel().selectedIndex).toBe(1); - expect(model.toViewModel().rows[1]?.taskId).toBe('selected'); - }); - - it('clamps selection when the selected task disappears and handles an empty replacement', () => { - const model = new TasksBrowserModel([ - task('first', 'running', 1), - task('second', 'running', 2), - task('third', 'running', 3), - ]); - model.handleKey({ kind: 'down' }); - model.handleKey({ kind: 'down' }); - - model.setTasks([task('only', 'running')]); - expect(model.toViewModel().selectedIndex).toBe(0); - expect(rowIds(model)).toEqual(['only']); - - model.setTasks([]); - expect(model.toViewModel().selectedIndex).toBe(0); - }); - - it('clears a pending stop when its task becomes terminal', () => { - const model = new TasksBrowserModel([task('pending')]); - model.handleKey({ kind: 'stop' }); - - model.setTasks([task('pending', 'failed', 0, 10)]); - - expect(model.isStopPending()).toBe(false); - expect(model.toViewModel().stopPendingTaskId).toBeUndefined(); - }); - - it('clears a pending stop when its task is removed', () => { - const model = new TasksBrowserModel([task('pending'), task('other')]); - model.handleKey({ kind: 'stop' }); - - model.setTasks([task('other')]); - - expect(model.isStopPending()).toBe(false); - }); - - it('preserves a pending stop when its task remains non-terminal in the full task list', () => { - const model = new TasksBrowserModel([task('pending'), task('other')], 'active'); - model.handleKey({ kind: 'stop' }); - - model.setTasks([task('other', 'running', 1), task('pending', 'running', 2)]); - - expect(model.isStopPending()).toBe(true); - expect(model.toViewModel().stopPendingTaskId).toBe('pending'); - }); - - it('returns select at both up and down boundaries without moving past them', () => { - const model = new TasksBrowserModel([ - task('first', 'running', 1), - task('last', 'running', 2), - ]); - - expect(model.handleKey({ kind: 'up' })).toEqual({ type: 'select', taskId: 'first' }); - expect(model.toViewModel().selectedIndex).toBe(0); - expect(model.handleKey({ kind: 'down' })).toEqual({ type: 'select', taskId: 'last' }); - expect(model.handleKey({ kind: 'down' })).toEqual({ type: 'select', taskId: 'last' }); - expect(model.toViewModel().selectedIndex).toBe(1); - }); - - it('consumes up and down on an empty list without changing state', () => { - const model = new TasksBrowserModel([]); - const before = model.toViewModel(); - - expect(model.handleKey({ kind: 'up' })).toEqual({ type: 'consumed' }); - expect(model.handleKey({ kind: 'down' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel()).toEqual(before); - }); - - it('preserves selection across a filter flip when the selected task remains visible', () => { - const model = new TasksBrowserModel([ - task('running-first', 'running', 1), - task('running-selected', 'running', 2), - task('terminal', 'completed', 0, 5), - ]); - model.handleKey({ kind: 'down' }); - - expect(model.handleKey({ kind: 'toggle-filter' })).toEqual({ type: 'consumed' }); - expect(model.toViewModel().filter).toBe('active'); - expect(model.toViewModel().selectedIndex).toBe(1); - expect(model.toViewModel().rows[1]?.taskId).toBe('running-selected'); - }); - - it('clamps selection to zero when a filter flip removes the selected task', () => { - const model = new TasksBrowserModel([ - task('running', 'running', 1), - task('terminal', 'completed', 0, 5), - ]); - model.handleKey({ kind: 'down' }); - - model.handleKey({ kind: 'toggle-filter' }); - - expect(rowIds(model)).toEqual(['running']); - expect(model.toViewModel().selectedIndex).toBe(0); - }); - - it('returns refresh without changing any state', () => { - const model = new TasksBrowserModel([task('running')], 'active'); - model.handleKey({ kind: 'stop' }); - const before = model.toViewModel(); - - expect(model.handleKey({ kind: 'refresh' })).toEqual({ type: 'refresh' }); - expect(model.toViewModel()).toEqual(before); - }); - - it('arms the selected running task and consumes stop on an empty list', () => { - const model = new TasksBrowserModel([task('running')]); - - expect(model.handleKey({ kind: 'stop' })).toEqual({ - type: 'stop-armed', - taskId: 'running', - }); - expect(model.isStopPending()).toBe(true); - - const emptyModel = new TasksBrowserModel([]); - expect(emptyModel.handleKey({ kind: 'stop' })).toEqual({ type: 'consumed' }); - expect(emptyModel.isStopPending()).toBe(false); - }); - - it('ignores stop on a selected terminal task without mutating pending state', () => { - const model = new TasksBrowserModel([task('terminal', 'lost', 0, 2)]); - expect(model.isStopPending()).toBe(false); - - expect(model.handleKey({ kind: 'stop' })).toEqual({ - type: 'stop-ignored', - taskId: 'terminal', - }); - expect(model.isStopPending()).toBe(false); - expect(model.toViewModel().stopPendingTaskId).toBeUndefined(); - }); - - it('opens the selected task or consumes open when empty without changing state', () => { - const model = new TasksBrowserModel([task('first'), task('second', 'running', 1)]); - model.handleKey({ kind: 'down' }); - const before = model.toViewModel(); - - expect(model.handleKey({ kind: 'open' })).toEqual({ type: 'open', taskId: 'second' }); - expect(model.toViewModel()).toEqual(before); - expect(new TasksBrowserModel([]).handleKey({ kind: 'open' })).toEqual({ - type: 'consumed', - }); - }); - - it('returns cancel without changing state', () => { - const model = new TasksBrowserModel([task('running')]); - model.handleKey({ kind: 'stop' }); - const before = model.toViewModel(); - - expect(model.handleKey({ kind: 'cancel' })).toEqual({ type: 'cancel' }); - expect(model.toViewModel()).toEqual(before); - }); - - it('reports pending state and confirms only exact lowercase or uppercase y', () => { - for (const char of ['y', 'Y']) { - const model = new TasksBrowserModel([task(`task-${char}`)]); - model.handleKey({ kind: 'stop' }); - - expect(model.isStopPending()).toBe(true); - expect(model.handleStopPromptKey(char)).toEqual({ - type: 'confirmed', - taskId: `task-${char}`, - }); - expect(model.isStopPending()).toBe(false); - } - }); - - it('cancels every non-y stop prompt input and always clears pending state', () => { - for (const char of ['n', 'unrelated', 'Escape', '']) { - const model = new TasksBrowserModel([task('pending')]); - model.handleKey({ kind: 'stop' }); - - expect(model.handleStopPromptKey(char)).toEqual({ type: 'cancelled' }); - expect(model.isStopPending()).toBe(false); - } - }); - - it('harmlessly cancels a stop prompt key when no stop is pending', () => { - const model = new TasksBrowserModel([task('running')]); - const before = model.toViewModel(); - - expect(model.handleStopPromptKey('y')).toEqual({ type: 'cancelled' }); - expect(model.toViewModel()).toEqual(before); - }); - - it('projects only renderer-neutral row fields from the latest sorted tasks', () => { - const source = task('task-1', 'timed_out', 4, null); - const model = new TasksBrowserModel([source]); - - expect(model.toViewModel()).toEqual({ - rows: [ - { - taskId: 'task-1', - description: 'Description for task-1', - status: 'timed_out', - statusLabel: 'timed out', - }, - ], - selectedIndex: 0, - filter: 'all', - stopPendingTaskId: undefined, - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/presentation/tool-presentation-model.test.ts b/apps/pythinker-code/test/tui/presentation/tool-presentation-model.test.ts deleted file mode 100644 index 38fbaa7c..00000000 --- a/apps/pythinker-code/test/tui/presentation/tool-presentation-model.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Verifies renderer-neutral tool status, verb, and grouping decisions. - */ - -import { describe, expect, it } from 'vitest'; -import { - deriveToolStatus, - deriveToolVerb, - ToolGroupPlanner, -} from '../../../src/tui/presentation/tool-presentation-model'; - -describe('deriveToolStatus', () => { - it('derives every unfinished status', () => { - expect(deriveToolStatus({ hasResult: false, truncated: true })).toBe('truncated'); - expect(deriveToolStatus({ hasResult: false, streamingArguments: '{}' })).toBe('streaming'); - expect(deriveToolStatus({ hasResult: false, streamingArguments: '' })).toBe('streaming'); - expect(deriveToolStatus({ hasResult: false })).toBe('running'); - }); - - it('gives completed results precedence over unfinished state', () => { - expect(deriveToolStatus({ hasResult: true, truncated: true })).toBe('done'); - expect( - deriveToolStatus({ - hasResult: true, - truncated: true, - streamingArguments: '', - }), - ).toBe('done'); - }); - - it('only treats errors with results as failed', () => { - expect(deriveToolStatus({ hasResult: true, isError: true })).toBe('failed'); - expect(deriveToolStatus({ hasResult: false, isError: true })).toBe('running'); - }); -}); - -describe('deriveToolVerb', () => { - it('maps every status to its presentation verb', () => { - expect(deriveToolVerb('done')).toBe('Used'); - expect(deriveToolVerb('failed')).toBe('Used'); - expect(deriveToolVerb('truncated')).toBe('Truncated'); - expect(deriveToolVerb('streaming')).toBe('Using'); - expect(deriveToolVerb('running')).toBe('Using'); - }); -}); - -describe('ToolGroupPlanner', () => { - it('places a lone Agent call standalone', () => { - const planner = new ToolGroupPlanner(); - - expect(planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' })).toEqual({ - kind: 'standalone', - toolCallId: 'a1', - }); - }); - - it('opens a group for two matching Agent calls and appends a third', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - - expect( - planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }), - ).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:0', - toolCallIds: ['a1', 'a2'], - }); - expect( - planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' }), - ).toEqual({ - kind: 'append-group', - groupKey: 'group:Agent:0', - toolCallId: 'a3', - }); - }); - - it('starts new groups after differing steps and turn ids', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - const first = planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'a3', name: 'Agent', step: 2, turnId: 't1' }); - const second = planner.place({ toolCallId: 'a4', name: 'Agent', step: 2, turnId: 't1' }); - planner.place({ toolCallId: 'a5', name: 'Agent', step: 2, turnId: 't2' }); - const third = planner.place({ toolCallId: 'a6', name: 'Agent', step: 2, turnId: 't2' }); - - expect(first).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:0', - toolCallIds: ['a1', 'a2'], - }); - expect(second).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:1', - toolCallIds: ['a3', 'a4'], - }); - expect(third).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:2', - toolCallIds: ['a5', 'a6'], - }); - }); - - it('clears the pending Agent slot when a Read call arrives', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'r1', name: 'Read', step: 1, turnId: 't1' }); - - expect(planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' })).toEqual({ - kind: 'standalone', - toolCallId: 'a3', - }); - }); - - it('leaves an open group intact across a deferred question', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - - expect( - planner.place({ - toolCallId: 'q1', - name: 'AskUserQuestion', - step: 2, - turnId: 't2', - }), - ).toEqual({ kind: 'deferred' }); - expect( - planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' }), - ).toEqual({ - kind: 'append-group', - groupKey: 'group:Agent:0', - toolCallId: 'a3', - }); - }); - - it('tracks Agent and Read independently without merging them', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ - toolCallId: 'q1', - name: 'AskUserQuestion', - step: 1, - turnId: 't1', - }); - planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ - toolCallId: 'q2', - name: 'AskUserQuestion', - step: 1, - turnId: 't1', - }); - planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ - toolCallId: 'q3', - name: 'AskUserQuestion', - step: 1, - turnId: 't1', - }); - planner.place({ toolCallId: 'r1', name: 'Read', step: 1, turnId: 't1' }); - planner.place({ - toolCallId: 'q4', - name: 'AskUserQuestion', - step: 1, - turnId: 't1', - }); - - expect(planner.place({ toolCallId: 'r2', name: 'Read', step: 1, turnId: 't1' })).toEqual({ - kind: 'open-group', - groupKey: 'group:Read:1', - toolCallIds: ['r1', 'r2'], - }); - }); - - it('uses different keys when an unrelated call separates identical groups', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - const first = planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'b1', name: 'Bash', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' }); - const second = planner.place({ toolCallId: 'a4', name: 'Agent', step: 1, turnId: 't1' }); - - expect(first).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:0', - toolCallIds: ['a1', 'a2'], - }); - expect(second).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:1', - toolCallIds: ['a3', 'a4'], - }); - }); - - it('clears slots and restarts key numbering on reset', () => { - const planner = new ToolGroupPlanner(); - planner.place({ toolCallId: 'a1', name: 'Agent', step: 1, turnId: 't1' }); - planner.place({ toolCallId: 'a2', name: 'Agent', step: 1, turnId: 't1' }); - planner.reset(); - - expect(planner.place({ toolCallId: 'a3', name: 'Agent', step: 1, turnId: 't1' })).toEqual({ - kind: 'standalone', - toolCallId: 'a3', - }); - expect(planner.place({ toolCallId: 'a4', name: 'Agent', step: 1, turnId: 't1' })).toEqual({ - kind: 'open-group', - groupKey: 'group:Agent:0', - toolCallIds: ['a3', 'a4'], - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index f6a17751..b671ed10 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -1,68 +1,116 @@ import { AsyncLocalStorage } from 'node:async_hooks'; +import { existsSync } from 'node:fs'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import chalk from 'chalk'; +import { join, resolve } from 'node:path'; import { deleteAllKittyImages, resetCapabilitiesCache, setCapabilities, - type AutocompleteProvider, - type Component, -} from '@earendil-works/pi-tui'; -import type { ApprovalRequest, ApprovalResponse, Event } from '@pymodel/pythinker-code-sdk'; +} from '@pymodel/pi-tui'; +import type { + ApprovalRequest, + ApprovalResponse, + Event, + GoalSnapshot, + Session, +} from '@pymodel/pythinker-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; -import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; +import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; +import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { MOON_SPINNER_FRAMES } from '#/tui/constant/rendering'; import { - ANTHROPIC_PLUGIN_MARKETPLACE_URL, - PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, -} from '#/constant/app'; -import { appendInputHistory } from '#/utils/history/input-history'; -import { performHeapDump } from '#/utils/heap-dump'; -import { getInputHistoryFile } from '#/utils/paths'; -import { DynamicWorkflowMissionControlComponent } from '#/tui/components/messages/dynamic-workflow-mission-control'; -import { ThinkingComponent } from '#/tui/components/messages/thinking'; -import { BRAILLE_SPINNER_FRAMES } from '#/tui/constant/rendering'; + AgentDynamicWorkflowProgressComponent, + agentDynamicWorkflowGridHeightForTerminalRows, +} from '#/tui/components/messages/agent-dynamic-workflow-progress'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; +import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { + groupTurns, + TRANSCRIPT_KEEP_RECENT_ASSISTANT, + TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED, + TRANSCRIPT_KEEP_RECENT_STEPS, +} from '#/tui/utils/transcript-window'; import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; +import { ThinkingComponent } from '#/tui/components/messages/thinking'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { ChoicePickerComponent } from '#/tui/components/dialogs/choice-picker'; -import { StartPermissionPromptComponent } from '#/tui/components/dialogs/start-permission-prompt'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; -import { EffortSelectorComponent } from '#/tui/components/dialogs/effort-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; import { + PluginInstallTrustConfirmComponent, PluginMcpSelectorComponent, - PluginMarketplaceSelectorComponent, PluginRemoveConfirmComponent, - PluginsOverviewSelectorComponent, + PluginsPanelComponent, } from '#/tui/components/dialogs/plugins-selector'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; +import type { SessionReplayRenderer } from '#/tui/controllers/session-replay'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -import { ScrollbackBridge } from '#/tui/runtime/scrollback/scrollback-bridge'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { openUrl } from '#/utils/open-url'; +import { createFeedbackArchivePath } from '../../src/feedback/archive'; +import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; +import { uploadArchive } from '../../src/feedback/upload'; import { + promptFeedbackAttachment, promptFeedbackInput, runModelSelector, + type FeedbackPromptResult, } from '#/tui/commands/prompts'; -import { currentTheme } from '#/tui/theme'; import type { QueuedMessage } from '#/tui/types'; import type { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; -import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; +import { + extractMediaAttachments, + type ExtractionResult, +} from '#/tui/utils/image-placeholder'; vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); - return { ...actual, promptFeedbackInput: vi.fn() }; + return { + ...actual, + promptFeedbackInput: vi.fn(), + promptFeedbackAttachment: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/codebase', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/feedback/codebase')>(); + return { + ...actual, + scanCodebase: vi.fn().mockResolvedValue(undefined), + packageCodebase: vi.fn(), + }; +}); + +vi.mock('../../src/feedback/upload', () => ({ + uploadArchive: vi.fn(), +})); + +vi.mock('../../src/feedback/archive', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/feedback/archive')>(); + return { + ...actual, + // Wrap the real implementation so archive packaging keeps working in the + // other tests; individual tests can reject it to simulate an unwritable + // cache dir. + createFeedbackArchivePath: vi.fn(actual.createFeedbackArchivePath), + }; }); +// /feedback opens GitHub Issues in a browser when submission fails — stub it +// out so the test suite never spawns a browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); -vi.mock('#/utils/heap-dump', () => ({ performHeapDump: vi.fn() })); + +// Clipboard access spawns platform tools (pbcopy/wl-copy …) and emits OSC 52 — +// stub it out so the suite never touches the real clipboard or stdout. +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => 'native'), +})); const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -76,27 +124,27 @@ function stripSgr(text: string): string { interface MessageDriver { state: TUIState; streamingUI: StreamingUIController; + sessionReplay: SessionReplayRenderer; + pluginCommandMap: Map<string, string>; sessionEventHandler: { startSubscription(): void; handleEvent(event: Event, sendQueued: (item: QueuedMessage) => void): void; - clearDynamicWorkflowMissionControls(): void; - hasDynamicWorkflowMissionControl(toolCallId: string): boolean; - resetRuntimeState(): void; }; init(): Promise<boolean>; handleUserInput(text: string): void; - sendSkillActivation( - session: ReturnType<typeof makeSession>, - skillName: string, - skillArgs: string, - ): void; persistInputHistory(text: string): Promise<void>; + sendQueuedMessage(session: unknown, item: QueuedMessage): void; + recallLastQueued(): QueuedMessage | undefined; + recallStashedMedia(text: string, extraction: ExtractionResult | undefined): void; + clearQueuedMessages(): void; + closeSession(reason: string): Promise<void>; + setSession(session: unknown): Promise<void>; getCurrentSessionId(): string; } interface FeedbackDriver extends MessageDriver { handleFeedbackCommand(): Promise<void>; - promptFeedbackInput(): Promise<string | undefined>; + promptFeedbackInput(): Promise<FeedbackPromptResult | undefined>; } interface ModelSelectorDriver extends MessageDriver { @@ -111,15 +159,14 @@ interface ModelSelectorDriver extends MessageDriver { capabilities?: string[]; } >, - ): Promise<{ alias: string; effort: string } | undefined>; + ): Promise<{ alias: string; thinking: boolean } | undefined>; } -function makeStartupInput(layout: 'inline' | 'fixed' = 'inline'): PythinkerTUIStartupInput { +function makeStartupInput(): PythinkerTUIStartupInput { return { cliOptions: { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -127,15 +174,16 @@ function makeStartupInput(layout: 'inline' | 'fixed' = 'inline'): PythinkerTUISt outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', - layout, - copyFullResponse: false, + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-a', @@ -143,62 +191,23 @@ function makeStartupInput(layout: 'inline' | 'fixed' = 'inline'): PythinkerTUISt } function makeSession(overrides: Record<string, unknown> = {}) { - const prompt = vi.fn(async (_input: unknown) => {}); + let model = 'k2'; + let thinkingEffort = 'off'; return { id: 'ses-1', model: 'k2', summary: { title: null }, - prompt, + prompt: vi.fn(async (_input: unknown) => {}), + compact: vi.fn(async () => {}), steer: vi.fn(async () => {}), init: vi.fn(async () => {}), startBtw: vi.fn(async () => 'agent-btw'), undoHistory: vi.fn(async () => {}), - compact: vi.fn(async () => {}), - listFileCheckpoints: vi.fn(async () => - prompt.mock.calls.map(([input], index) => ({ - id: `checkpoint-${String(index + 1)}`, - kind: 'user' as const, - createdAt: new Date(Date.UTC(2026, 6, 30, 12, index)).toISOString(), - prompt: - typeof input === 'string' - ? input - : Array.isArray(input) - ? input - .filter( - (part: unknown): part is { type: 'text'; text: string } => - typeof part === 'object' && - part !== null && - 'type' in part && - part.type === 'text' && - 'text' in part && - typeof part.text === 'string', - ) - .map((part: { type: 'text'; text: string }) => part.text) - .join('') - : 'User prompt', - complete: true, - changedPaths: [], - })), - ), - previewFileCheckpoint: vi.fn(async (checkpointId: string) => ({ - checkpointId, - complete: true, - paths: [], - insertions: 0, - deletions: 0, - conversationAvailable: true, - })), - restoreFileCheckpoint: vi.fn(async (checkpointId: string) => ({ - checkpointId, - recoveryCheckpointId: 'recovery-1', - restoredPaths: [], - deletedPaths: [], - })), cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', + model, + thinkingEffort, permission: 'manual', planMode: false, contextTokens: 0, @@ -206,25 +215,27 @@ function makeSession(overrides: Record<string, unknown> = {}) { contextUsage: 0, })), getGoal: vi.fn(async () => ({ goal: null })), - listBackgroundTasks: vi.fn(async () => []), setApprovalHandler: vi.fn(), setQuestionHandler: vi.fn(), - setModel: vi.fn(async () => {}), - setThinking: vi.fn(async () => {}), + setModel: vi.fn(async (alias: string) => { + model = alias; + }), + setThinking: vi.fn(async (effort: string) => { + thinkingEffort = effort; + }), setPermission: vi.fn(async () => {}), setPlanMode: vi.fn(async () => {}), setDynamicWorkflowMode: vi.fn(async () => {}), onEvent: vi.fn(() => vi.fn()), listMcpServers: vi.fn(async () => []), listSkills: vi.fn(async () => []), - activateSkill: vi.fn(async () => ({ execution: 'inline' as const })), getResumeState: vi.fn(() => ({ sessionMetadata: {}, agents: { main: { status: { model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 0, @@ -248,12 +259,15 @@ function makeSession(overrides: Record<string, unknown> = {}) { mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', })), setPluginEnabled: vi.fn(async () => {}), setPluginMcpServerEnabled: vi.fn(async () => {}), removePlugin: vi.fn(async () => {}), reloadPlugins: vi.fn(async () => ({ added: [], removed: [], errors: [] })), reloadSession: vi.fn(async () => ({})), + activateSkill: vi.fn(async () => {}), + promptWithSkills: vi.fn(async () => {}), getPluginInfo: vi.fn(async (id: string) => ({ id, displayName: id, @@ -276,10 +290,10 @@ function makeSession(overrides: Record<string, unknown> = {}) { function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { const interactiveAgentScope = new AsyncLocalStorage<string>(); - return { + const harness = { getConfig: vi.fn(async () => ({ models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), setConfig: vi.fn(async () => ({ providers: {} })), @@ -287,6 +301,13 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), listSessions: vi.fn(async () => []), + exportSession: vi.fn(async () => ({ + zipPath: '/tmp/fake-session.zip', + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + })), + deleteFile: vi.fn(async () => {}), close: vi.fn(async () => {}), track: vi.fn(), setTelemetryContext: vi.fn(), @@ -298,31 +319,55 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> }), getExperimentalFeatures: vi.fn(async () => []), auth: { - status: vi.fn(), + // /feedback gates on the OAuth token rather than the active model, so + // the default mock is a signed-in user; signed-out cases override this. + status: vi.fn(async () => ({ + providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + })), login: vi.fn(), logout: vi.fn(), getManagedUsage: vi.fn(), submitFeedback: vi.fn( - async (): Promise<{ kind: 'ok' } | { kind: 'error'; status?: number; message: string }> => ({ + async (): Promise< + { kind: 'ok'; feedbackId: number } | { kind: 'error'; status?: number; message: string } + > => ({ kind: 'ok', + feedbackId: 3, }), ), }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise<unknown[]>; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } async function makeDriver( session = makeSession(), harnessOverrides: Record<string, unknown> = {}, - layout: 'inline' | 'fixed' = 'inline', + startupInput: PythinkerTUIStartupInput = makeStartupInput(), ): Promise<{ driver: MessageDriver; session: ReturnType<typeof makeSession>; harness: ReturnType<typeof makeHarness>; }> { const harness = makeHarness(session, harnessOverrides); - const driver = new PythinkerTUI(harness as never, makeStartupInput(layout)) as unknown as MessageDriver; + const driver = new PythinkerTUI(harness as never, startupInput) as unknown as MessageDriver; vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); vi.spyOn(driver.state.terminal, 'setProgress').mockImplementation(() => {}); driver.persistInputHistory = vi.fn(async () => {}); @@ -330,12 +375,31 @@ async function makeDriver( return { driver, session, harness }; } -function renderTranscript(driver: MessageDriver): string { - return driver.state.transcriptContainer.render(120).join('\n'); +function makeActiveGoalSnapshot(): GoalSnapshot { + return { + goalId: 'g1', + objective: 'Ship the feature', + status: 'active', + turnsUsed: 3, + tokensUsed: 100, + wallClockMs: 1000, + budget: { + tokenBudget: null, + turnBudget: null, + wallClockBudgetMs: null, + remainingTokens: null, + remainingTurns: null, + remainingWallClockMs: null, + tokenBudgetReached: false, + turnBudgetReached: false, + wallClockBudgetReached: false, + overBudget: false, + }, + }; } -function renderMcpStatus(driver: Readonly<MessageDriver>): string { - return driver.state.mcpStatusContainer.render(120).join('\n'); +function renderTranscript(driver: MessageDriver): string { + return driver.state.transcriptContainer.render(120).join('\n'); } async function confirmUndoSelection(driver: MessageDriver): Promise<void> { @@ -391,67 +455,6 @@ function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } -function dispatchTerminalInput(driver: MessageDriver, data: string): void { - (driver.state.ui as unknown as { handleInput(input: string): void }).handleInput(data); -} - -function setTask7Keybindings( - tui: PythinkerTUI, - blocks: Parameters<typeof parseKeybindingBlocks>[0], -): void { - const bindings = [...defaultKeybindings(), ...parseKeybindingBlocks(blocks)]; - tui.state.editor.setKeybindings(bindings); - tui.editorKeyboard.setKeybindings(bindings); -} - -function activeGoal() { - return { - goalId: 'goal-1', - objective: 'Ship it', - status: 'active' as const, - turnsUsed: 1, - tokensUsed: 0, - wallClockMs: 0, - budget: { - turnBudget: null, - tokenBudget: null, - wallClockBudgetMs: null, - remainingTokens: null, - remainingTurns: null, - remainingWallClockMs: null, - tokenBudgetReached: false, - turnBudgetReached: false, - wallClockBudgetReached: false, - overBudget: false, - }, - }; -} - -async function flushAutocomplete(): Promise<void> { - await Promise.resolve(); - await Promise.resolve(); -} - -function autocompleteProvider(): AutocompleteProvider { - return { - getSuggestions: vi.fn(async () => ({ - items: [{ value: 'help', label: 'help' }], - prefix: '', - })), - applyCompletion: vi.fn((lines, cursorLine, cursorCol) => ({ - lines, - cursorLine, - cursorCol, - })), - }; -} - -function enableMcpStatusAnimationForTest(): void { - vi.stubEnv('PYTHINKER_NO_ANIMATION', ''); - vi.stubEnv('CI', ''); - vi.stubEnv('NO_COLOR', ''); -} - const tempDirs: string[] = []; const originalPythinkerCodeHome = process.env['PYTHINKER_CODE_HOME']; const originalPluginMarketplaceUrl = process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL']; @@ -464,8 +467,47 @@ async function makeTempHome(): Promise<string> { return dir; } +/** Runs `run` with a temp clip.mp4 source, removing the temp dir afterwards. */ +async function withTempVideo(run: (srcVideo: string) => Promise<void>): Promise<void> { + const dir = await mkdtemp(join(tmpdir(), 'tui-video-')); + try { + const srcVideo = join(dir, 'clip.mp4'); + await writeFile(srcVideo, 'video-bytes'); + await run(srcVideo); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function stagedImage(imageStore: ImageAttachmentStore, fileId: string) { + return imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1, undefined, fileId); +} + +/** + * Emits the turn.started/turn.ended pair that claims and then releases a + * staged-media lease; `between` runs assertions after the claim. + */ +function emitTurn(driver: MessageDriver, turnId: number, between?: () => void): void { + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', turnId, origin: { kind: 'user' } } as Event, + () => {}, + ); + between?.(); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId, reason: 'completed' } as Event, + () => {}, + ); +} + +async function makeExportedSessionZip(content = 'session zip'): Promise<string> { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-code-feedback-export-')); + tempDirs.push(dir); + const zipPath = join(dir, 'session.zip'); + await writeFile(zipPath, content); + return zipPath; +} + afterEach(async () => { - vi.unstubAllEnvs(); resetCapabilitiesCache(); for (const dir of tempDirs.splice(0)) { await rm(dir, { recursive: true, force: true }); @@ -493,1008 +535,2562 @@ afterEach(async () => { }); describe('PythinkerTUI message flow', () => { - it('settles a local request after a forked skill completes', async () => { - const session = makeSession({ - activateSkill: vi.fn(async () => ({ - execution: 'fork' as const, - result: 'Forked review complete.', - })), - }); - const { driver } = await makeDriver(session); + it('tracks editor shortcut and paste hooks', async () => { + const { driver, harness } = await makeDriver(); + harness.track.mockClear(); - driver.sendSkillActivation(session, 'review', 'current branch'); + driver.state.editor.handleInput('\u001F'); + delete process.env['VISUAL']; + delete process.env['EDITOR']; + driver.state.editor.onOpenExternalEditor?.(); + driver.state.editor.onToggleToolExpand?.(); + driver.state.editor.onTextPaste?.(); + + expect(harness.track).toHaveBeenCalledWith('undo', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_editor', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_expand', undefined); + expect(harness.track).toHaveBeenCalledWith('shortcut_paste', { kind: 'text' }); + }); + + it('lazily creates the session on the first message (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Startup stays session-less on the v2 engine. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + expect(driver.state.appState.model).toBe('k2'); + + driver.handleUserInput('hello'); - expect(driver.state.appState.streamingPhase).toBe('waiting'); await vi.waitFor(() => { - expect(driver.state.appState.streamingPhase).toBe('idle'); + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + model: 'k2', + thinking: undefined, + permission: 'manual', + planMode: undefined, }); - expect(session.activateSkill).toHaveBeenCalledWith('review', 'current branch'); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); - it('searches persisted prompt history with Ctrl-R and restores the selected input', async () => { - process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); - const historyFile = getInputHistoryFile('/tmp/proj-a'); - await appendInputHistory(historyFile, 'older prompt'); - await appendInputHistory(historyFile, 'multi\nline prompt'); - const { driver, harness } = await makeDriver(); - harness.track.mockClear(); + it('lazily creates the session for session-requiring slash commands (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + expect(harness.createSession).not.toHaveBeenCalled(); - driver.state.editor.handleInput('\u0012'); + driver.handleUserInput('/compact'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + expect(session.compact).toHaveBeenCalledWith({ instruction: undefined }); }); - const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.handleInput('m'); - picker.handleInput('\u001B'); - - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); - expect(driver.state.editor.getText()).toBe('multi\nline prompt'); - expect(harness.track).toHaveBeenCalledWith('shortcut_history_search', undefined); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); - it('applies remapped prompt-history accept, cancel, and execute semantics', async () => { - process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); - const historyFile = getInputHistoryFile('/tmp/proj-a'); - await appendInputHistory(historyFile, 'older prompt'); - await appendInputHistory(historyFile, 'newer prompt'); - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - const bindings = parseKeybindingBlocks([ + it('lazily creates the session for skill commands (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy', activateSkill: vi.fn(async () => {}) }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, { - context: 'HistorySearch', - bindings: { - escape: null, - 'ctrl+c': null, - enter: null, - 'alt+n': 'historySearch:next', - 'alt+a': 'historySearch:accept', - 'alt+c': 'historySearch:cancel', - 'alt+e': 'historySearch:execute', - }, + listWorkspaceSkills: vi.fn(async () => [ + { + name: 'my-skill', + description: 'A test skill', + path: '/tmp/my-skill', + source: 'user', + }, + ]), + listPluginCommands: vi.fn(async () => []), }, - ]); + startupInput, + ); + // `makeDriver` stops after init(); the skill command list is refreshed in + // finishStartup, so resolve it here to exercise the workspace-level path. + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - driver.state.editor.setText('unchanged draft'); - await tui.showInputHistoryPicker(); - let picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.setKeybindings(bindings); - const hint = stripSgr(picker.render(120).join('\n')).split('\n')[2]; - expect(hint).toContain('alt+a'); - expect(hint).not.toContain('Esc'); - expect(hint).not.toContain('Enter'); - expect(hint).not.toContain('ctrl+c'); - picker.handleInput('\u001B'); - expect(driver.state.editorContainer.children[0]).toBe(picker); - picker.handleInput('\u001Bn'); - picker.handleInput('\u001Ba'); - expect(driver.state.editor.getText()).toBe('older prompt'); - - driver.state.editor.setText('unchanged draft'); - await tui.showInputHistoryPicker(); - picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.setKeybindings(bindings); - picker.handleInput('\u0003'); - expect(driver.state.editorContainer.children[0]).toBe(picker); - picker.handleInput('\u001Bc'); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); - expect(driver.state.editor.getText()).toBe('unchanged draft'); - - const handleUserInput = vi.spyOn(tui, 'handleUserInput'); - await tui.showInputHistoryPicker(); - picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.setKeybindings(bindings); - picker.handleInput('\r'); - expect(handleUserInput).not.toHaveBeenCalled(); - picker.handleInput('\u001Bn'); - picker.handleInput('\u001Be'); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); - expect(handleUserInput).toHaveBeenCalledWith('older prompt'); + // Startup resolves skill commands from the workspace, no session needed. + expect(harness.createSession).not.toHaveBeenCalled(); + + driver.handleUserInput('/skill:my-skill'); + + await vi.waitFor(() => { + expect(session.activateSkill).toHaveBeenCalledWith('my-skill', ''); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); - it('delivers valid keybinding reloads to the active replacement and retains the last valid set', async () => { - const homeDir = await makeTempHome(); - process.env['PYTHINKER_CODE_HOME'] = homeDir; - const { driver } = await makeDriver(makeSession(), { homeDir }); - const tui = driver as unknown as PythinkerTUI; - const panel = { - focused: false, - setKeybindings: vi.fn(), - handleInput: () => {}, - invalidate: () => {}, - render: () => [], + it('submits inline skill tokens with the prompt as one grouped submission (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, }; - tui.mountEditorReplacement(panel); - - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [{ context: 'Chat', bindings: { 'alt+j': 'command:second-command' } }], - }), - 'utf-8', + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, ); - tui.reloadKeybindings(); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - expect(panel.setKeybindings).toHaveBeenLastCalledWith( - expect.arrayContaining([ - expect.objectContaining({ context: 'Chat', action: 'command:second-command' }), - ]), + driver.handleUserInput('please /skill:review and /skill:security this change'); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + 'please /skill:review and /skill:security this change', + [{ name: 'review' }, { name: 'security' }], + ); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(session.activateSkill).not.toHaveBeenCalled(); + }); + + it('combines a leading skill command with later inline skills into one submission (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - const deliveries = panel.setKeybindings.mock.calls.length; - await writeFile(join(homeDir, 'keybindings.json'), '{', 'utf-8'); - tui.reloadKeybindings(); + driver.handleUserInput('/skill:review check this /skill:security'); - expect(panel.setKeybindings).toHaveBeenCalledTimes(deliveries); - tui.restoreEditor(); - const handleUserInput = vi.spyOn(tui, 'handleUserInput'); - driver.state.editor.handleInput('\u001Bj'); - expect(handleUserInput).toHaveBeenCalledWith('/second-command'); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:review check this /skill:security', + [{ name: 'review' }, { name: 'security' }], + ); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); - it('enters footer focus after configured history-next reaches the empty lower boundary', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - expect(tui.canFocusFooter()).toBe(true); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { down: 'history:next' } }, - ]); - driver.state.editor.addToHistory('previous prompt'); - driver.state.editor.handleInput('\u001B[A'); - expect(driver.state.editor.getText()).toBe('previous prompt'); - session.getGoal.mockClear(); + it('bundles a repeated leading skill as one bundled submission (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - dispatchTerminalInput(driver, '\u001B[B'); + driver.handleUserInput('/skill:review check /skill:review'); - expect(driver.state.editor.getText()).toBe(''); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, '\r'); - expect(driver.state.footer.selectedActionId()).toBeNull(); await vi.waitFor(() => { - expect(session.getGoal).toHaveBeenCalledOnce(); + expect(session.promptWithSkills).toHaveBeenCalledWith('/skill:review check /skill:review', [ + { name: 'review' }, + ]); }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); - it('enters footer focus through a Global history-next fallback before editor input', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Global', bindings: { j: 'history:next' } }, - ]); - driver.state.ui.setFocus(driver.state.editor); - const editorInput = vi.spyOn(driver.state.editor, 'handleInput'); + it('passes no args in a bundle while media rides the prompt parts (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); - dispatchTerminalInput(driver, 'j'); + driver.handleUserInput(`/skill:review inspect ${attachment.placeholder} /skill:security`); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - expect(driver.state.editor.getText()).toBe(''); - expect(editorInput).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + [ + { type: 'text', text: '/skill:review inspect ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'text', text: ' /skill:security' }, + ], + [{ name: 'review' }, { name: 'security' }], + ); + }); }); - it('consumes a null Global fallback at the empty history boundary', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Global', bindings: { x: null } }, - ]); - driver.state.ui.setFocus(driver.state.editor); - const editorInput = vi.spyOn(driver.state.editor, 'handleInput'); + it('bundles newline-separated skills with the leading one included (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - dispatchTerminalInput(driver, 'x'); + driver.handleUserInput('/skill:review\ncheck this /skill:security'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(driver.state.editor.getText()).toBe(''); - expect(editorInput).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/skill:review\ncheck this /skill:security', + [{ name: 'review' }, { name: 'security' }], + ); + }); + expect(session.prompt).not.toHaveBeenCalled(); }); - it('navigates selected footer focus through a Global action before editor input', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - driver.state.footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); - setTask7Keybindings(tui, [ - { context: 'Global', bindings: { j: 'footer:next' } }, - ]); - driver.state.footer.selectFirst(); - driver.state.ui.setFocus(driver.state.editor); - const editorInput = vi.spyOn(driver.state.editor, 'handleInput'); + it('scans inline skills in messages that start with an unknown slash token (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - dispatchTerminalInput(driver, 'j'); + driver.handleUserInput('/dance please use /skill:review'); - expect(driver.state.footer.selectedActionId()).toBe('shell-tasks'); - expect(driver.state.editor.getText()).toBe(''); - expect(editorInput).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith( + '/dance please use /skill:review', + [{ name: 'review' }], + ); + }); + expect(session.prompt).not.toHaveBeenCalled(); }); - it('consumes a null Global fallback while footer focus is selected', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Global', bindings: { x: null } }, - ]); - driver.state.footer.selectFirst(); - driver.state.ui.setFocus(driver.state.editor); - const editorInput = vi.spyOn(driver.state.editor, 'handleInput'); + it('keeps inline skill tokens as plain text on the legacy engine', async () => { + const session = makeSession({ id: 'ses-1' }); + const { driver } = await makeDriver(session, { + listSkills: undefined, + listPluginCommands: vi.fn(async () => []), + }); + ( + driver as unknown as { skillCommandMap: Map<string, string> } + ).skillCommandMap.set('skill:review', 'review'); - dispatchTerminalInput(driver, 'x'); + driver.handleUserInput('please /skill:review this'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - expect(driver.state.editor.getText()).toBe(''); - expect(editorInput).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('please /skill:review this', { promptId: undefined }); + }); + expect(session.promptWithSkills).not.toHaveBeenCalled(); }); - it('honors null, raw, and semantic footer remaps without stealing printable input', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - driver.state.footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - setTask7Keybindings(tui, [ - { - context: 'Chat', - bindings: { - down: null, - 'alt+n': 'history:next', - 'ctrl+k ctrl+n': 'history:next', - }, - }, + it('queues an inline-skill prompt while a goal is active (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, { - context: 'Footer', - bindings: { - down: null, - right: null, - escape: null, - 'alt+j': 'footer:next', - 'alt+x': 'footer:clearSelection', - 'ctrl+k ctrl+j': 'footer:next', - 'q x': 'chat:submit', - }, + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + // Materialize the lazy session first: an active goal only exists inside a + // live session, and lazy creation would refresh (and clear) the goal + // snapshot set up below. + await (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession(); + driver.state.appState.goal = makeActiveGoalSnapshot(); + + driver.handleUserInput('check /skill:review'); + + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + expect.objectContaining({ + text: 'check /skill:review', + inlineSkillActivations: [{ skillName: 'review' }], + }), ]); - driver.state.ui.setFocus(driver.state.editor); - - dispatchTerminalInput(driver, '\u001B[B'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - dispatchTerminalInput(driver, '\u001Bn'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, 'ctrl+k'); - dispatchTerminalInput(driver, 'ctrl+j'); - expect(driver.state.footer.selectedActionId()).toBe('shell-tasks'); - dispatchTerminalInput(driver, '\u001Bj'); - expect(driver.state.footer.selectedActionId()).toBe('agents'); - dispatchTerminalInput(driver, '\u001B'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - dispatchTerminalInput(driver, 'ctrl+k'); - dispatchTerminalInput(driver, 'ctrl+n'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, '\u001Bx'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - - dispatchTerminalInput(driver, 'ctrl+k'); - dispatchTerminalInput(driver, 'ctrl+n'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, 'q'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(driver.state.editor.getText()).toBe('q'); - }); - - it('prefers an effective printable Footer action over the Chat binding', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - driver.state.footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next', j: 'chat:submit' } }, - { context: 'Footer', bindings: { j: 'footer:next' } }, - ]); - - dispatchTerminalInput(driver, '\u001Bn'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, 'j'); - - expect(driver.state.footer.selectedActionId()).toBe('shell-tasks'); - expect(driver.state.editor.getText()).toBe(''); }); - it('passes an unshadowed printable Chat binding through after clearing footer focus', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next', j: 'chat:newline' } }, - ]); - driver.state.ui.setFocus(driver.state.editor); + it('queues a leading-combo bundle while busy instead of rejecting it (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession(); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + driver.state.appState.goal = makeActiveGoalSnapshot(); - dispatchTerminalInput(driver, '\u001Bn'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - dispatchTerminalInput(driver, 'j'); + driver.handleUserInput('/skill:review check this /skill:security'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(driver.state.editor.getText()).toBe('\n'); + expect(session.promptWithSkills).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + expect.objectContaining({ + text: '/skill:review check this /skill:security', + inlineSkillActivations: [{ skillName: 'review' }, { skillName: 'security' }], + }), + ]); }); - it('preserves normal multiline Down behavior when footer actions are available', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next' } }, - ]); - driver.state.ui.setFocus(driver.state.editor); - driver.state.editor.setText('first\nsecond'); - driver.state.editor.handleInput('\u001B[A'); - expect(driver.state.editor.getCursor()).toEqual({ line: 0, col: 5 }); + it('does not append a user entry when the grouped submission is rejected (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + promptWithSkills: vi.fn(async () => { + throw new Error('Skill "review" was not found'); + }), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - dispatchTerminalInput(driver, '\u001Bn'); + driver.handleUserInput('please /skill:review'); - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(driver.state.editor.getCursor()).toEqual({ line: 1, col: 6 }); + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(driver.state.appState.streamingPhase).toBe('idle'); + }); + // A rejected group leaves no local undo anchor the engine never recorded. + expect(driver.state.transcriptEntries.filter((entry) => entry.kind === 'user')).toHaveLength(0); }); - it('does not consume non-history Chat chord prefixes while footer focus is available', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'ctrl+k ctrl+x': 'chat:newline' } }, + it('renders a bundled replay submission as a single turn', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + (session.getResumeState as ReturnType<typeof vi.fn>).mockReturnValue({ + sessionMetadata: {}, + agents: { + main: { + config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' }, + plan: null, + permission: { mode: 'manual' }, + dynamicWorkflowMode: false, + context: { history: [], tokenCount: 0 }, + background: [], + toolStore: {}, + replay: [ + { + type: 'message', + time: 1, + message: { + role: 'user', + content: [{ type: 'text', text: 'earlier question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'message', + time: 2, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'earlier answer' }], + toolCalls: [], + }, + }, + { + type: 'message', + time: 3, + message: { + role: 'user', + content: [{ type: 'text', text: 'hook note' }], + toolCalls: [], + origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, + }, + }, + { + type: 'message', + time: 4, + message: { + role: 'user', + content: [ + { type: 'text', text: 'skill card A body' }, + { type: 'text', text: 'skill card B body' }, + { type: 'text', text: 'please /skill:review and /skill:security' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [ + { activationId: 'act-1', skillName: 'review' }, + { activationId: 'act-2', skillName: 'security' }, + ], + }, + }, + }, + { + type: 'message', + time: 5, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'bundled answer' }], + toolCalls: [], + }, + }, + { + type: 'message', + time: 6, + message: { + role: 'user', + content: [ + { type: 'text', text: 'skill card C body' }, + { type: 'text', text: 'please /commit' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [{ activationId: 'act-3', skillName: 'commit' }], + }, + }, + }, + ], + }, + }, + }); + + const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session); + expect(replayed).toBe(true); + + const turns = groupTurns(driver.state.transcriptEntries); + expect(turns).toHaveLength(3); + // The hook result is projected inside the bundle's window (after the + // skill cards, before the prompt), matching the live event order. + expect(turns[1]!.entries.map((entry) => entry.kind)).toEqual([ + 'skill_activation', + 'skill_activation', + 'assistant', + 'user', + 'assistant', ]); + expect(turns[1]!.entries[2]!.hookResult).toBe(true); + // The user entry shows only the caller's own text — the rendered skill + // blocks the engine prepended to the content are stripped. + expect(turns[1]!.entries[3]!.content).toBe('please /skill:review and /skill:security'); + expect( + turns[1]!.entries.slice(0, 2).map((entry) => entry.bundledWithPrompt), + ).toEqual([true, true]); + expect(turns[2]!.entries.map((entry) => entry.kind)).toEqual(['skill_activation', 'user']); + expect(turns[2]!.entries[1]!.content).toBe('please /commit'); + }); + + it('keeps hook results recorded before the oldest retained bundle within the replay limit', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + const plainTurn = (index: number) => [ + { + type: 'message', + time: index * 2, + message: { + role: 'user', + content: [{ type: 'text', text: `question ${index}` }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'message', + time: index * 2 + 1, + message: { + role: 'assistant', + content: [{ type: 'text', text: `answer ${index}` }], + toolCalls: [], + }, + }, + ]; + (session.getResumeState as ReturnType<typeof vi.fn>).mockReturnValue({ + sessionMetadata: {}, + agents: { + main: { + config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' }, + plan: null, + permission: { mode: 'manual' }, + dynamicWorkflowMode: false, + context: { history: [], tokenCount: 0 }, + background: [], + toolStore: {}, + replay: [ + ...plainTurn(0), + { + type: 'message', + time: 1, + message: { + role: 'user', + content: [{ type: 'text', text: 'hook note' }], + toolCalls: [], + origin: { kind: 'hook_result', event: 'UserPromptSubmit' }, + }, + }, + { + type: 'message', + time: 2, + message: { + role: 'user', + content: [ + { type: 'text', text: 'review body' }, + { type: 'text', text: 'bundled question' }, + ], + toolCalls: [], + origin: { + kind: 'user', + skillActivations: [{ activationId: 'act-1', skillName: 'review' }], + }, + }, + }, + { + type: 'message', + time: 3, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'bundled answer' }], + toolCalls: [], + }, + }, + ...Array.from({ length: 9 }, (_, i) => plainTurn(i + 10)).flat(), + ], + }, + }, + }); - const handleFooterInput = ( - tui.editorKeyboard as unknown as { - handleFooterInput(data: string): { consume: boolean } | undefined; - } - ).handleFooterInput.bind(tui.editorKeyboard); + const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session); + expect(replayed).toBe(true); - expect(handleFooterInput('\u000B')).toBeUndefined(); - expect(handleFooterInput('\u0018')).toBeUndefined(); - expect(driver.state.footer.selectedActionId()).toBeNull(); + const entries = driver.state.transcriptEntries; + const hookIndex = entries.findIndex((entry) => entry.hookResult === true); + expect(hookIndex).toBeGreaterThan(-1); + expect(entries[hookIndex]!.content).toContain('hook note'); + const contents = entries.map((entry) => entry.content); + expect(contents.indexOf('Activated skill: review')).toBeLessThan(hookIndex); + expect(contents.indexOf('bundled question')).toBeGreaterThan(hookIndex); + expect(contents).not.toContain('question 0'); }); - it('opens both task badges through the native tasks browser', async () => { + it('appends the user entry after the skill cards for a bundled submission (v2 engine)', async () => { const session = makeSession({ - listBackgroundTasks: vi.fn(async () => []), + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), }); - const { driver } = await makeDriver(session); - const tui = driver as unknown as PythinkerTUI; - driver.state.footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 1 }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next' } }, + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, { - context: 'Footer', - bindings: { - 'alt+j': 'footer:next', - 'alt+o': 'footer:openSelected', - }, + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), }, - ]); + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + + // Hold the RPC open so the skill.activated event can land mid-flight, + // exactly how the in-process wiring delivers it during the call. + let release!: () => void; + const heldPrompt = new Promise<void>((resolve) => { + release = resolve; + }); + (session.promptWithSkills as ReturnType<typeof vi.fn>).mockReturnValue(heldPrompt); + + driver.handleUserInput('please /skill:review'); - dispatchTerminalInput(driver, '\u001Bn'); - dispatchTerminalInput(driver, '\u001Bo'); await vi.waitFor(() => { - expect(driver.state.tasksBrowser).toBeDefined(); + expect(session.promptWithSkills).toHaveBeenCalled(); }); - tui.tasksBrowserController.close(); + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + sessionId: 'ses-lazy', + agentId: 'main', + activationId: 'act-1', + skillName: 'review', + trigger: 'user-slash', + } as Event, + () => {}, + ); + release(); - dispatchTerminalInput(driver, '\u001Bn'); - dispatchTerminalInput(driver, '\u001Bj'); - dispatchTerminalInput(driver, '\u001Bo'); await vi.waitFor(() => { - expect(driver.state.tasksBrowser).toBeDefined(); + expect(driver.state.transcriptEntries.map((entry) => entry.kind)).toEqual([ + 'skill_activation', + 'user', + ]); }); - expect(session.listBackgroundTasks).toHaveBeenCalledTimes(2); - tui.tasksBrowserController.close(); + expect(driver.state.transcriptEntries[0]!.bundledWithPrompt).toBe(true); }); - it.each([ - 'replacement dialog', - 'autocomplete', - 'compaction', - 'task browser', - 'BTW panel', - ])('does not enter footer focus while %s is active', async (surface) => { - const session = makeSession(); - const { driver } = await makeDriver(session); - const tui = driver as unknown as PythinkerTUI; - driver.state.footer.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next' } }, - ]); - let autocompleteActive = surface !== 'autocomplete'; - - if (surface === 'replacement dialog') { - tui.mountEditorReplacement({ - focused: false, - invalidate: () => {}, - render: () => [], - handleInput: () => {}, - }); - } else if (surface === 'autocomplete') { - driver.state.editor.setAutocompleteProvider(autocompleteProvider()); - driver.state.editor.handleInput('/'); - await flushAutocomplete(); - autocompleteActive = driver.state.editor.isShowingAutocomplete(); - } else if (surface === 'compaction') { - tui.setAppState({ isCompacting: true }); - } else if (surface === 'task browser') { - tui.setTasksBrowser({} as never); - } else { - await openBtwPanel(driver, session); - } - - expect(autocompleteActive).toBe(true); - dispatchTerminalInput(driver, '\u001Bn'); - - expect(driver.state.footer.selectedActionId()).toBeNull(); - }); + it('serializes concurrent lazy session creation (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); - it('does not enter footer focus while a generic UI overlay is active', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next' } }, - ]); - const overlay = driver.state.ui.showOverlay({ - invalidate: () => {}, - render: () => [], - }); - expect(driver.state.ui.hasOverlay()).toBe(true); + // Hold the first createSession open so both triggers land inside the + // in-flight window. + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); - dispatchTerminalInput(driver, '\u001Bn'); + const ensure = (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession; + const first = ensure.call(driver); + const second = ensure.call(driver); + resolveCreate(session); + await Promise.all([first, second]); - expect(driver.state.footer.selectedActionId()).toBeNull(); - overlay.hide(); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); - it('passes pending history chords to an overlay and clears stale footer focus', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'ctrl+k ctrl+n': 'history:next', 'alt+n': 'history:next' } }, - ]); - const received: string[] = []; - const overlay = driver.state.ui.showOverlay({ - invalidate: () => {}, - render: () => [], - handleInput: (data) => received.push(data), + it('waits out the in-flight lazy creation before /new (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const newSession = makeSession({ id: 'ses-new' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(lazySession, {}, startupInput); + + // Hold the lazy createSession open so it is still in flight when /new + // arrives (triggered directly, without a prompt starting a turn). + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession + .mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ) + .mockResolvedValueOnce(newSession); + const ensure = (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession; + const pending = ensure.call(driver); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); }); - dispatchTerminalInput(driver, 'ctrl+k'); - dispatchTerminalInput(driver, 'ctrl+n'); - overlay.hide(); - dispatchTerminalInput(driver, 'ctrl+n'); - - expect(received).toEqual(['ctrl+k', 'ctrl+n']); - expect(driver.state.footer.selectedActionId()).toBeNull(); - }); + driver.handleUserInput('/new'); + // /new must not race a second createSession while the lazy one is held. + await new Promise((resolve) => setImmediate(resolve)); + expect(harness.createSession).toHaveBeenCalledTimes(1); - it('releases footer input to an overlay that appears after selection', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - tui.setAppState({ goal: activeGoal() }); - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { 'alt+n': 'history:next' } }, - ]); - dispatchTerminalInput(driver, '\u001Bn'); - expect(driver.state.footer.selectedActionId()).toBe('goal'); - const received: string[] = []; - const overlay = driver.state.ui.showOverlay({ - invalidate: () => {}, - render: () => [], - handleInput: (data) => received.push(data), + resolveCreate(lazySession); + await pending; + // No turn started, so /new proceeds after the wait. + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(2); + expect(driver.getCurrentSessionId()).toBe('ses-new'); }); - - dispatchTerminalInput(driver, '\r'); - - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(received).toEqual(['\r']); - overlay.hide(); }); - it('keeps the editor active at the lower boundary when no footer action exists', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - setTask7Keybindings(tui, [ - { context: 'Chat', bindings: { down: 'history:next' } }, - ]); - - dispatchTerminalInput(driver, '\u001B[B'); - - expect(driver.state.footer.selectedActionId()).toBeNull(); - expect(driver.state.editor.getText()).toBe(''); - }); + it('blocks /new while the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(lazySession, {}, startupInput); - it('delivers remapped confirmation bindings to mounted permission prompts', async () => { - const homeDir = await makeTempHome(); - process.env['PYTHINKER_CODE_HOME'] = homeDir; - const { driver } = await makeDriver(makeSession(), { homeDir }); - const tui = driver as unknown as PythinkerTUI; - await writeFile( - join(homeDir, 'keybindings.json'), - JSON.stringify({ - bindings: [ - { - context: 'Confirmation', - bindings: { - y: null, - n: null, - enter: null, - escape: null, - up: null, - down: null, - 'alt+p': 'confirm:previous', - 'alt+n': 'confirm:next', - 'alt+y': 'confirm:yes', - 'alt+x': 'confirm:no', - }, - }, - ], - }), - 'utf-8', + // Hold the lazy createSession open so the first prompt is still pending + // when /new arrives. + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), ); - tui.reloadKeybindings(); - const choices: string[] = []; - const prompt = new StartPermissionPromptComponent({ - title: 'Choose permission mode', - noticeLines: [], - options: [ - { value: 'auto', label: 'Auto', description: 'Approve safe actions.' }, - { value: 'yolo', label: 'YOLO', description: 'Approve all actions.' }, - ], - onSelect: (choice) => choices.push(choice), - onCancel: () => choices.push('cancel'), - }); - tui.mountEditorReplacement(prompt); - - const hint = stripSgr(prompt.render(80).join('\n')); - expect(hint).toContain('alt+n navigate'); - expect(hint).toContain('alt+y select'); - expect(hint).toContain('alt+x cancel'); - prompt.handleInput('\u001Bn'); - prompt.handleInput('\u001By'); - expect(choices).toEqual(['yolo']); - - prompt.handleInput('\u001Bx'); - expect(choices).toEqual(['yolo', 'cancel']); - - const recovered: string[] = []; - const recoveryPrompt = new StartPermissionPromptComponent({ - title: 'Choose permission mode', - noticeLines: [], - options: [ - { value: 'auto', label: 'Auto', description: 'Approve safe actions.' }, - { value: 'yolo', label: 'YOLO', description: 'Approve all actions.' }, - ], - onSelect: (choice) => recovered.push(choice), - onCancel: () => recovered.push('cancel'), + driver.handleUserInput('hello'); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); }); - recoveryPrompt.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { n: null, escape: null } }, - ]), - ]); - recoveryPrompt.handleInput('\u001B'); - expect(recovered).toEqual(['cancel']); - - const semantic: string[] = []; - const semanticPrompt = new StartPermissionPromptComponent({ - title: 'Choose permission mode', - noticeLines: [], - options: [ - { value: 'auto', label: 'Auto', description: 'Approve safe actions.' }, - { value: 'yolo', label: 'YOLO', description: 'Approve all actions.' }, - ], - onSelect: (choice) => semantic.push(choice), - onCancel: () => semantic.push('cancel'), + driver.handleUserInput('/new'); + + resolveCreate(lazySession); + // The prompt continuation starts its turn first; /new (idle-only) must + // then be blocked instead of switching away from the active session. + await vi.waitFor(() => { + expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot /new while streaming'); }); - semanticPrompt.setKeybindings( - parseKeybindingBlocks([ - { - context: 'Confirmation', - bindings: { - 'ctrl+k ctrl+n': 'confirm:next', - 'ctrl+k ctrl+y': 'confirm:yes', - 'ctrl+k ctrl+x': 'confirm:no', - }, - }, - ]), - ); - semanticPrompt.handleInput('ctrl+k'); - semanticPrompt.handleInput('ctrl+n'); - semanticPrompt.handleInput('ctrl+k'); - semanticPrompt.handleInput('ctrl+y'); - semanticPrompt.handleInput('ctrl+k'); - semanticPrompt.handleInput('ctrl+x'); - expect(semantic).toEqual(['yolo', 'cancel']); + expect(harness.createSession).toHaveBeenCalledTimes(1); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); - it('tracks editor shortcut and paste hooks', async () => { - const { driver, harness } = await makeDriver(); - harness.track.mockClear(); - - driver.state.editor.handleInput('\u001B[106;5u'); - driver.state.editor.handleInput('\u001F'); - delete process.env['VISUAL']; - delete process.env['EDITOR']; - driver.state.editor.onOpenExternalEditor?.(); - driver.state.editor.onToggleToolExpand?.(); - driver.state.editor.onTextPaste?.(); - - expect(harness.track).toHaveBeenCalledWith('shortcut_newline', undefined); - expect(harness.track).toHaveBeenCalledWith('undo', undefined); - expect(harness.track).toHaveBeenCalledWith('shortcut_editor', undefined); - expect(harness.track).toHaveBeenCalledWith('shortcut_expand', undefined); - expect(harness.track).toHaveBeenCalledWith('shortcut_paste', { kind: 'text' }); + const thinkingModelsConfig = () => ({ + models: { + k2: { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, }); - it('tracks /clear as the clear alias for /new', async () => { - const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); - const nextSession = makeSession({ id: 'ses-2' }); - harness.createSession.mockResolvedValueOnce(nextSession); - harness.track.mockClear(); + it('blocks an effort switch once the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { getConfig: vi.fn(async () => thinkingModelsConfig()) }, + startupInput, + ); - driver.handleUserInput('/clear'); + // Hold the lazy createSession open so the first prompt is still pending + // when the effort switch arrives. + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + driver.handleUserInput('hello'); await vi.waitFor(() => { - expect(driver.getCurrentSessionId()).toBe('ses-2'); + expect(harness.createSession).toHaveBeenCalledTimes(1); }); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'new' }); - expect(harness.track).toHaveBeenCalledWith('clear', undefined); - }); - - it('tracks theme changes from slash commands', async () => { - process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); - const { driver, harness } = await makeDriver(); - harness.track.mockClear(); - - driver.handleUserInput('/theme light'); + driver.handleUserInput('/effort low'); + resolveCreate(lazySession); + // The prompt starts its turn first; the switch must then be rejected + // instead of being silently overwritten by the session assembly. await vi.waitFor(() => { - expect(driver.state.appState.theme).toBe('light'); + expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch models while streaming'); }); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'theme' }); - expect(harness.track).toHaveBeenCalledWith('theme_switch', { theme: 'light' }); + expect(lazySession.setThinking).not.toHaveBeenCalled(); }); - it('dispatches /reload-tui without reloading the active session', async () => { - const homeDir = await makeTempHome(); - process.env['PYTHINKER_CODE_HOME'] = homeDir; - await writeFile( - join(homeDir, 'tui.toml'), - ` -theme = "light" + it('applies an effort switch after waiting out an in-flight lazy creation (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { + getConfig: vi.fn(async () => thinkingModelsConfig()), + setConfig: vi.fn(async () => ({ providers: {} })), + }, + startupInput, + ); -[editor] -command = "vim" -`, - 'utf-8', + // Trigger the lazy creation directly, without a prompt starting a turn. + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), ); - const { driver, session, harness } = await makeDriver(); - harness.track.mockClear(); - session.reloadSession.mockClear(); + const ensure = (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession; + const pending = ensure.call(driver); + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); - driver.handleUserInput('/reload-tui'); + driver.handleUserInput('/effort low'); + // While the creation is held the switch must wait, not write pending + // state that the assembly would overwrite. + await new Promise((resolve) => setImmediate(resolve)); + expect(driver.state.appState.thinkingEffort).toBe('high'); + resolveCreate(lazySession); + await pending; await vi.waitFor(() => { - expect(driver.state.appState.theme).toBe('light'); + expect(lazySession.setThinking).toHaveBeenCalledWith('low'); }); - expect(driver.state.appState.editorCommand).toBe('vim'); - expect(session.reloadSession).not.toHaveBeenCalled(); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload-tui' }); }); - it('dispatches /reload through session reload and applies tui.toml', async () => { - const homeDir = await makeTempHome(); - process.env['PYTHINKER_CODE_HOME'] = homeDir; - await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); - const { driver, session, harness } = await makeDriver(); - harness.track.mockClear(); - session.reloadSession.mockClear(); - driver.handleUserInput('hello before reload'); - driver.state.appState.streamingPhase = 'idle'; + it('blocks a session-picker switch once the waited-out first prompt starts a turn (v2 engine)', async () => { + const lazySession = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + lazySession, + { + listSessions: vi.fn(async () => [ + { id: 'ses-old', title: 'Old session', workDir: '/tmp/proj-a', updatedAt: Date.now() }, + ]), + }, + startupInput, + ); - driver.handleUserInput('/reload'); + // Hold the lazy createSession open so the first prompt is still pending + // when the picker selection arrives. + let resolveCreate!: (s: ReturnType<typeof makeSession>) => void; + harness.createSession.mockImplementationOnce( + () => new Promise((resolve) => { resolveCreate = resolve; }), + ); + driver.handleUserInput('hello'); await vi.waitFor(() => { - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(harness.createSession).toHaveBeenCalledTimes(1); }); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('\r'); + + resolveCreate(lazySession); + // The prompt starts its turn first; the switch must then be rejected + // instead of being overwritten when the lazy creation completes. await vi.waitFor(() => { - expect(driver.state.appState.theme).toBe('light'); + expect(lazySession.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + expect(stripSgr(renderTranscript(driver))).toContain('Cannot switch sessions while streaming'); }); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload' }); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('hello before reload'); - expect(transcript).toContain('Session reloaded.'); + expect(harness.resumeSession).not.toHaveBeenCalled(); + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); }); + it('carries a session-only thinking choice into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + // Alt+S session-only thinking before any session exists. + await ( + driver as unknown as { + authFlow: { activateModelAfterLogin(model: string, effort?: string): Promise<void> }; + } + ).authFlow.activateModelAfterLogin('k2', 'high'); - it('does not track feedback when the dialog is cancelled', async () => { + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + expect(driver.state.appState.lazySessionThinking).toBeUndefined(); + }); + + it('does not pass the config default plan mode into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; const { driver, harness } = await makeDriver( - makeSession(), + session, { getConfig: vi.fn(async () => ({ - models: { - k2: { - model: 'pythoughts-v1', - maxContextSize: 100, - provider: 'managed:kimi-code', - }, - }, + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, })), }, + startupInput, ); - const feedbackDriver = driver as unknown as FeedbackDriver; - vi.mocked(promptFeedbackInput).mockImplementation(async () => undefined); - harness.track.mockClear(); - await handleFeedbackCommand(feedbackDriver as any); + // The footer shows the config default… + expect(driver.state.appState.planMode).toBe(true); - expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); - expect(harness.track).not.toHaveBeenCalledWith('feedback_submitted', undefined); + // …but the create call must not repeat it: the v2 engine applies + // defaultPlanMode at create time, and re-entering plan mode throws. + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: undefined }), + ); }); - it('tracks blocked slash commands as invalid without counting them as executed commands', async () => { - const { driver, harness } = await makeDriver(); - driver.state.appState.streamingPhase = 'waiting'; + it('passes the explicit --plan flag into the lazy-created session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2', plan: true }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); - for (const command of ['/new', '/sessions']) { - harness.track.mockClear(); + driver.handleUserInput('hello'); - driver.handleUserInput(command); - await Promise.resolve(); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: true }), + ); + }); - expect(harness.track).toHaveBeenCalledWith('input_command_invalid', { - reason: 'blocked', - command: command.slice(1), - }); - expect(harness.track).not.toHaveBeenCalledWith('input_command', { - command: command.slice(1), - }); - } + it('queues a bash command submitted while the lazy session is being created (v2 engine)', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ id: 'ses-lazy', runShellCommand }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + + // A prompt and a bash command both trigger the same in-flight creation. + driver.handleUserInput('hello'); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + driver.handleUserInput('ls'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + // The shell command must be queued, not run concurrently with the prompt. + expect(runShellCommand).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); }); - it('does not re-enter plan mode after creating a plan-mode session', async () => { + it('opens /settings without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: /settings must still open so the user can fix + // local editor/theme/update settings before picking a model. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/settings'); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('blocks a skill command submitted while the lazy session is being created (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy', activateSkill: vi.fn(async () => {}) }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { + name: 'my-skill', + description: 'A test skill', + path: '/tmp/my-skill', + source: 'user', + }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + + // A prompt and a skill command both trigger the same in-flight creation. + driver.handleUserInput('hello'); + driver.handleUserInput('/skill:my-skill'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + }); + // The skill activation must be blocked, not run concurrently with the + // prompt's turn. + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('manages plugins without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listPlugins = vi.fn(async () => []); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: /plugins must still work via the app-global API. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, { listPlugins }, startupInput); + + driver.handleUserInput('/plugins list'); + + await vi.waitFor(() => { + expect(listPlugins).toHaveBeenCalled(); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('lists additional directories without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: the read-only form must still work. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/add-dir list'); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('lazily creates the session when adding a directory (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/add-dir /tmp/extra'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-lazy'); + }); + expect(harness.createSession).toHaveBeenCalledTimes(1); + }); + + it('shows pending startup directories in /add-dir list before the lazy session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + additionalDirs: ['/tmp/extra'], + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + const showStatus = vi.spyOn( + driver as unknown as { showStatus: (msg: string) => void }, + 'showStatus', + ); + + driver.handleUserInput('/add-dir list'); + + await vi.waitFor(() => { + expect(showStatus).toHaveBeenCalledWith(expect.stringContaining('/tmp/extra')); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('refreshes plugin slash commands after a sessionless /plugins reload (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listPluginCommands = vi.fn(async () => [ + { + pluginId: 'my-plugin', + name: 'my-command', + body: 'do things', + description: 'A plugin command', + }, + ]); + const reloadPlugins = vi.fn(async () => ({ added: [], removed: [], errors: [] })); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver( + session, + { listPluginCommands, reloadPlugins }, + startupInput, + ); + + driver.handleUserInput('/plugins reload'); + + await vi.waitFor(() => { + expect(reloadPlugins).toHaveBeenCalled(); + expect(listPluginCommands).toHaveBeenCalled(); + expect(driver.pluginCommandMap.get('my-plugin:my-command')).toBe('do things'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('hydrates lazy config defaults on a sessionless /reload (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['PYTHINKER_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ models: Record<string, unknown>; defaultModel?: string }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + // Initially no default model configured. + }), + ); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.model).toBe(''); + + // A default model is added externally, then /reload runs before the first + // prompt — the lazy defaults must be refreshed, not left stale. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + }); + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(driver.state.appState.model).toBe('k2'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + }); + + it('clears stale lazy defaults when the default model is removed (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['PYTHINKER_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ models: Record<string, unknown>; defaultModel?: string }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + }), + ); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.model).toBe('k2'); + expect(driver.state.appState.maxContextTokens).toBe(100); + + // The default model is removed externally, then /reload runs — the + // hydrated value must not survive as a stale explicit model. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + }); + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(driver.state.appState.model).toBe(''); + }); + expect(driver.state.appState.maxContextTokens).toBe(0); + }); + + it('does not re-enter plan mode on /plan on when config already applied it (v2 engine)', async () => { const session = makeSession({ + id: 'ses-lazy', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 0, maxContextTokens: 100, contextUsage: 0, })), - setPlanMode: vi.fn(async () => { - throw new Error('Already in plan mode'); - }), }); - const { driver, harness } = await makeDriver(session); - harness.createSession.mockClear(); - session.setPlanMode.mockClear(); - driver.state.appState.planMode = true; + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, + })), + }, + startupInput, + ); - driver.handleUserInput('/new'); + driver.handleUserInput('/plan on'); await vi.waitFor(() => { - expect(harness.createSession).toHaveBeenCalledWith({ - workDir: '/tmp/proj-a', - model: 'k2', - thinking: 'off', - permission: 'manual', - planMode: true, - }); + expect(harness.createSession).toHaveBeenCalledTimes(1); }); + // The engine already applied defaultPlanMode at create; the command must + // notice the active plan mode instead of re-entering (which would throw). expect(session.setPlanMode).not.toHaveBeenCalled(); - expect(stripSgr(renderTranscript(driver))).not.toContain('Post-create setup failed'); + expect(driver.state.appState.planMode).toBe(true); }); - it('keeps the new session subscribed when post-create setup fails', async () => { - const initialSession = makeSession({ id: 'ses-initial' }); - const failedSession = makeSession({ - id: 'ses-failed', - setPermission: vi.fn(async () => { - throw new Error('permission setup failed'); + it('clears the stale permission default when it is removed from config (v2 engine)', async () => { + const homeDir = await makeTempHome(); + process.env['PYTHINKER_CODE_HOME'] = homeDir; + const session = makeSession({ id: 'ses-lazy' }); + const getConfig = vi.fn( + async (): Promise<{ + models: Record<string, unknown>; + defaultModel?: string; + defaultPermissionMode?: string; + }> => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', }), + ); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver } = await makeDriver(session, { getConfig }, startupInput); + expect(driver.state.appState.permissionMode).toBe('auto'); + + // The elevated default is removed externally, then /reload runs — a stale + // elevated mode must not reach the first lazy-created session. + getConfig.mockResolvedValue({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', }); - const createSession = vi - .fn() - .mockResolvedValueOnce(initialSession) - .mockResolvedValueOnce(failedSession); - const { driver } = await makeDriver(initialSession, { createSession }); - vi.mocked(failedSession.onEvent).mockClear(); + driver.handleUserInput('/reload'); - driver.handleUserInput('/new'); + await vi.waitFor(() => { + expect(driver.state.appState.permissionMode).toBe('manual'); + }); + }); + + it('does not pass --plan when config already applies default plan mode (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + // The engine applied the config default at create. + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2', plan: true }, + }; + const { driver, harness } = await makeDriver( + session, + { + getConfig: vi.fn(async () => ({ + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPlanMode: true, + })), + }, + startupInput, + ); + + driver.handleUserInput('hello'); await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Post-create setup failed: permission setup failed', - ); + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); }); - expect(failedSession.onEvent).toHaveBeenCalledOnce(); + // The engine applies the config default at create; repeating --plan would + // re-enter plan mode and throw, so it must not be passed again. + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ planMode: undefined }), + ); + expect(driver.state.appState.planMode).toBe(true); }); - it('routes /yolo through session permission state without app-layer telemetry duplication', async () => { - const { driver, session, harness } = await makeDriver(); - harness.track.mockClear(); + it('opens read-only status commands without creating a session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + // No model configured: read-only views must still open. + cliOptions: { ...makeStartupInput().cliOptions }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + driver.handleUserInput('/status'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Status'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.sessionId).toBe(''); + }); + + it('applies /yolo on session-less and passes the mode to the lazy session (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); driver.handleUserInput('/yolo on'); await vi.waitFor(() => { - expect(session.setPermission).toHaveBeenCalledWith('yolo'); + expect(driver.state.appState.permissionMode).toBe('yolo'); }); - expect(driver.state.appState).toMatchObject({ - permissionMode: 'yolo', + expect(harness.createSession).not.toHaveBeenCalled(); + expect(session.setPermission).not.toHaveBeenCalled(); + + driver.handleUserInput('hello'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); }); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'yolo' }); - expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything()); + expect(harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ permission: 'yolo' }), + ); }); - it('hydrates MCP server status after subscribing to session events', async () => { - const session = makeSession({ - listMcpServers: vi.fn(async () => [ - { - name: 'local-tools', - transport: 'stdio', - status: 'connected', - toolCount: 2, - }, - { - name: 'remote-tools', - transport: 'http', - status: 'failed', - toolCount: 0, - error: 'connection refused', - }, - ]), + it('waits for lazy session assembly before dispatching further input (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + + // Hold the post-create assembly open inside setPermission: the session is + // assigned but setup is not finished yet. + let resolvePermission!: () => void; + session.setPermission.mockImplementationOnce( + () => new Promise<void>((resolve) => { resolvePermission = resolve; }), + ); + + const ensure = (driver as unknown as { ensureSession(): Promise<unknown> }).ensureSession; + const first = ensure.call(driver); + await vi.waitFor(() => { + expect(session.setPermission).toHaveBeenCalled(); }); - const { driver } = await makeDriver(session); - enableMcpStatusAnimationForTest(); - driver.sessionEventHandler.startSubscription(); + // A second trigger must wait for the assembly instead of dispatching + // against the half-initialized session. + const second = ensure.call(driver); + let secondResolved = false; + void second.then(() => { + secondResolved = true; + }); await Promise.resolve(); + expect(secondResolved).toBe(false); - expect(session.onEvent).toHaveBeenCalledOnce(); - expect(session.listMcpServers).toHaveBeenCalledOnce(); - const subscribeOrder = session.onEvent.mock.invocationCallOrder[0]; - const snapshotOrder = session.listMcpServers.mock.invocationCallOrder[0]; - if (subscribeOrder === undefined || snapshotOrder === undefined) { - throw new Error('Expected MCP status sync to subscribe and fetch a snapshot.'); - } - expect(subscribeOrder).toBeLessThan(snapshotOrder); - const status = stripSgr(renderMcpStatus(driver)); - const transcript = stripSgr(renderTranscript(driver)); - expect(status).toContain( - '✗ MCP servers · 1/2 connected · 1 failed · /mcp for details', - ); - expect(countOccurrences(status, 'MCP servers')).toBe(1); - expect(status).not.toContain('local-tools'); - expect(status).not.toContain('remote-tools'); - expect(transcript).not.toContain('MCP servers'); + resolvePermission(); + await Promise.all([first, second]); + expect(secondResolved).toBe(true); + expect(harness.createSession).toHaveBeenCalledTimes(1); }); - it('deduplicates identical MCP status updates while allowing reconnect transitions', async () => { - const eventListeners: Array<(event: Event) => void> = []; - const connectedServer = { - name: 'local-tools', - transport: 'stdio', - status: 'connected', - toolCount: 2, + it('lists MCP servers before the lazy session via the workspace view (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const listWorkspaceMcpServers = vi.fn(async () => [ + { name: 'my-mcp', status: 'connected', transport: 'stdio', tools: [] }, + ]); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions }, }; + const { driver, harness } = await makeDriver( + session, + { listWorkspaceMcpServers }, + startupInput, + ); + + driver.handleUserInput('/mcp'); + + await vi.waitFor(() => { + expect(listWorkspaceMcpServers).toHaveBeenCalledWith('/tmp/proj-a'); + }); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(session.listMcpServers).not.toHaveBeenCalled(); + }); + + it('tracks /clear as the clear alias for /new', async () => { + const { driver, harness } = await makeDriver(makeSession({ id: 'ses-1' })); + const nextSession = makeSession({ id: 'ses-2' }); + harness.createSession.mockResolvedValueOnce(nextSession); + harness.track.mockClear(); + + driver.handleUserInput('/clear'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-2'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'new' }); + expect(harness.track).toHaveBeenCalledWith('clear', undefined); + }); + + it('tracks theme changes from slash commands', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const { driver, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.handleUserInput('/theme light'); + + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'theme' }); + expect(harness.track).toHaveBeenCalledWith('theme_switch', { theme: 'light' }); + }); + + it('dispatches /reload-tui without reloading the active session', async () => { + const homeDir = await makeTempHome(); + process.env['PYTHINKER_CODE_HOME'] = homeDir; + await writeFile( + join(homeDir, 'tui.toml'), + ` +theme = "light" + +[editor] +command = "vim" +`, + 'utf-8', + ); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + + driver.handleUserInput('/reload-tui'); + + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(driver.state.appState.editorCommand).toBe('vim'); + expect(session.reloadSession).not.toHaveBeenCalled(); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload-tui' }); + }); + + it('dispatches /reload through session reload and applies tui.toml', async () => { + const homeDir = await makeTempHome(); + process.env['PYTHINKER_CODE_HOME'] = homeDir; + await writeFile(join(homeDir, 'tui.toml'), 'theme = "light"\n', 'utf-8'); + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + session.reloadSession.mockClear(); + driver.handleUserInput('hello before reload'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/reload'); + + await vi.waitFor(() => { + expect(session.reloadSession).toHaveBeenCalledOnce(); + }); + await vi.waitFor(() => { + expect(driver.state.appState.theme).toBe('light'); + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload' }); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello before reload'); + expect(transcript).toContain('Session reloaded.'); + }); + + it('prints the sign-up page and GitHub Issues links when not signed in', async () => { + const { driver, harness } = await makeDriver(makeSession()); + harness.auth.status.mockResolvedValueOnce({ + providers: [{ providerName: 'managed:pythinker-code', hasToken: false }], + }); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).not.toHaveBeenCalled(); + expect(promptFeedbackInput).not.toHaveBeenCalled(); + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain("You're not signed in"); + expect(transcript).toContain('https://www.kimi.com/code'); + expect(transcript).toContain('https://github.com/PyModel/pythinker-code/issues'); + }); + + it('falls back to GitHub Issues when the sign-in status cannot be read', async () => { + const { driver, harness } = await makeDriver(makeSession()); + harness.auth.status.mockRejectedValueOnce(new Error('token storage unavailable')); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockClear(); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).toHaveBeenCalledTimes(1); + expect(openUrl).toHaveBeenCalledWith('https://github.com/PyModel/pythinker-code/issues'); + expect(promptFeedbackInput).not.toHaveBeenCalled(); + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Opening GitHub Issues as fallback'); + }); + + it('submits feedback via OAuth for a signed-in user on an API-key model', async () => { + const { driver, harness } = await makeDriver(makeSession()); + driver.state.appState.availableModels = { + k2: { + provider: 'openai', + model: 'gpt-x', + maxContextSize: 100, + displayName: 'GPT X', + capabilities: [], + }, + }; + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 7 }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 7'); + }); + + it('tracks successful feedback submissions only after the request succeeds', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.track.mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'useful feedback', + sessionId: 'ses-1', + version: 'pythinker-code-0.0.0-test', + model: 'k2', + }), + ); + expect(harness.track).toHaveBeenCalledWith('feedback_submitted', undefined); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + }); + + it('submits text feedback before preparing requested attachments', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + + const zipPath = await makeExportedSessionZip(); + let resolveExport!: () => void; + const exportBlocked = new Promise<{ + zipPath: string; + entries: string[]; + sessionDir: string; + manifest: Record<string, never>; + }>((resolve) => { + resolveExport = () => { + resolve({ + zipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + }; + }); + harness.exportSession.mockImplementationOnce(() => exportBlocked); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'ses-1', + includeGlobalLog: true, + version: '0.0.0-test', + }), + ); + }); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.objectContaining({ content: 'useful feedback' }), + ); + expect(harness.auth.submitFeedback.mock.invocationCallOrder[0]).toBeLessThan( + harness.exportSession.mock.invocationCallOrder[0]!, + ); + expect(settled).toBe(false); + + resolveExport(); + await command; + }); + + it('waits for the codebase upload to finish before returning', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([ + { id: 'ses-1', sessionDir: '/tmp/session-a' }, + ] as never); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + const sessionZipPath = await makeExportedSessionZip(); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + + let resolveCodebaseUpload!: () => void; + const codebaseUploadBlocked = new Promise<void>((resolve) => { + resolveCodebaseUpload = resolve; + }); + vi.mocked(uploadArchive).mockImplementation((_api, archive) => { + if (archive.path === sessionZipPath) return Promise.resolve(); + return codebaseUploadBlocked; + }); + + let settled = false; + const command = handleFeedbackCommand(feedbackDriver as any).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(uploadArchive).toHaveBeenCalledTimes(2); + }); + expect(settled).toBe(false); + + resolveCodebaseUpload(); + await command; + expect(settled).toBe(true); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: '/tmp/fake-codebase.zip' }), + 3, + { filename: 'repo.zip' }, + ); + expect(harness.auth.submitFeedback).toHaveBeenCalledWith( + expect.not.objectContaining({ info: expect.anything() }), + ); + }); + + it('uploads session logs when codebase scanning fails but the session directory is available', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(packageCodebase).mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + vi.mocked(scanCodebase).mockRejectedValueOnce(new Error('scan failed')); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.exportSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ses-1', includeGlobalLog: true }), + ); + expect(packageCodebase).not.toHaveBeenCalled(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('keeps archive-path creation failures as partial failures without the GitHub fallback', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + vi.mocked(createFeedbackArchivePath).mockRejectedValueOnce(new Error('cache dir not writable')); + vi.mocked(openUrl).mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(openUrl).not.toHaveBeenCalled(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback submitted, thank you!'); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when feedback is sent but codebase packaging fails', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(scanCodebase).mockReset(); + vi.mocked(packageCodebase).mockReset(); + harness.exportSession.mockReset(); + vi.mocked(uploadArchive).mockReset(); + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + harness.listSessions.mockResolvedValueOnce([{ id: 'ses-1', sessionDir: '/tmp/session-a' }] as never); + const sessionZipPath = await makeExportedSessionZip(); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + harness.exportSession.mockResolvedValueOnce({ + zipPath: sessionZipPath, + entries: ['manifest.json', 'state.json'], + sessionDir: '/tmp/session-a', + manifest: {}, + }); + vi.mocked(packageCodebase).mockRejectedValueOnce(new Error('zip failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + const calls = harness.auth.submitFeedback.mock.calls as unknown as Array<[Record<string, unknown>]>; + expect(calls[0]?.[0]?.['info']).toBeUndefined(); + expect(uploadArchive).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ path: sessionZipPath }), + 3, + { filename: 'session.zip' }, + ); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('tells the user when the codebase upload fails', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'logs+codebase'); + harness.auth.submitFeedback.mockResolvedValueOnce({ kind: 'ok', feedbackId: 3 }); + + vi.mocked(scanCodebase).mockResolvedValueOnce({ + root: '/tmp/proj-a', + files: [{ path: 'keep.ts', size: 4 }], + fingerprint: 'fp-123', + usedGitIgnore: false, + } as any); + vi.mocked(packageCodebase).mockResolvedValueOnce({ + path: '/tmp/fake-codebase.zip', + size: 4, + sha256: 'hash-123', + fingerprint: 'fp-123', + fileCount: 1, + }); + vi.mocked(uploadArchive).mockRejectedValueOnce(new Error('upload failed')); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).toHaveBeenCalledOnce(); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Feedback ID: 3'); + expect(transcript).toContain('attachment upload failed'); + }); + + it('shows feedback API error messages without replacing them with HTTP status text', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockResolvedValueOnce({ + kind: 'error', + status: 500, + message: 'backend says no', + }); + + await handleFeedbackCommand(feedbackDriver as any); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('backend says no'); + expect(transcript).toContain('Opening GitHub Issues as fallback'); + expect(transcript).not.toContain('Failed to submit feedback (HTTP 500).'); + }); + + it('falls back to GitHub Issues when the submission request rejects', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => ({ value: 'useful feedback' })); + vi.mocked(promptFeedbackAttachment).mockImplementation(async () => 'none'); + harness.auth.submitFeedback.mockRejectedValueOnce(new Error('socket hangup')); + vi.mocked(openUrl).mockClear(); + + await expect(handleFeedbackCommand(feedbackDriver as any)).rejects.toThrow('socket hangup'); + + expect(openUrl).toHaveBeenCalledWith('https://github.com/PyModel/pythinker-code/issues'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Opening GitHub Issues as fallback'); + }); + + it('does not track feedback when the dialog is cancelled', async () => { + const { driver, harness } = await makeDriver(makeSession()); + const feedbackDriver = driver as unknown as FeedbackDriver; + vi.mocked(promptFeedbackInput).mockImplementation(async () => undefined); + harness.track.mockClear(); + + await handleFeedbackCommand(feedbackDriver as any); + + expect(harness.auth.submitFeedback).not.toHaveBeenCalled(); + expect(harness.track).not.toHaveBeenCalledWith('feedback_submitted', undefined); + }); + + it('tracks blocked slash commands as invalid without counting them as executed commands', async () => { + const { driver, harness } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + + for (const command of ['/new', '/sessions']) { + harness.track.mockClear(); + + driver.handleUserInput(command); + await Promise.resolve(); + + expect(harness.track).toHaveBeenCalledWith('input_command_invalid', { + reason: 'blocked', + command: command.slice(1), + }); + expect(harness.track).not.toHaveBeenCalledWith('input_command', { + command: command.slice(1), + }); + } + }); + + it('does not re-enter plan mode after creating a plan-mode session', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'manual', + planMode: true, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setPlanMode: vi.fn(async () => { + throw new Error('Already in plan mode'); + }), + }); + const { driver, harness } = await makeDriver(session); + harness.createSession.mockClear(); + session.setPlanMode.mockClear(); + driver.state.appState.planMode = true; + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'manual', + planMode: true, + }); + }); + expect(session.setPlanMode).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).not.toContain('Post-create setup failed'); + }); + + it('keeps the new session subscribed when post-create setup fails', async () => { + const initialSession = makeSession({ id: 'ses-initial' }); + const failedSession = makeSession({ + id: 'ses-failed', + setPermission: vi.fn(async () => { + throw new Error('permission setup failed'); + }), + }); + const createSession = vi + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValueOnce(failedSession); + const { driver } = await makeDriver(initialSession, { createSession }); + vi.mocked(failedSession.onEvent).mockClear(); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Post-create setup failed: permission setup failed', + ); + }); + expect(failedSession.onEvent).toHaveBeenCalledOnce(); + }); + + it('tracks Shift-Tab mode switches through the editor handler', async () => { + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.state.editor.onShiftTab?.(); + + await vi.waitFor(() => { + expect(session.setPlanMode).toHaveBeenCalledWith(true); + }); + expect(harness.track).toHaveBeenCalledWith('shortcut_plan_toggle', { enabled: true }); + expect(harness.track).toHaveBeenCalledWith('shortcut_mode_switch', { to_mode: 'plan' }); + }); + + it('routes /yolo through session permission state without app-layer telemetry duplication', async () => { + const { driver, session, harness } = await makeDriver(); + harness.track.mockClear(); + + driver.handleUserInput('/yolo on'); + + await vi.waitFor(() => { + expect(session.setPermission).toHaveBeenCalledWith('yolo'); + }); + expect(driver.state.appState).toMatchObject({ + permissionMode: 'yolo', + }); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'yolo' }); + expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything()); + }); + + it('hydrates MCP server status after subscribing to session events', async () => { + const session = makeSession({ + listMcpServers: vi.fn(async () => [ + { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }, + { + name: 'remote-tools', + transport: 'http', + status: 'failed', + toolCount: 0, + error: 'connection refused', + }, + ]), + }); + const { driver } = await makeDriver(session); + + driver.sessionEventHandler.startSubscription(); + await Promise.resolve(); + + expect(session.onEvent).toHaveBeenCalledOnce(); + expect(session.listMcpServers).toHaveBeenCalledOnce(); + const subscribeOrder = session.onEvent.mock.invocationCallOrder[0]; + const snapshotOrder = session.listMcpServers.mock.invocationCallOrder[0]; + if (subscribeOrder === undefined || snapshotOrder === undefined) { + throw new Error('Expected MCP status sync to subscribe and fetch a snapshot.'); + } + expect(subscribeOrder).toBeLessThan(snapshotOrder); + const transcript = renderTranscript(driver); + expect(transcript).toContain('MCP server "local-tools" connected'); + expect(transcript).toContain('2 tools (stdio)'); + expect(transcript).toContain('MCP server "remote-tools" failed: connection refused'); + }); + + it('deduplicates identical MCP status updates while allowing reconnect transitions', async () => { + const eventListeners: Array<(event: Event) => void> = []; + const connectedServer = { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }; + const session = makeSession({ + onEvent: vi.fn((listener: (event: Event) => void) => { + eventListeners.push(listener); + return vi.fn(); + }), + listMcpServers: vi.fn(async () => [connectedServer]), + }); + const { driver } = await makeDriver(session); + + driver.sessionEventHandler.startSubscription(); + await Promise.resolve(); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: connectedServer, + } as Event); + + expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( + 1, + ); + + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: { + ...connectedServer, + status: 'pending', + toolCount: 0, + }, + } as Event); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: connectedServer, + } as Event); + + expect(countOccurrences(renderTranscript(driver), 'MCP server "local-tools" connected')).toBe( + 2, + ); + }); + + it('does not let a late MCP snapshot overwrite a live status event', async () => { + const eventListeners: Array<(event: Event) => void> = []; + let resolveSnapshot: ( + servers: Array<{ + name: string; + transport: 'stdio' | 'http' | 'sse'; + status: 'pending' | 'connected' | 'failed' | 'disabled'; + toolCount: number; + error?: string; + }>, + ) => void = () => {}; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); const session = makeSession({ onEvent: vi.fn((listener: (event: Event) => void) => { eventListeners.push(listener); return vi.fn(); }), - listMcpServers: vi.fn(async () => [connectedServer]), + listMcpServers: vi.fn(() => snapshot), + }); + const { driver } = await makeDriver(session); + + driver.sessionEventHandler.startSubscription(); + eventListeners[0]?.({ + type: 'mcp.server.status', + agentId: 'main', + sessionId: 'ses-1', + server: { + name: 'local-tools', + transport: 'stdio', + status: 'connected', + toolCount: 2, + }, + } as Event); + resolveSnapshot([ + { + name: 'local-tools', + transport: 'stdio', + status: 'failed', + toolCount: 0, + error: 'stale failure', + }, + ]); + await Promise.resolve(); + + const transcript = renderTranscript(driver); + expect(transcript).toContain('MCP server "local-tools" connected'); + expect(transcript).not.toContain('stale failure'); + }); + + it('sends normal editor input to the active session and marks the turn as waiting', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + + expect(session.prompt).toHaveBeenCalledWith('hello', { promptId: undefined }); + expect(driver.state.appState.streamingPhase).not.toBe('idle'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + }); + + it('keeps the transcript intact when undo RPC fails', async () => { + const session = makeSession({ + undoHistory: vi.fn(async () => { + throw new Error('core rpc unavailable'); + }), }); const { driver } = await makeDriver(session); - enableMcpStatusAnimationForTest(); - driver.sessionEventHandler.startSubscription(); - await Promise.resolve(); - eventListeners[0]?.({ - type: 'mcp.server.status', - agentId: 'main', - sessionId: 'ses-1', - server: connectedServer, - } as Event); + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Error: Failed to undo: core rpc unavailable', + ); + }); + + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('hello'); + }); + + it('does not duplicate welcome after undoing the only turn', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(driver.state.transcriptEntries).toEqual([]); + }); + + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof WelcomeComponent, + ), + ).toHaveLength(1); + }); + + it('keeps command notices that are not part of the undone context', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/auto on'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); + }); + + driver.handleUserInput('/undo 10'); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', + ); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Cannot undo 10 prompts'); + expect(transcript).toContain('Auto mode: ON'); + expect(driver.state.appState.permissionMode).toBe('auto'); + }); + + it('removes turn-scoped background status entries and restores welcome', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.sessionEventHandler.handleEvent( + { + type: 'background.task.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + info: { + kind: 'process', + taskId: 'bash-bg123456', + command: 'npm test', + description: 'Run tests in background', + status: 'running', + pid: 1234, + exitCode: null, + startedAt: Date.now(), + endedAt: null, + }, + } as Event, + () => {}, + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('bash task started in background'); + expect(transcript).toContain('Run tests in background'); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(driver.state.transcriptEntries).toEqual([]); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('bash task started in background'); + expect(transcript).not.toContain('Run tests in background'); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof WelcomeComponent, + ), + ).toHaveLength(1); + }); + + it('removes AgentDynamicWorkflow progress from undone turns', async () => { + const { driver, session } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.handleUserInput('launch dynamic_workflow'); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('launch dynamic_workflow'); + expect(transcript).toContain('Agent DynamicWorkflow'); + expect(transcript).toContain('Review changed files'); + + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('launch dynamic_workflow'); + expect(transcript).not.toContain('Agent DynamicWorkflow'); + expect(transcript).not.toContain('Review changed files'); + }); + + it('removes approval notices from undone turns', async () => { + const { driver, session } = await makeDriver(); + const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as + | ((request: ApprovalRequest) => Promise<ApprovalResponse>) + | undefined; + if (approvalHandler === undefined) throw new Error('expected approval handler'); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + const response = approvalHandler({ + turnId: 1, + toolCallId: 'call_bash', + toolName: 'Bash', + action: 'Run shell command', + display: { + kind: 'generic', + summary: 'Run shell command', + detail: { command: 'echo ok', description: 'Run a shell command' }, + }, + }); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); + }); + (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('1'); + await expect(response).resolves.toMatchObject({ decision: 'approved' }); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('Approved: Run shell command'); + }); + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('Approved: Run shell command'); + }); + + it('removes debug timing status from undone turns', async () => { + const { driver, session } = await makeDriver(); + const previousDebug = process.env['PYTHINKER_CODE_DEBUG']; + process.env['PYTHINKER_CODE_DEBUG'] = '1'; + try { + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.completed', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + llmFirstTokenLatencyMs: 120, + llmStreamDurationMs: 800, + } as Event, + () => {}, + ); - expect(countOccurrences(stripSgr(renderMcpStatus(driver)), 'MCP servers')).toBe(1); + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain('[Debug]'); + }); - eventListeners[0]?.({ - type: 'mcp.server.status', - agentId: 'main', - sessionId: 'ses-1', - server: { - ...connectedServer, - status: 'pending', - toolCount: 0, - }, - } as Event); - eventListeners[0]?.({ - type: 'mcp.server.status', - agentId: 'main', - sessionId: 'ses-1', - server: connectedServer, - } as Event); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(1); + }); - const status = stripSgr(renderMcpStatus(driver)); - expect(countOccurrences(status, 'MCP servers')).toBe(1); - expect(status).toContain('✓ MCP servers · 1/1 connected · 2 tools'); - expect(stripSgr(renderTranscript(driver))).not.toContain('MCP servers'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('[Debug]'); + } finally { + if (previousDebug === undefined) { + delete process.env['PYTHINKER_CODE_DEBUG']; + } else { + process.env['PYTHINKER_CODE_DEBUG'] = previousDebug; + } + } }); - it('does not let a late MCP snapshot overwrite a live status event', async () => { - const eventListeners: Array<(event: Event) => void> = []; - let resolveSnapshot: ( - servers: Array<{ - name: string; - transport: 'stdio' | 'http' | 'sse'; - status: 'pending' | 'connected' | 'failed' | 'disabled'; - toolCount: number; - error?: string; - }>, - ) => void = () => {}; - const snapshot = new Promise((resolve) => { - resolveSnapshot = resolve; - }); - const session = makeSession({ - onEvent: vi.fn((listener: (event: Event) => void) => { - eventListeners.push(listener); - return vi.fn(); - }), - listMcpServers: vi.fn(() => snapshot), + it('undoes multiple turns when a count is provided', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('first'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('second'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('third'); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo 2'); + + await vi.waitFor(() => { + expect(session.undoHistory).toHaveBeenCalledWith(2); }); - const { driver } = await makeDriver(session); - enableMcpStatusAnimationForTest(); - driver.sessionEventHandler.startSubscription(); - eventListeners[0]?.({ - type: 'mcp.server.status', - agentId: 'main', - sessionId: 'ses-1', - server: { - name: 'local-tools', - transport: 'stdio', - status: 'connected', - toolCount: 2, - }, - } as Event); - resolveSnapshot([ - { - name: 'local-tools', - transport: 'stdio', - status: 'failed', - toolCount: 0, - error: 'stale failure', - }, + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'first', + }), ]); - await Promise.resolve(); - - const status = stripSgr(renderMcpStatus(driver)); - expect(status).toContain('✓ MCP servers · 1/1 connected · 2 tools'); - expect(countOccurrences(status, 'MCP servers')).toBe(1); - expect(status).not.toContain('stale failure'); - expect(stripSgr(renderTranscript(driver))).not.toContain('MCP servers'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('first'); + expect(transcript).not.toContain('second'); + expect(transcript).not.toContain('third'); }); - it('sends normal editor input to the active session and marks the turn as waiting', async () => { + it('rejects invalid undo counts without changing context', async () => { const { driver, session } = await makeDriver(); driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; - expect(session.prompt).toHaveBeenCalledWith('hello'); - expect(driver.state.appState.streamingPhase).not.toBe('idle'); - expect(driver.state.appState.streamingPhase).toBe('waiting'); - expect(driver.state.livePane.mode).toBe('waiting'); + driver.handleUserInput('/undo 0'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Error: Usage: /undo [count], where count is a positive integer.', + ); + }); + + expect(session.undoHistory).not.toHaveBeenCalled(); expect(driver.state.transcriptEntries).toEqual([ expect.objectContaining({ kind: 'user', @@ -1503,27 +3099,61 @@ command = "vim" ]); }); - it('keeps the transcript intact when undo RPC fails', async () => { - const session = makeSession({ - undoHistory: vi.fn(async () => { - throw new Error('core rpc unavailable'); - }), - }); - const { driver } = await makeDriver(session); + it('undoes from the real user turn when the last skill activation came from the model', async () => { + const { driver } = await makeDriver(); driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + agentId: 'main', + activationId: 'act-model', + skillName: 'review', + trigger: 'model-tool', + } as Event, + () => {}, + ); driver.state.appState.streamingPhase = 'idle'; driver.handleUserInput('/undo'); await confirmUndoSelection(driver); await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); + expect(driver.state.transcriptEntries).toEqual([]); }); + + expect(driver.state.transcriptEntries).toEqual([]); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('hello'); + expect(transcript).not.toContain('review'); + }); + + it('keeps user-slash skill activations as undo anchors', async () => { + const { driver } = await makeDriver(); + + driver.handleUserInput('hello'); + driver.sessionEventHandler.handleEvent( + { + type: 'skill.activated', + agentId: 'main', + activationId: 'act-user', + skillName: 'review', + trigger: 'user-slash', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'idle'; + + driver.handleUserInput('/undo'); + await confirmUndoSelection(driver); + await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Error: Failed to undo: core rpc unavailable', - ); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + ]); }); expect(driver.state.transcriptEntries).toEqual([ @@ -1534,1138 +3164,1095 @@ command = "vim" ]); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('hello'); + expect(transcript).not.toContain('review'); + }); + + it('keeps a pasted video cache copy for history until the session closes', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + let finishPrompt!: () => void; + const promptSettled = new Promise<void>((resolve) => { + finishPrompt = resolve; + }); + const session = makeSession({ prompt: vi.fn(() => promptSettled) }); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + try { + await withTempVideo(async (srcVideo) => { + const attachment = imageStore.addVideo('video/mp4', srcVideo); + + // Submission is fully synchronous: the paste is copied to the cache and + // referenced by a `file://` video_url the engine resolves in-turn. + driver.handleUserInput(`watch ${attachment.placeholder}`); + + const parts = vi.mocked(session.prompt).mock.calls[0]?.[0] as + | Array<{ + type: string; + text?: string; + videoUrl?: { url: string }; + }> + | undefined; + expect(parts?.[0]).toEqual({ type: 'text', text: 'watch ' }); + expect(parts?.[1]?.type).toBe('video_url'); + expect(parts?.[1]?.videoUrl?.url).toMatch(/^file:\/\/.*clip\.mp4$/); + const stagingPath = driver.state.queuedMessages[0]?.stagingPaths?.[0] + ?? new URL(parts![1]!.videoUrl!.url).pathname; + expect(existsSync(stagingPath)).toBe(true); + + driver.sessionEventHandler.handleEvent( + { type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event, + () => {}, + ); + finishPrompt(); + expect(existsSync(stagingPath)).toBe(true); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + () => {}, + ); + // The cache copy survives the consuming turn: a v1 degrade persists a + // `<video path>` tag carrying this exact path into history, and later + // turns re-open it with ReadMediaFile. + await new Promise((resolve) => { + setTimeout(resolve, 20); + }); + expect(existsSync(stagingPath)).toBe(true); + + // Session close retires it. + await driver.closeSession('test'); + await vi.waitFor(() => { + expect(existsSync(stagingPath)).toBe(false); + }); + }); + } finally { + finishPrompt(); + } + }); + + it('queues a pasted video (file:// part) while a turn is streaming', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const session = makeSession(); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + await withTempVideo(async (srcVideo) => { + const attachment = imageStore.addVideo('video/mp4', srcVideo); + driver.state.appState.streamingPhase = 'waiting'; + + driver.handleUserInput(`describe ${attachment.placeholder}`); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toHaveLength(1); + const queued = driver.state.queuedMessages[0]; + const parts = queued?.parts as Array<{ type: string; text?: string; videoUrl?: { url: string } }>; + expect(parts?.[0]).toEqual({ type: 'text', text: 'describe ' }); + expect(parts?.[1]?.type).toBe('video_url'); + expect(parts?.[1]?.videoUrl?.url).toMatch(/^file:\/\/.*clip\.mp4$/); + expect(queued?.stagingPaths).toHaveLength(1); + expect(existsSync(queued!.stagingPaths![0]!)).toBe(true); + + driver.sendQueuedMessage(session, queued!); + expect(vi.mocked(session.prompt).mock.calls[0]?.[0]).toEqual(parts); + }); + }); + + it('falls back to retained bytes when a queued image upload expires before dispatch', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage( + new Uint8Array([0xaa, 0xbb]), + 'image/png', + 1, + 1, + undefined, + 'file-expired', + 1, + ); + + driver.sendQueuedMessage(session, { + text: `describe ${attachment.placeholder}`, + parts: [ + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-expired' } }, + ], + imageAttachmentIds: [attachment.id], + }); + + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }], + { promptId: expect.any(String) }, + ); + }); + + it('sends pasted image placeholders as image content parts', async () => { + const { driver, session } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + + driver.handleUserInput(`describe ${attachment.placeholder}`); + + expect(session.prompt).toHaveBeenCalledWith( + [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + // Staged media rides with a client-chosen prompt id so the consuming + // turn's `turn.started` can bind the lease exactly. + { promptId: expect.any(String) }, + ); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: `describe ${attachment.placeholder}`, + imageAttachmentIds: [attachment.id], + }), + ]); + }); + + it('keeps an image staging upload until the consuming turn ends', async () => { + const { driver, session, harness } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-1'); + + driver.handleUserInput(attachment.placeholder); + + expect(session.prompt).toHaveBeenCalledOnce(); + emitTurn(driver, 1, () => { + expect(harness.deleteFile).not.toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-1'); + }); + expect(attachment.fileId).toBeUndefined(); + expect(attachment.bytes).toEqual(new Uint8Array([0xaa, 0xbb])); + }); + + it('keeps an image staging upload across lazy session creation (v2 engine)', async () => { + const session = makeSession({ id: 'ses-lazy' }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver, harness } = await makeDriver(session, {}, startupInput); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-lazy'); + + driver.handleUserInput(attachment.placeholder); + + // The lease is created at extraction, before the session exists: lazy + // creation runs setSession mid-dispatch, and the first prompt's lease + // must survive it — the engine's intake only reads the upload once the + // prompt lands. + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith( + [{ type: 'image_url', imageUrl: { url: 'pythinker-file://file-lazy' } }], + { promptId: expect.any(String) }, + ); + }); + expect(harness.deleteFile).not.toHaveBeenCalled(); + emitTurn(driver, 1, () => { + expect(harness.deleteFile).not.toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-lazy'); + }); }); - it('does not duplicate welcome after undoing the only turn', async () => { - const { driver } = await makeDriver(); + it('still deletes the staging upload when a cache-hint dismissal precedes the resend', async () => { + const { driver, session, harness } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-dismissed'); + const text = `describe ${attachment.placeholder}`; - driver.handleUserInput('hello'); - driver.state.appState.streamingPhase = 'idle'; + // Simulate a cache-hint interception dismissed back into the editor: the + // submit's extraction is stashed, then restored with recall semantics + // (retain consumed, staged files kept for the restored draft). + const extraction = extractMediaAttachments(text, imageStore); + driver.recallStashedMedia(text, extraction); - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + // The restored draft resubmits and re-retains; the consuming turn must + // still delete the daemon upload — a retain leaked by the dismissal would + // keep the count above zero and pin the upload until its TTL. + driver.handleUserInput(text); + expect(session.prompt).toHaveBeenCalledOnce(); + emitTurn(driver, 1, () => { + expect(harness.deleteFile).not.toHaveBeenCalled(); + }); await vi.waitFor(() => { - expect(driver.state.transcriptEntries).toEqual([]); + expect(harness.deleteFile).toHaveBeenCalledWith('file-dismissed'); }); - - expect( - driver.state.transcriptContainer.children.filter( - (child) => child instanceof WelcomeComponent, - ), - ).toHaveLength(1); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); }); - it('keeps command notices that are not part of the undone context', async () => { + it('waits briefly for a pending paste ingestion so the submit uses the daemon-ref form', async () => { const { driver, session } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + // Simulate a paste whose background ingestion is still uploading when the + // user hits Enter: the send path waits for it instead of dispatching the + // inline fallback. + let finishIngestion!: () => void; + attachment.pending = new Promise<void>((resolve) => { + finishIngestion = () => { + attachment.fileId = 'file-late'; + attachment.fileExpiresAt = Date.now() + 60 * 60 * 1000; + attachment.pending = undefined; + resolve(); + }; + }); - driver.handleUserInput('hello'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/auto on'); + driver.handleUserInput(`describe ${attachment.placeholder}`); + expect(session.prompt).not.toHaveBeenCalled(); + finishIngestion(); await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain('Auto mode: ON'); + expect(session.prompt).toHaveBeenCalledWith( + [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'pythinker-file://file-late' } }, + ], + { promptId: expect.any(String) }, + ); }); + }); - driver.handleUserInput('/undo 10'); - await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Cannot undo 10 prompts; only 1 prompt can be undone in the active context.', - ); + it('releases staged media exactly once when the prompt dispatch rejects', async () => { + const session = makeSession({ + prompt: vi.fn(async () => { + throw new Error('session closed'); + }), }); + const { driver, harness } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-reject'); - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + driver.handleUserInput(attachment.placeholder); await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); + expect(driver.state.appState.streamingPhase).toBe('idle'); }); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to send: session closed'); + expect(harness.deleteFile).toHaveBeenCalledWith('file-reject'); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('hello'); - expect(transcript).not.toContain('Cannot undo 10 prompts'); - expect(transcript).toContain('Auto mode: ON'); - expect(driver.state.appState.permissionMode).toBe('auto'); + // The released lease must not be claimed or deleted again by later turn + // events or by session close. + emitTurn(driver, 1); + await driver.closeSession('test'); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); }); - it('removes turn-scoped background status entries and restores welcome', async () => { - const { driver, session } = await makeDriver(); + it('releases goal-steered staging media when the running goal turn ends', async () => { + const { driver, session, harness } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-goal'); + // The goal driver's continuation turn (origin system_trigger — it never + // claims leases through handleTurnStarted) is streaming when the queued + // steer dispatch lands. + driver.state.appState.goal = makeActiveGoalSnapshot(); + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('7'); - driver.handleUserInput('hello'); - driver.state.appState.streamingPhase = 'idle'; + driver.sendQueuedMessage(session, { + text: attachment.placeholder, + agentId: 'main', + parts: [{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }], + imageAttachmentIds: [attachment.id], + }); + + expect(session.steer).toHaveBeenCalledOnce(); + expect(harness.deleteFile).not.toHaveBeenCalled(); driver.sessionEventHandler.handleEvent( - { - type: 'background.task.started', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - info: { - kind: 'process', - taskId: 'bash-bg123456', - command: 'npm test', - description: 'Run tests in background', - status: 'running', - pid: 1234, - exitCode: null, - startedAt: Date.now(), - endedAt: null, - }, - } as Event, + { type: 'turn.ended', agentId: 'main', turnId: 7, reason: 'completed' } as Event, () => {}, ); - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('bash task started in background'); - expect(transcript).toContain('Run tests in background'); + expect(harness.deleteFile).toHaveBeenCalledWith('file-goal'); }); + expect(attachment.fileId).toBeUndefined(); + }); - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + it('releases every queued use of shared media when the queue is discarded', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const { driver, harness } = await makeDriver(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-queued'); + driver.state.appState.streamingPhase = 'waiting'; + + driver.handleUserInput(`first ${attachment.placeholder}`); + driver.handleUserInput(`second ${attachment.placeholder}`); + const stagingPaths = driver.state.queuedMessages.flatMap((item) => item.stagingPaths ?? []); + expect(driver.state.queuedMessages).toHaveLength(2); + // An uploaded image stages no local cache copy — the engine's intake + // materializes the session copy — so only the daemon upload lease rides + // with each queued message. + expect(stagingPaths).toHaveLength(0); + + driver.clearQueuedMessages(); await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); + expect(harness.deleteFile).toHaveBeenCalledWith('file-queued'); }); - - const transcript = stripSgr(renderTranscript(driver)); - expect(driver.state.transcriptEntries).toEqual([]); - expect(transcript).not.toContain('hello'); - expect(transcript).not.toContain('bash task started in background'); - expect(transcript).not.toContain('Run tests in background'); - expect( - driver.state.transcriptContainer.children.filter( - (child) => child instanceof WelcomeComponent, - ), - ).toHaveLength(1); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); + expect(attachment.fileId).toBeUndefined(); }); - it('removes Dynamic Workflow mission control from undone turns', async () => { - const { driver, session } = await makeDriver(); - const sendQueued = vi.fn(); + it('does not delete shared daemon media while another turn still uses it', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + driver.state.appState.model = 'k2'; + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-shared-turn'); - driver.handleUserInput('launch swarm'); + driver.handleUserInput(`first ${attachment.placeholder}`); driver.sessionEventHandler.handleEvent( - { - type: 'tool.call.started', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - toolCallId: 'call_dynamic_workflow', - name: 'DynamicWorkflow', - args: { - description: 'Review changed files', - prompt_template: 'Review {{item}}', - items: ['src/a.ts', 'src/b.ts'], - }, - } as Event, - sendQueued, + { type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event, + () => {}, ); + driver.state.appState.streamingPhase = 'waiting'; + driver.handleUserInput(`second ${attachment.placeholder}`); + driver.clearQueuedMessages(); - let transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('launch swarm'); - expect(transcript).toContain('Dynamic Workflow'); - expect(transcript).toContain('Review changed files'); - expect( - driver.sessionEventHandler.hasDynamicWorkflowMissionControl('call_dynamic_workflow'), - ).toBe(true); - - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + await Promise.resolve(); + expect(harness.deleteFile).not.toHaveBeenCalled(); + driver.sessionEventHandler.handleEvent( + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, + () => {}, + ); await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); + expect(harness.deleteFile).toHaveBeenCalledWith('file-shared-turn'); }); + }); - transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('launch swarm'); - expect(transcript).not.toContain('Dynamic Workflow'); - expect(transcript).not.toContain('Review changed files'); - expect( - driver.sessionEventHandler.hasDynamicWorkflowMissionControl('call_dynamic_workflow'), - ).toBe(false); + it('queues editor input instead of prompting while a turn is already streaming', async () => { + const { driver, session, harness } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + harness.track.mockClear(); - driver.sessionEventHandler.handleEvent( - { - type: 'tool.call.started', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - toolCallId: 'call_dynamic_workflow', - name: 'DynamicWorkflow', - args: { description: 'Late recreated workflow', items: ['Late work'] }, - } as Event, - sendQueued, - ); - driver.sessionEventHandler.handleEvent( - { - type: 'tool.call.delta', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - toolCallId: 'call_dynamic_workflow', - name: 'DynamicWorkflow', - argumentsPart: '{"description":"Late streamed workflow"}', - } as Event, - sendQueued, - ); - expect( - driver.sessionEventHandler.hasDynamicWorkflowMissionControl('call_dynamic_workflow'), - ).toBe(false); - expect(stripSgr(renderTranscript(driver))).not.toContain('Late recreated workflow'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Late streamed workflow'); + driver.handleUserInput('queued message'); - driver.sessionEventHandler.handleEvent( - { - type: 'subagent.spawned', - agentId: 'main', - sessionId: 'ses-1', - parentToolCallId: 'call_dynamic_workflow', - subagentId: 'late-agent', - subagentName: 'coder', - dynamicWorkflowIndex: 1, - runInBackground: false, - } as Event, - sendQueued, - ); - driver.sessionEventHandler.handleEvent( - { - type: 'assistant.delta', - agentId: 'late-agent', - sessionId: 'ses-1', - turnId: 1, - delta: 'Late output from undone work', - } as Event, - sendQueued, - ); + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'queued message', agentId: 'main' }]); + expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); + }); - transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('Late output from undone work'); - expect(transcript).not.toContain('Review changed files'); + it('queues a slash-skill activation while a turn is streaming (like any other input) and activates on drain', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { + name: 'tower', + description: 'multi-agent tower mode', + path: 'builtin://tower', + source: 'builtin', + type: 'inline', + }, + ]), + }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise<void> } + ).refreshSkillCommands(session); + driver.state.appState.streamingPhase = 'waiting'; + harness.track.mockClear(); - driver.sessionEventHandler.handleEvent( - { - type: 'tool.call.started', - agentId: 'main', - sessionId: 'ses-1', - turnId: 2, - toolCallId: 'call_fresh_dynamic_workflow', - name: 'DynamicWorkflow', - args: { description: 'Fresh workflow', items: ['Fresh work'] }, - } as Event, - sendQueued, - ); - driver.sessionEventHandler.handleEvent( - { - type: 'subagent.spawned', - agentId: 'main', - sessionId: 'ses-1', - parentToolCallId: 'call_fresh_dynamic_workflow', - subagentId: 'late-agent', - subagentName: 'coder', - dynamicWorkflowIndex: 1, - runInBackground: false, - } as Event, - sendQueued, - ); - driver.sessionEventHandler.handleEvent( + driver.handleUserInput('/tower refactor auth and ui'); + + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ { - type: 'subagent.completed', + text: '/tower refactor auth and ui', agentId: 'main', - sessionId: 'ses-1', - subagentId: 'late-agent', - parentToolCallId: 'call_dynamic_workflow', - resultSummary: 'Late completion from undone work', - } as Event, - sendQueued, - ); - - transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+○\s+WAIT\s+Fresh work/u); - expect(transcript).not.toContain('Late completion from undone work'); - }); - - it('removes approval notices from undone turns', async () => { - const { driver, session } = await makeDriver(); - const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as - | ((request: ApprovalRequest) => Promise<ApprovalResponse>) - | undefined; - if (approvalHandler === undefined) throw new Error('expected approval handler'); + mode: 'skill', + skillName: 'tower', + skillArgs: 'refactor auth and ui', + }, + ]); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); - driver.handleUserInput('hello'); + // Turn ends: the drain re-enters sendSkillActivation, which now fires. driver.state.appState.streamingPhase = 'idle'; - const response = approvalHandler({ - turnId: 1, - toolCallId: 'call_bash', - toolName: 'Bash', - action: 'Run shell command', - display: { - kind: 'generic', - summary: 'Run shell command', - detail: { command: 'echo ok', description: 'Run a shell command' }, - }, - }); + const queued = driver.state.queuedMessages[0]!; + driver.state.queuedMessages = []; + driver.sendQueuedMessage(session, queued); + + expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui'); + }); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApprovalPanelComponent); + it('queues a slash-skill activation while compacting and activates it on drain', async () => { + const session = makeSession({ + listSkills: vi.fn(async () => [ + { + name: 'tower', + description: 'multi-agent tower mode', + path: 'builtin://tower', + source: 'builtin', + type: 'inline', + }, + ]), }); - (driver.state.editorContainer.children[0] as ApprovalPanelComponent).handleInput('1'); - await expect(response).resolves.toMatchObject({ decision: 'approved' }); + const { driver, harness } = await makeDriver(session); + await ( + driver as unknown as { refreshSkillCommands(s: unknown): Promise<void> } + ).refreshSkillCommands(session); + driver.state.appState.isCompacting = true; + harness.track.mockClear(); - await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain('Approved: Run shell command'); - }); + driver.handleUserInput('/tower refactor auth and ui'); - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { + text: '/tower refactor auth and ui', + agentId: 'main', + mode: 'skill', + skillName: 'tower', + skillArgs: 'refactor auth and ui', + }, + ]); + expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); + expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); - await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); - }); + driver.state.appState.isCompacting = false; + const queued = driver.state.queuedMessages[0]!; + driver.state.queuedMessages = []; + driver.sendQueuedMessage(session, queued); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('hello'); - expect(transcript).not.toContain('Approved: Run shell command'); + expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui'); }); - it('undoes multiple turns when a count is provided', async () => { + it('steers fresh input while a goal is active even when the streaming phase is idle', async () => { const { driver, session } = await makeDriver(); + driver.state.appState.goal = makeActiveGoalSnapshot(); - driver.handleUserInput('first'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('second'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('third'); - driver.state.appState.streamingPhase = 'idle'; - - driver.handleUserInput('/undo 2'); - - await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(2); - }); + driver.handleUserInput('hello mid-goal'); + expect(session.steer).toHaveBeenCalledWith('hello mid-goal'); + expect(session.prompt).not.toHaveBeenCalled(); expect(driver.state.transcriptEntries).toEqual([ expect.objectContaining({ kind: 'user', - content: 'first', + content: 'hello mid-goal', }), ]); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('first'); - expect(transcript).not.toContain('second'); - expect(transcript).not.toContain('third'); }); - it('summarizes from a selected prompt and restores it for editing', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('first'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('second'); - driver.state.appState.streamingPhase = 'idle'; - - driver.handleUserInput('/undo'); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); + it('resets the streaming phase when steering mid-goal input fails', async () => { + const session = makeSession({ + steer: vi.fn(async () => { + throw new Error('session closed'); + }), }); - (driver.state.editorContainer.children[0] as UndoSelectorComponent).handleInput('s'); + const { driver } = await makeDriver(session); + driver.state.appState.goal = makeActiveGoalSnapshot(); + driver.handleUserInput('hello mid-goal'); + + expect(driver.state.appState.streamingPhase).toBe('waiting'); await vi.waitFor(() => { - expect(session.compact).toHaveBeenCalledWith({ - promptFromEnd: 1, - direction: 'from', - }); + expect(driver.state.appState.streamingPhase).toBe('idle'); }); - expect(driver.state.editor.getText()).toBe('second'); - expect(session.undoHistory).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to steer: session closed'); }); - it('summarizes up to an earlier selected prompt without restoring it', async () => { + it('steers a queued message at a turn boundary while a goal is active', async () => { const { driver, session } = await makeDriver(); + driver.state.appState.goal = makeActiveGoalSnapshot(); + driver.state.appState.streamingPhase = 'waiting'; + driver.handleUserInput('mid-goal note'); + expect(driver.state.queuedMessages).toEqual([{ text: 'mid-goal note', agentId: 'main' }]); - driver.handleUserInput('first'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('second'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('third'); - driver.state.appState.streamingPhase = 'idle'; - - driver.handleUserInput('/undo'); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); - }); - const selector = driver.state.editorContainer.children[0] as UndoSelectorComponent; - selector.setKeybindings(defaultKeybindings()); - selector.handleInput('\u001B[A'); - selector.handleInput('u'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + (item) => { + driver.sendQueuedMessage(session, item); + }, + ); await vi.waitFor(() => { - expect(session.compact).toHaveBeenCalledWith({ - promptFromEnd: 2, - direction: 'up_to', - }); + expect(session.steer).toHaveBeenCalledWith('mid-goal note'); }); - expect(driver.state.editor.getText()).toBe(''); - expect(session.undoHistory).not.toHaveBeenCalled(); + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([]); }); - it('uses remapped MessageSelector actions while keeping code-only summary keys local', () => { - const selected: string[] = []; - const summarized: string[] = []; - const cancelled: string[] = []; - const raw = new UndoSelectorComponent({ - choices: [ - { id: 'first', count: 2, input: 'first', label: 'First' }, - { id: 'middle', count: 1, input: 'middle', label: 'Middle' }, - { id: 'last', count: 1, input: 'last', label: 'Last' }, - ], - onSelect: () => {}, - onSummarize: () => {}, - onCancel: () => {}, - }); - raw.handleInput('\u001B[A'); - expect(stripSgr(raw.render(120).join('\n'))).toContain('❯ Middle'); - const selector = new UndoSelectorComponent({ - choices: [ - { id: 'first', count: 2, input: 'first', label: 'First' }, - { id: 'code', input: '', label: 'Code only' }, - { id: 'last', count: 1, input: 'last', label: 'Last' }, - ], - onSelect: (choice) => selected.push(choice.id), - onSummarize: (choice) => summarized.push(choice.id), - onCancel: () => cancelled.push('cancel'), - }); - selector.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'MessageSelector', - bindings: { - up: null, - down: null, - enter: null, - 'alt+u': 'messageSelector:up', - 'alt+n': 'messageSelector:down', - 'alt+t': 'messageSelector:top', - 'alt+b': 'messageSelector:bottom', - 'alt+s': 'messageSelector:select', - }, - }, - { context: 'Confirmation', bindings: { escape: null, 'alt+x': 'confirm:no' } }, - ]), - ]); + it('prompts the queued message as a new turn when no goal is active', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.handleUserInput('after the turn'); - selector.handleInput('\u001B[A'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ Last'); - selector.handleInput('\u001B[B'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ Last'); - selector.handleInput('\u001Bt'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ First'); - selector.handleInput('\u001Bn'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ Code only'); - selector.handleInput('\u001Bb'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ Last'); - selector.handleInput('\u001Bu'); - expect(stripSgr(selector.render(120).join('\n'))).toContain('❯ Code only'); - selector.handleInput('s'); - expect(summarized).toEqual([]); - selector.handleInput('\u001Bs'); - expect(selected).toEqual(['code']); - - const cancellable = new UndoSelectorComponent({ - choices: [{ id: 'only', count: 1, input: 'only', label: 'Only' }], - onSelect: () => {}, - onSummarize: () => {}, - onCancel: () => cancelled.push('cancel'), - }); - cancellable.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { context: 'Confirmation', bindings: { escape: null, 'alt+x': 'confirm:no' } }, - ]), - ]); - cancellable.handleInput('\u001B'); - expect(cancelled).toEqual([]); - cancellable.handleInput('\u001Bx'); - expect(cancelled).toEqual(['cancel']); - - const paging = new UndoSelectorComponent({ - choices: Array.from({ length: 12 }, (_, index) => ({ - id: `point-${String(index + 1)}`, - count: 1, - input: `point ${String(index + 1)}`, - label: `Point ${String(index + 1)}`, - })), - onSelect: () => {}, - onSummarize: () => {}, - onCancel: () => {}, - }); - paging.handleInput(`${ESC}[5~`); - expect(stripSgr(paging.render(120).join('\n'))).toContain('❯ Point 4'); - paging.handleInput(`${ESC}[6~`); - expect(stripSgr(paging.render(120).join('\n'))).toContain('❯ Point 12'); - - const localSummary = new UndoSelectorComponent({ - choices: [{ id: 'summary', count: 1, input: 'summary', label: 'Summary' }], - onSelect: () => {}, - onSummarize: (choice) => summarized.push(choice.id), - onCancel: () => {}, - }); - localSummary.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'MessageSelector', - bindings: { 's x': 'messageActions:enter' }, - }, - ]), - ]); - localSummary.handleInput('s'); - expect(summarized).toEqual(['summary']); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + (item) => { + driver.sendQueuedMessage(session, item); + }, + ); - const hintless = new UndoSelectorComponent({ - choices: [{ id: 'hint', count: 1, input: 'hint', label: 'Hint' }], - onSelect: () => {}, - onSummarize: () => {}, - onCancel: () => {}, + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('after the turn', { promptId: undefined }); }); - hintless.setKeybindings( - parseKeybindingBlocks([ - { - context: 'MessageSelector', - bindings: { up: null, down: null, enter: null }, - }, - { context: 'Confirmation', bindings: { escape: null } }, - ]), - ); - expect(stripSgr(hintless.render(120)[2] ?? '').trim()).toBe( - 'S summarize from · U summarize up to', - ); + expect(session.steer).not.toHaveBeenCalled(); }); - it('preserves the editor draft when message actions are cancelled', async () => { - const { driver } = await makeDriver(); - const tui = driver as unknown as PythinkerTUI; - driver.handleUserInput('select this transcript entry'); - driver.state.appState.streamingPhase = 'idle'; - driver.state.editor.setText('keep this draft'); + it('cancels active streaming from Escape and Ctrl-C editor shortcuts', async () => { + const { driver, session } = await makeDriver(); - tui.showMessageActions(); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); - (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\u001B'); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(driver.state.editor.getText()).toBe('draft while streaming'); + + session.cancel.mockClear(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText(''); + driver.state.editor.onCtrlC?.(); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); - expect(driver.state.editor.getText()).toBe('keep this draft'); + expect(session.cancel).toHaveBeenCalledTimes(1); }); - it('uses persisted checkpoint IDs and ignores summarize keys for code-only history', async () => { - const session = makeSession({ - getContext: vi.fn(async () => ({ - history: [ - { - role: 'user', - content: [{ type: 'text', text: 'before compaction' }], - origin: { kind: 'user', checkpointId: 'checkpoint-old' }, - }, - { - role: 'user', - content: [{ type: 'text', text: 'summary' }], - origin: { kind: 'compaction_summary' }, - }, - { - role: 'user', - content: [{ type: 'text', text: 'active prompt' }], - origin: { kind: 'user', checkpointId: 'checkpoint-active' }, - }, - ], - })), - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-old', - kind: 'user' as const, - createdAt: '2026-07-30T11:00:00.000Z', - prompt: 'before compaction', - complete: true, - changedPaths: ['src/old.ts'], - }, - { - id: 'checkpoint-active', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'active prompt', - complete: true, - changedPaths: [], - }, - ]), - previewFileCheckpoint: vi.fn(async (checkpointId: string) => ({ - checkpointId, - complete: true, - paths: [{ path: 'src/old.ts', insertions: 2, deletions: 1, modeChanged: false }], - insertions: 2, - deletions: 1, - conversationAvailable: false, - })), - }); - const { driver } = await makeDriver(session); + it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); - driver.handleUserInput('active prompt'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft while streaming'); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(UndoSelectorComponent); - }); - const selector = driver.state.editorContainer.children[0] as UndoSelectorComponent; - const rows = stripSgr(selector.render(120).join('\n')); - expect(rows).toContain('before compaction'); - expect(rows).toContain('active prompt'); + driver.state.editor.onCtrlC?.(); - selector.handleInput('\u001B[A'); - selector.handleInput('s'); - selector.handleInput('u'); - expect(session.compact).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe(''); + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('waiting'); - selector.handleInput('\r'); - await vi.waitFor(() => { - expect(session.previewFileCheckpoint).toHaveBeenCalledWith('checkpoint-old'); - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); + driver.state.editor.onCtrlC?.(); - const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - const options = ( - picker as unknown as { - opts: { options: readonly { value: string }[] }; - } - ).opts.options.map((option) => option.value); - expect(options).toEqual(['code', 'cancel']); + expect(session.cancel).toHaveBeenCalledTimes(1); }); - it('undoes the conversation directly when a checkpoint has no tracked file changes', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ + it('dispatches the next queued message after the active turn ends', async () => { + vi.useFakeTimers(); + try { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.streamingStartTime = 1; + driver.streamingUI.setTurnId('1'); + driver.state.queuedMessages = [{ text: 'next' }]; + + driver.sessionEventHandler.handleEvent( { - id: 'checkpoint-empty', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'hello', - complete: true, - changedPaths: [], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-empty', - complete: true, - paths: [], - insertions: 0, - deletions: 0, - conversationAvailable: true, - })), - }); - const { driver } = await makeDriver(session); + type: 'turn.ended', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + reason: 'completed', + } as Event, + sendQueued, + ); + await vi.runAllTimersAsync(); - driver.handleUserInput('hello'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + expect(sendQueued).toHaveBeenCalledWith({ text: 'next' }); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.appState.streamingPhase).toBe('idle'); + } finally { + vi.useRealTimers(); + } + }); - await vi.waitFor(() => { - expect(session.previewFileCheckpoint).toHaveBeenCalledWith('checkpoint-empty'); - expect(session.undoHistory).toHaveBeenCalledWith(1); - expect(driver.state.editor.getText()).toBe('hello'); - }); - expect(session.restoreFileCheckpoint).not.toHaveBeenCalled(); + it('queues bash input with mode bash while a turn is streaming', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(session.prompt).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); }); - it('shows exact restore actions and checkpoint diff statistics', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-files', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'change files', - complete: true, - changedPaths: ['src/a.ts', 'src/b.ts'], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-files', - complete: true, - paths: [ - { path: 'src/a.ts', insertions: 4, deletions: 1, modeChanged: false }, - { path: 'src/b.ts', insertions: 3, deletions: 2, modeChanged: true }, - ], - insertions: 7, - deletions: 3, - conversationAvailable: true, - })), - }); + it('dispatches a queued bash item to runShellCommand instead of prompt', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); const { driver } = await makeDriver(session); - driver.handleUserInput('change files'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + driver.sendQueuedMessage(session, { text: 'ls', mode: 'bash' }); + await Promise.resolve(); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); - const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - const options = ( - picker as unknown as { - opts: { options: readonly { value: string }[] }; - } - ).opts.options.map((option) => option.value); - expect(options).toEqual(['both', 'conversation', 'code', 'cancel']); + expect(runShellCommand).toHaveBeenCalledWith( + 'ls', + expect.objectContaining({ commandId: expect.any(String) }), + ); + expect(session.prompt).not.toHaveBeenCalled(); + }); - const output = stripSgr(picker.render(120).join('\n')); - expect(output).toContain('2 files'); - expect(output).toContain('7 insertions'); - expect(output).toContain('3 deletions'); - expect(output).toContain('Shell commands and manual edits are not tracked.'); + it('persists bash input to input history with a leading !', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; + + driver.handleUserInput('ls'); + + expect(driver.persistInputHistory).toHaveBeenCalledWith('!ls'); }); - it('refuses an incomplete checkpoint before offering restore actions', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-incomplete', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'unsafe edit', - complete: false, - changedPaths: ['src/a.ts'], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-incomplete', - complete: false, - paths: [{ path: 'src/a.ts', insertions: 1, deletions: 1, modeChanged: false }], - insertions: 1, - deletions: 1, - conversationAvailable: true, - })), - }); - const { driver } = await makeDriver(session); + it('persists normal input to input history', async () => { + const { driver } = await makeDriver(); - driver.handleUserInput('unsafe edit'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + driver.handleUserInput('hello'); - await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Error: Cannot restore code because this checkpoint is incomplete.', - ); - }); - expect(session.restoreFileCheckpoint).not.toHaveBeenCalled(); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + expect(driver.persistInputHistory).toHaveBeenCalledWith('hello'); }); - it('reports checkpoint preview failures without claiming success', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-missing', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'missing', - complete: true, - changedPaths: [], - }, - ]), - previewFileCheckpoint: vi.fn(async () => { - throw new Error('checkpoint not found'); - }), - }); + it('does not steer queued bash commands, keeping them queued', async () => { + const session = makeSession(); const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [ + { text: 'ls', agentId: 'main', mode: 'bash' }, + { text: 'focus on tests', agentId: 'main' }, + ]; - driver.handleUserInput('missing'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + driver.state.editor.onCtrlS?.(); - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain( - 'Error: Failed to preview checkpoint: checkpoint not found', - ); - expect(transcript).not.toContain('Files restored'); - }); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + expect(session.steer).toHaveBeenCalledWith('focus on tests'); + expect(driver.state.queuedMessages).toEqual([ + { text: 'ls', agentId: 'main', mode: 'bash' }, + ]); }); - it('reports code restore failures without undoing the conversation', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-restore-fails', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'change files', - complete: true, - changedPaths: ['src/a.ts'], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-restore-fails', - complete: true, - paths: [{ path: 'src/a.ts', insertions: 1, deletions: 0, modeChanged: false }], - insertions: 1, - deletions: 0, - conversationAvailable: true, - })), - restoreFileCheckpoint: vi.fn(async () => { - throw new Error('disk write failed'); - }), - }); + it('does not steer while a shell command is running', async () => { + const session = makeSession(); const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'shell'; + driver.state.queuedMessages = [{ text: 'summarize the output', agentId: 'main' }]; - driver.handleUserInput('change files'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); - - const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.handleInput('\u001B[B'); - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); + driver.state.editor.onCtrlS?.(); - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Error: Failed to restore code: disk write failed'); - expect(transcript).not.toContain('Files restored'); - }); - expect(session.undoHistory).not.toHaveBeenCalled(); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([ + { text: 'summarize the output', agentId: 'main' }, + ]); }); - it('reports the recovery checkpoint when conversation undo fails after code restore', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-mixed', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'change files', - complete: true, - changedPaths: ['src/a.ts'], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-mixed', - complete: true, - paths: [{ path: 'src/a.ts', insertions: 1, deletions: 0, modeChanged: false }], - insertions: 1, - deletions: 0, - conversationAvailable: true, - })), - restoreFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-mixed', - recoveryCheckpointId: 'recovery-mixed', - restoredPaths: ['src/a.ts'], - deletedPaths: [], - })), - undoHistory: vi.fn(async () => { - throw new Error('conversation rpc failed'); - }), - }); + it('does not steer the editor draft while it is in bash mode', async () => { + const session = makeSession(); const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.inputMode = 'bash'; + driver.state.editor.setText('ls'); - driver.handleUserInput('change files'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); - (driver.state.editorContainer.children[0] as ChoicePickerComponent).handleInput('\r'); + driver.state.editor.onCtrlS?.(); - await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Error: Files were restored, but conversation undo failed. Recovery checkpoint: recovery-mixed.', - ); - }); - expect(session.restoreFileCheckpoint).toHaveBeenCalledBefore(session.undoHistory); - expect(driver.state.transcriptEntries).toEqual([ - expect.objectContaining({ kind: 'user', content: 'change files' }), - ]); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.editor.getText()).toBe('ls'); }); - it('reports successful code-only restores with counts and a recovery checkpoint', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-code', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'change files', - complete: true, - changedPaths: ['src/a.ts', 'src/new.ts'], - }, - ]), - previewFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-code', - complete: true, - paths: [ - { path: 'src/a.ts', insertions: 1, deletions: 0, modeChanged: false }, - { path: 'src/new.ts', insertions: 1, deletions: 0, modeChanged: false }, - ], - insertions: 2, - deletions: 0, - conversationAvailable: true, - })), - restoreFileCheckpoint: vi.fn(async () => ({ - checkpointId: 'checkpoint-code', - recoveryCheckpointId: 'recovery-code', - restoredPaths: ['src/a.ts'], - deletedPaths: ['src/new.ts'], - })), - }); + it('drains a queued image message with its media parts', async () => { + const session = makeSession(); const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.appState.streamingPhase = 'waiting'; - driver.handleUserInput('change files'); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); + driver.handleUserInput(`describe ${attachment.placeholder}`); - const picker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - picker.handleInput('\u001B[B'); - picker.handleInput('\u001B[B'); - picker.handleInput('\r'); + expect(session.prompt).not.toHaveBeenCalled(); + const queued = driver.state.queuedMessages[0]; + expect(queued?.parts).toEqual([ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Files restored'); - expect(transcript).toContain('Restored: 1. Deleted: 1.'); - expect(transcript).toContain('Recovery checkpoint: recovery-code.'); - }); - expect(session.undoHistory).not.toHaveBeenCalled(); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + driver.sendQueuedMessage(session, queued!); + + expect(session.prompt).toHaveBeenCalledWith( + [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + // Staged media rides with a client-chosen prompt id so the consuming + // turn's `turn.started` can bind the lease exactly. + { promptId: expect.any(String) }, + ); }); - it('rejects invalid undo counts without changing context', async () => { - const { driver, session } = await makeDriver(); + it('steers editor image input as media parts', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.editor.setText(`check ${attachment.placeholder}`); - driver.handleUserInput('hello'); - driver.state.appState.streamingPhase = 'idle'; + driver.state.editor.onCtrlS?.(); - driver.handleUserInput('/undo 0'); + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'check ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ]); + }); - await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Error: Usage: /undo [count], where count is a positive integer.', - ); - }); + it('steers queued image messages with their media parts', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { + text: `look ${attachment.placeholder}`, + agentId: 'main', + parts: [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + imageAttachmentIds: [attachment.id], + }, + ]; - expect(session.undoHistory).not.toHaveBeenCalled(); - expect(driver.state.transcriptEntries).toEqual([ - expect.objectContaining({ - kind: 'user', - content: 'hello', - }), + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, ]); + expect(driver.state.queuedMessages).toEqual([]); }); - it('undoes from the real user turn when the last skill activation came from the model', async () => { - const { driver } = await makeDriver(); + it('releases every queued use of shared media after a batched steer', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-batched'); + driver.handleUserInput(`first ${attachment.placeholder}`); + driver.handleUserInput(`second ${attachment.placeholder}`); + expect(driver.state.queuedMessages).toHaveLength(2); - driver.handleUserInput('hello'); + driver.state.editor.onCtrlS?.(); + + expect(session.steer).toHaveBeenCalledOnce(); driver.sessionEventHandler.handleEvent( - { - type: 'skill.activated', - agentId: 'main', - activationId: 'act-model', - skillName: 'review', - trigger: 'model-tool', - } as Event, + { type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' } as Event, () => {}, ); - driver.state.appState.streamingPhase = 'idle'; - - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); - await vi.waitFor(() => { - expect(driver.state.transcriptEntries).toEqual([]); + expect(harness.deleteFile).toHaveBeenCalledWith('file-batched'); }); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); + expect(attachment.fileId).toBeUndefined(); + expect(driver.state.queuedMessages).toEqual([]); + }); - expect(driver.state.transcriptEntries).toEqual([]); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('hello'); - expect(transcript).not.toContain('review'); + it('keeps a shared staged upload alive while another submission still holds it', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-shared'); + + // One message referencing the same image twice retains it once; a second + // queued message retains it again — two retains total. + driver.handleUserInput(`compare ${attachment.placeholder} with ${attachment.placeholder}`); + driver.handleUserInput(`and ${attachment.placeholder}`); + const [first, second] = driver.state.queuedMessages; + + driver.sendQueuedMessage(session, first!); + emitTurn(driver, 1); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The first turn consumed the only retain its submission held; the second + // queued message's retain keeps the upload alive. + expect(harness.deleteFile).not.toHaveBeenCalled(); + + driver.sendQueuedMessage(session, second!); + emitTurn(driver, 2); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-shared'); + }); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); }); - it('keeps user-slash skill activations as undo anchors', async () => { - const session = makeSession({ - listFileCheckpoints: vi.fn(async () => [ - { - id: 'checkpoint-user', - kind: 'user' as const, - createdAt: '2026-07-30T12:00:00.000Z', - prompt: 'hello', - complete: true, - changedPaths: [], - }, - { - id: 'checkpoint-skill', - kind: 'user' as const, - createdAt: '2026-07-30T12:01:00.000Z', - prompt: '/review', - complete: true, - changedPaths: [], - }, - ]), + it('keeps staged media when a queued message is recalled into the editor', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = stagedImage(imageStore, 'file-recall'); + + driver.handleUserInput(`look ${attachment.placeholder}`); + expect(driver.state.queuedMessages).toHaveLength(1); + + const recalled = driver.recallLastQueued(); + expect(recalled?.text).toContain(attachment.placeholder); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Recalled, not discarded: the daemon upload stays staged for the + // restored draft. + expect(harness.deleteFile).not.toHaveBeenCalled(); + expect(attachment.fileId).toBe('file-recall'); + + // Re-queueing the restored draft reuses the daemon-ref form, and the + // consuming turn's end releases the upload exactly once. + driver.handleUserInput(recalled!.text); + const requeued = driver.state.queuedMessages[0]!; + expect(requeued.parts).toContainEqual({ + type: 'image_url', + imageUrl: { url: 'pythinker-file://file-recall' }, + }); + + driver.sendQueuedMessage(session, requeued); + emitTurn(driver, 1); + await vi.waitFor(() => { + expect(harness.deleteFile).toHaveBeenCalledWith('file-recall'); }); + expect(harness.deleteFile).toHaveBeenCalledTimes(1); + }); + + it('rebases a recalled video onto its staged cache copy', async () => { + process.env['PYTHINKER_CODE_HOME'] = await makeTempHome(); + const session = makeSession(); const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + await withTempVideo(async (srcVideo) => { + const attachment = imageStore.addVideo('video/mp4', srcVideo); + driver.state.appState.streamingPhase = 'waiting'; - driver.handleUserInput('hello'); - driver.sessionEventHandler.handleEvent( + driver.handleUserInput(`describe ${attachment.placeholder}`); + const queued = driver.state.queuedMessages[0]!; + const cachePath = queued.stagingPaths![0]!; + expect(existsSync(cachePath)).toBe(true); + + const recalled = driver.recallLastQueued(); + expect(recalled?.text).toContain(attachment.placeholder); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The cache copy survives the recall and becomes the video's source, so + // a vanished original cannot lose the media on resubmit. + expect(existsSync(cachePath)).toBe(true); + expect(attachment.sourcePath).toBe(cachePath); + }); + }); + + it('steers consecutive image-only messages without a whitespace-only separator part', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const first = imageStore.addImage(new Uint8Array([0xaa]), 'image/png', 1, 1); + const second = imageStore.addImage(new Uint8Array([0xbb]), 'image/png', 1, 1); + const imagePart = (bytes: Uint8Array) => ({ + type: 'image_url' as const, + imageUrl: { url: `data:image/png;base64,${Buffer.from(bytes).toString('base64')}` }, + }); + driver.state.queuedMessages = [ { - type: 'skill.activated', + text: first.placeholder, agentId: 'main', - activationId: 'act-user', - skillName: 'review', - trigger: 'user-slash', - checkpointId: 'checkpoint-skill', - } as Event, - () => {}, - ); - driver.state.appState.streamingPhase = 'idle'; + parts: [imagePart(first.bytes)], + imageAttachmentIds: [first.id], + }, + { + text: second.placeholder, + agentId: 'main', + parts: [imagePart(second.bytes)], + imageAttachmentIds: [second.id], + }, + ]; - expect(driver.state.transcriptEntries.at(-1)).toMatchObject({ - kind: 'skill_activation', - checkpointId: 'checkpoint-skill', - }); + driver.state.editor.onCtrlS?.(); - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); + // normalizePromptInput rejects whitespace-only text parts, so the + // item separator must not become a standalone `{type:'text',text:'\n\n'}` + // between two image parts. + expect(session.steer).toHaveBeenCalledWith([imagePart(first.bytes), imagePart(second.bytes)]); + }); - await vi.waitFor(() => { - expect(driver.state.transcriptEntries).toEqual([ - expect.objectContaining({ - kind: 'user', - content: 'hello', - }), - ]); - }); + it('steers a media item followed by plain text with a blank-line separator', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { + text: `look ${attachment.placeholder}`, + agentId: 'main', + parts: [ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + ], + imageAttachmentIds: [attachment.id], + }, + { text: 'focus on tests', agentId: 'main' }, + ]; - expect(driver.state.transcriptEntries).toEqual([ - expect.objectContaining({ - kind: 'user', - content: 'hello', - }), + driver.state.editor.onCtrlS?.(); + + // The historical '\n\n' item separator merges into the following text + // part (legal for normalizePromptInput) instead of vanishing after a + // media part. + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'look ' }, + { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, + { type: 'text', text: '\n\nfocus on tests' }, ]); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('hello'); - expect(transcript).not.toContain('review'); }); - it('sends pasted image placeholders as image content parts', async () => { - const { driver, session } = await makeDriver(); + it('steers plain text followed by a media item with a blank-line separator', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; + driver.state.appState.streamingPhase = 'waiting'; + driver.streamingUI.setTurnId('1'); const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; const attachment = imageStore.addImage(new Uint8Array([0xaa, 0xbb]), 'image/png', 1, 1); + driver.state.queuedMessages = [ + { text: 'hello', agentId: 'main' }, + { + text: attachment.placeholder, + agentId: 'main', + parts: [{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }], + imageAttachmentIds: [attachment.id], + }, + ]; - driver.handleUserInput(`describe ${attachment.placeholder}`); + driver.state.editor.onCtrlS?.(); - expect(session.prompt).toHaveBeenCalledWith([ - { type: 'text', text: 'describe ' }, + expect(session.steer).toHaveBeenCalledWith([ + { type: 'text', text: 'hello\n\n' }, { type: 'image_url', imageUrl: { url: 'data:image/png;base64,qrs=' } }, ]); - expect(driver.state.transcriptEntries).toEqual([ - expect.objectContaining({ - kind: 'user', - content: `describe ${attachment.placeholder}`, - imageAttachmentIds: [attachment.id], - }), - ]); }); - it('queues editor input instead of prompting while a turn is already streaming', async () => { - const { driver, session, harness } = await makeDriver(); - driver.state.appState.streamingPhase = 'waiting'; - harness.track.mockClear(); + it('shows an error instead of throwing when skill media materialization fails', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + // The pasted video's source file vanished before submit — the cache copy + // throws, and it must surface as a TUI error, not an unhandled rejection. + const missing = imageStore.addVideo('video/quicktime', '/tmp/pythinker-missing-source.mov'); - driver.handleUserInput('queued message'); + ( + driver as unknown as { + sendSkillActivation(s: unknown, name: string, args: string): void; + } + ).sendSkillActivation(session, 'test', `look ${missing.placeholder}`); - expect(session.prompt).not.toHaveBeenCalled(); - expect(driver.state.queuedMessages).toEqual([{ text: 'queued message', agentId: 'main' }]); - expect(driver.state.queueContainer.children.length).toBeGreaterThan(0); - expect(harness.track).toHaveBeenCalledWith('input_queue', undefined); + expect(session.activateSkill).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); }); - it('cancels active streaming from Escape and Ctrl-C editor shortcuts', async () => { - const { driver, session } = await makeDriver(); + it('shows an error instead of throwing when plugin command media materialization fails', async () => { + const activatePluginCommand = vi.fn(async () => {}); + const session = makeSession({ activatePluginCommand }); + const { driver } = await makeDriver(session); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const missing = imageStore.addVideo('video/mp4', '/tmp/pythinker-missing-source.mp4'); - driver.state.appState.streamingPhase = 'waiting'; - driver.state.editor.setText('draft while streaming'); - driver.state.editor.onEscape?.(); + ( + driver as unknown as { + activatePluginCommand(s: unknown, pluginId: string, command: string, args: string): void; + } + ).activatePluginCommand(session, 'plug', 'cmd', missing.placeholder); - expect(session.cancel).toHaveBeenCalledTimes(1); - expect(driver.state.editor.getText()).toBe('draft while streaming'); + expect(activatePluginCommand).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); - session.cancel.mockClear(); + it('keeps the queue and draft intact when steer media extraction fails', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + driver.state.appState.model = 'k2'; driver.state.appState.streamingPhase = 'waiting'; - driver.state.editor.setText(''); - driver.state.editor.onCtrlC?.(); + const imageStore = (driver as unknown as { imageStore: ImageAttachmentStore }).imageStore; + const missing = imageStore.addVideo('video/quicktime', '/tmp/pythinker-missing-source.mov'); + driver.state.queuedMessages = [{ text: 'queued note', agentId: 'main' }]; + driver.state.editor.setText(`look ${missing.placeholder}`); - expect(session.cancel).toHaveBeenCalledTimes(1); - }); + driver.state.editor.onCtrlS?.(); - it('clears streaming editor text before cancelling the active turn on Ctrl-C', async () => { - const { driver, session } = await makeDriver(); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'queued note', agentId: 'main' }]); + expect(driver.state.editor.getText()).toBe(`look ${missing.placeholder}`); + expect(stripSgr(renderTranscript(driver))).toContain('Failed to prepare media attachment'); + }); + it('recalls a queued bash command back into bash mode on Up', async () => { + const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'waiting'; - driver.state.editor.setText('draft while streaming'); + driver.state.queuedMessages = [{ text: 'ls', agentId: 'main', mode: 'bash' }]; + // After a bash command is queued the editor is reset to prompt mode. + driver.state.editor.inputMode = 'prompt'; + driver.state.appState.inputMode = 'prompt'; - driver.state.editor.onCtrlC?.(); + const handled = driver.state.editor.onUpArrowEmpty?.(); - expect(driver.state.editor.getText()).toBe(''); - expect(session.cancel).not.toHaveBeenCalled(); - expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('ls'); + expect(driver.state.editor.inputMode).toBe('bash'); + expect(driver.state.appState.inputMode).toBe('bash'); + expect(driver.state.queuedMessages).toEqual([]); + }); - driver.state.editor.onCtrlC?.(); + it('recalls a queued prompt message in prompt mode on Up', async () => { + const { driver } = await makeDriver(); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.queuedMessages = [{ text: 'hello', agentId: 'main' }]; + driver.state.editor.inputMode = 'bash'; + driver.state.appState.inputMode = 'bash'; - expect(session.cancel).toHaveBeenCalledTimes(1); + const handled = driver.state.editor.onUpArrowEmpty?.(); + + expect(handled).toBe(true); + expect(driver.state.editor.getText()).toBe('hello'); + expect(driver.state.editor.inputMode).toBe('prompt'); + expect(driver.state.appState.inputMode).toBe('prompt'); + expect(driver.state.queuedMessages).toEqual([]); }); - it('dispatches the next queued message after the active turn ends', async () => { - vi.useFakeTimers(); - try { - const { driver } = await makeDriver(); - const sendQueued = vi.fn(); - driver.state.appState.streamingPhase = 'waiting'; - driver.state.appState.streamingStartTime = 1; - driver.streamingUI.setTurnId('1'); - driver.state.queuedMessages = [{ text: 'next' }]; + it('echoes a bash command with a $ prompt in the transcript', async () => { + const runShellCommand = vi.fn(async () => ({ stdout: '', stderr: '', isError: false })); + const session = makeSession({ runShellCommand }); + const { driver, harness } = await makeDriver(session); + driver.state.appState.inputMode = 'bash'; + driver.state.editor.inputMode = 'bash'; - driver.sessionEventHandler.handleEvent( - { - type: 'turn.ended', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - reason: 'completed', - } as Event, - sendQueued, - ); - await vi.runOnlyPendingTimersAsync(); + driver.handleUserInput('ls'); + await Promise.resolve(); - expect(sendQueued).toHaveBeenCalledWith({ text: 'next' }); - expect(driver.state.queuedMessages).toEqual([]); - expect(driver.state.appState.streamingPhase).toBe('idle'); - } finally { - vi.useRealTimers(); - } + expect(harness.track).toHaveBeenCalledWith('shell_command', undefined); + + const transcript = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); + expect(transcript).toContain('$ ls'); + expect(transcript).not.toContain('! ls'); }); it('renders cron fired events as distinct transcript entries', async () => { @@ -2753,7 +4340,7 @@ command = "vim" await vi.runOnlyPendingTimersAsync(); expect(updateSpy).toHaveBeenCalledTimes(1); - expect(updateSpy).toHaveBeenLastCalledWith('abc'); + expect(updateSpy).toHaveBeenLastCalledWith('abc', { transient: true }); } finally { vi.useRealTimers(); } @@ -2850,30 +4437,28 @@ command = "vim" expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); - it('dismisses a running /btw panel before cancelling compaction on Escape', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); - await openBtwPanel(driver, session); - driver.state.appState.isCompacting = true; + it('clears editor text before cancelling compaction on Ctrl-C', async () => { + const { driver, session } = await makeDriver(); + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + vi.fn(), + ); + driver.state.editor.setText('draft while compacting'); - driver.state.editor.onEscape?.(); + driver.state.editor.onCtrlC?.(); - expect(session.cancel).toHaveBeenCalledOnce(); + expect(driver.state.editor.getText()).toBe(''); expect(session.cancelCompaction).not.toHaveBeenCalled(); - expect(driver.state.btwPanelContainer.children).toHaveLength(0); - }); - - it('cancels a running /btw question before cancelling compaction on Ctrl-C', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); - await openBtwPanel(driver, session); - driver.state.appState.isCompacting = true; + expect(driver.state.appState.isCompacting).toBe(true); driver.state.editor.onCtrlC?.(); - expect(session.cancel).toHaveBeenCalledOnce(); - expect(session.cancelCompaction).not.toHaveBeenCalled(); - expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(session.cancelCompaction).toHaveBeenCalledTimes(1); }); it('dispatches the next queued message after compaction is cancelled', async () => { @@ -2900,7 +4485,7 @@ command = "vim" } as Event, sendQueued, ); - await vi.runOnlyPendingTimersAsync(); + await vi.runAllTimersAsync(); expect(driver.state.appState.isCompacting).toBe(false); expect(driver.state.appState.streamingPhase).toBe('idle'); @@ -2914,6 +4499,83 @@ command = "vim" } }); + it('stores the live compaction summary and expands it with tool output expansion', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const collapsed = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(collapsed).toContain('Compaction complete'); + expect(collapsed).not.toContain('Keep the src/tui compaction notes.'); + + driver.state.editor.onToggleToolExpand?.(); + + const expanded = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(expanded).toContain('Keep the src/tui compaction notes.'); + }); + + it('honors existing tool output expansion when a compaction block is created', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); + + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.started', + agentId: 'main', + sessionId: 'ses-1', + trigger: 'manual', + } as Event, + sendQueued, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'compaction.completed', + agentId: 'main', + sessionId: 'ses-1', + result: { + summary: 'Keep the src/tui compaction notes.', + compactedCount: 4, + tokensBefore: 120, + tokensAfter: 24, + }, + } as Event, + sendQueued, + ); + + const transcript = driver.state.transcriptContainer.render(120).map(stripSgr).join('\n'); + expect(transcript).toContain('Compaction complete'); + expect(transcript).toContain('Keep the src/tui compaction notes.'); + }); + it('renders an error instead of prompting when no model is selected', async () => { const { driver, session } = await makeDriver(); driver.state.appState.model = ''; @@ -2951,50 +4613,206 @@ command = "vim" await vi.waitFor(() => { expect(driver.state.appState.streamingPhase).toBe('idle'); }); - expect(driver.state.livePane.mode).toBe('idle'); - expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); + expect(driver.state.livePane.mode).toBe('idle'); + expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); + }); + + it('starts /btw through a forked side agent without changing the main busy state', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + harness.track.mockClear(); + driver.state.appState.streamingPhase = 'composing'; + driver.state.livePane.mode = 'thinking'; + + driver.handleUserInput('/btw What are you working on right now?'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('composing'); + expect(driver.state.livePane.mode).toBe('thinking'); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); + }); + + it('opens /btw without a question and sends the first panel input to a side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/btw'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + expect(session.prompt).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + + driver.handleUserInput('What are you working on right now?'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?'); + }); + + it('sends /btw panel input with inline skills via promptWithSkills (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + + driver.handleUserInput('/btw'); + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); + + driver.handleUserInput('check /skill:review'); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith('check /skill:review', [ + { name: 'review' }, + ]); + }); + expect(session.prompt).not.toHaveBeenCalled(); + }); + + it('activates inline skills in the initial /btw prompt (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); + + driver.handleUserInput('/btw check this /skill:review'); + + await vi.waitFor(() => { + expect(session.promptWithSkills).toHaveBeenCalledWith('check this /skill:review', [ + { name: 'review' }, + ]); + }); + expect(session.prompt).not.toHaveBeenCalled(); }); - it('starts /btw through a forked side agent without changing the main busy state', async () => { - const session = makeSession(); - const { driver, harness } = await makeDriver(session); - harness.track.mockClear(); - driver.state.appState.streamingPhase = 'composing'; - driver.state.livePane.mode = 'thinking'; + it('activates a leading skill token in the initial /btw prompt (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - driver.handleUserInput('/btw What are you working on right now?'); + driver.handleUserInput('/btw /skill:review check this'); await vi.waitFor(() => { - expect(session.startBtw).toHaveBeenCalledWith(); - }); - await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + expect(session.promptWithSkills).toHaveBeenCalledWith('/skill:review check this', [ + { name: 'review' }, + ]); }); - expect(session.steer).not.toHaveBeenCalled(); - expect(driver.state.appState.streamingPhase).toBe('composing'); - expect(driver.state.livePane.mode).toBe('thinking'); - expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); + expect(session.prompt).not.toHaveBeenCalled(); }); - it('opens /btw without a question and sends the first panel input to a side agent', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); + it('keeps /btw as the leading command when its prompt mentions multiple skills (v2 engine)', async () => { + const session = makeSession({ + id: 'ses-lazy', + listSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + }); + const startupInput: PythinkerTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver( + session, + { + listWorkspaceSkills: vi.fn(async () => [ + { name: 'review', description: 'Review skill', path: '/tmp/review', source: 'user' }, + { name: 'security', description: 'Security skill', path: '/tmp/security', source: 'user' }, + ]), + listPluginCommands: vi.fn(async () => []), + }, + startupInput, + ); + await ( + driver as unknown as { refreshSkillCommands(): Promise<void> } + ).refreshSkillCommands(); - driver.handleUserInput('/btw'); + driver.handleUserInput('/btw check /skill:review /skill:security'); await vi.waitFor(() => { expect(session.startBtw).toHaveBeenCalledWith(); }); - expect(session.prompt).not.toHaveBeenCalled(); - expect(stripSgr(renderBtwPanel(driver))).toContain('Ready for a side question...'); - - driver.handleUserInput('What are you working on right now?'); - await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('What are you working on right now?'); + expect(session.promptWithSkills).toHaveBeenCalledWith('check /skill:review /skill:security', [ + { name: 'review' }, + { name: 'security' }, + ]); }); - expect(session.steer).not.toHaveBeenCalled(); - expect(stripSgr(renderBtwPanel(driver))).toContain('Q: What are you working on right now?'); + expect(session.prompt).not.toHaveBeenCalled(); }); it('cancels an unused /btw side agent when closing an empty panel', async () => { @@ -3045,14 +4863,16 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); const panel = stripSgr(renderBtwPanel(driver)); - const editorLine = stripSgr(driver.state.editor.render(80)[1] ?? ''); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); expect(panel).toContain('BTW ─ Esc close'); expect(panel).not.toContain('ctrl+o expand'); - expect(editorLine.slice(0, 2)).toBe('❯ '); + expect(editorTopBorder.startsWith('├')).toBe(true); + expect(editorTopBorder.endsWith('┤')).toBe(true); driver.state.editor.handleInput('/'); - const highlightedEditorLine = stripSgr(driver.state.editor.render(80)[1] ?? ''); - expect(highlightedEditorLine.slice(0, 2)).toBe('❯ '); + const highlightedEditorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(highlightedEditorTopBorder.startsWith('╭')).toBe(true); + expect(highlightedEditorTopBorder.endsWith('╮')).toBe(true); expect(panel).not.toContain('BTW done'); expect(panel).not.toContain('BTW running'); expect(panel).not.toContain('BTW failed'); @@ -3066,7 +4886,7 @@ command = "vim" expect(transcript).not.toContain('I am implementing the dedicated /btw panel.'); }); - it('keeps the /btw panel above MCP status, the status bar, and the input', async () => { + it('keeps the /btw panel closest to the input after later transcript output', async () => { const session = makeSession(); const { driver } = await makeDriver(session); await openBtwPanel(driver, session); @@ -3118,14 +4938,8 @@ command = "vim" const panel = stripSgr(renderBtwPanel(driver)); const rootChildren = driver.state.ui.children; expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( - rootChildren.indexOf(driver.state.mcpStatusContainer) - 1, - ); - expect(rootChildren.indexOf(driver.state.mcpStatusContainer)).toBe( rootChildren.indexOf(driver.state.editorContainer) - 1, ); - expect(rootChildren.indexOf(driver.state.editorContainer)).toBe( - rootChildren.indexOf(driver.state.statusBarContainer) - 1, - ); expect(transcript).toContain('main answer after btw'); expect(transcript).not.toContain('side answer'); expect(panel).toContain('BTW'); @@ -3161,35 +4975,6 @@ command = "vim" expect(panel).toContain('line7'); }); - it('renders Markdown in the last two wrapped /btw thinking rows', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); - await openBtwPanel(driver, session); - const segments = Array.from({ length: 30 }, (_, index) => - `seg${String(index).padStart(2, '0')}` - ); - - driver.sessionEventHandler.handleEvent( - { - type: 'thinking.delta', - agentId: 'agent-btw', - sessionId: 'ses-1', - turnId: 0, - delta: `**start** ${segments.join(' ')} **finish**`, - } as Event, - () => {}, - ); - - const lines = getMountedBtwPanel(driver).render(36).map(stripSgr); - const thinkingRows = lines.filter((line) => /seg\d\d/.test(line)); - const output = lines.join('\n'); - expect(thinkingRows).toHaveLength(2); - expect(output).toContain('seg29'); - expect(output).toContain('finish'); - expect(output).not.toContain('seg00'); - expect(output).not.toContain('**'); - }); - it('renders /btw body at its actual content height when under the cap', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -3327,8 +5112,9 @@ command = "vim" expect(session.cancel).toHaveBeenCalledOnce(); expect(driver.state.btwPanelContainer.children).toHaveLength(0); expect(requestRender.mock.calls.at(-1)).toEqual([true]); - const editorLine = stripSgr(driver.state.editor.render(80)[1] ?? ''); - expect(editorLine.slice(0, 2)).toBe('❯ '); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(editorTopBorder.startsWith('╭')).toBe(true); + expect(editorTopBorder.endsWith('╮')).toBe(true); expect(driver.state.editor.focused).toBe(true); }); @@ -3599,701 +5385,991 @@ command = "vim" expect(renderedPanel).toContain('answer from new side agent'); }); - it('does not run /btw without a selected model', async () => { - const { driver, session } = await makeDriver(); - - driver.state.appState.model = ''; - driver.handleUserInput('/btw'); - expect(session.startBtw).not.toHaveBeenCalled(); - expect(driver.state.btwPanelContainer.children).toHaveLength(0); - expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + it('does not run /btw without a selected model', async () => { + const { driver, session } = await makeDriver(); + + driver.state.appState.model = ''; + driver.handleUserInput('/btw'); + expect(session.startBtw).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + + driver.handleUserInput('/btw What are you doing now?'); + + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + }); + + it('applies the effective thinking effort from status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + model: 'turbo', + thinkingEffort: 'mid', + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + }); + + it('renders dynamic_workflow mode markers from /dynamic_workflow commands, not tool-triggered status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + dynamicWorkflowMode: true, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.dynamicWorkflowMode).toBe(true); + expect(stripSgr(renderTranscript(driver))).not.toContain('DynamicWorkflow activated'); + + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'DynamicWorkflow activated')).toBe(0); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + dynamicWorkflowMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.dynamicWorkflowMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).not.toContain('DynamicWorkflow deactivated'); + expect(transcript).not.toContain('DynamicWorkflow ended'); + + expect(countOccurrences(transcript, 'DynamicWorkflow activated')).toBe(0); + expect(countOccurrences(transcript, 'DynamicWorkflow deactivated')).toBe(0); + expect(countOccurrences(transcript, 'DynamicWorkflow ended')).toBe(0); + }); + + it('renders an ended marker when a one-shot /dynamic_workflow task exits', async () => { + const { driver, session } = await makeDriver(undefined); + driver.state.appState.permissionMode = 'auto'; + + driver.handleUserInput('/dynamic_workflow Ship feature X'); + + await vi.waitFor(() => { + expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); + }); + await vi.waitFor(() => { + expect(countOccurrences(stripSgr(renderTranscript(driver)), 'DynamicWorkflow activated')).toBe(1); + }); + let transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'DynamicWorkflow activated')).toBe(1); + expect(transcript).not.toContain('DynamicWorkflow ended'); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + dynamicWorkflowMode: false, + } as Event, + vi.fn(), + ); + + expect(driver.state.appState.dynamicWorkflowMode).toBe(false); + transcript = stripSgr(renderTranscript(driver)); + expect(countOccurrences(transcript, 'DynamicWorkflow activated')).toBe(1); + expect(countOccurrences(transcript, 'DynamicWorkflow ended')).toBe(1); + expect(transcript).not.toContain('DynamicWorkflow deactivated'); + }); + + it('queues Ctrl-S input instead of steering while /init is running', async () => { + let resolveInit: (() => void) | undefined; + const session = makeSession({ + init: vi.fn( + () => + new Promise<void>((resolve) => { + resolveInit = resolve; + }), + ), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/init'); + await vi.waitFor(() => { + expect(session.init).toHaveBeenCalledTimes(1); + }); + + driver.state.editor.setText('apply after init'); + driver.state.editor.onCtrlS?.(); + + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.queuedMessages).toEqual([{ text: 'apply after init', agentId: 'main' }]); + expect(stripSgr(driver.state.queueContainer.render(120).join('\n'))).not.toContain( + 'ctrl-s to steer immediately', + ); + + resolveInit?.(); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('apply after init', { promptId: undefined }); + }); + expect(driver.state.queuedMessages).toEqual([]); + }); + + it('cancels the active /init request through the session', async () => { + let resolveInit: (() => void) | undefined; + const session = makeSession({ + init: vi.fn( + () => + new Promise<void>((resolve) => { + resolveInit = resolve; + }), + ), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/init'); + await vi.waitFor(() => { + expect(session.init).toHaveBeenCalledTimes(1); + }); + + driver.state.editor.onEscape?.(); + + await vi.waitFor(() => { + expect(session.cancel).toHaveBeenCalledTimes(1); + }); + + resolveInit?.(); + }); + + it('does not run /init when no model is selected', async () => { + const { driver, session } = await makeDriver(); + driver.state.appState.model = ''; + + driver.handleUserInput('/init'); + + expect(session.init).not.toHaveBeenCalled(); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); + }); + + it('shows the login prompt for auth.login_required session errors', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'error', + agentId: 'main', + sessionId: 'ses-1', + code: 'auth.login_required', + message: 'OAuth provider credentials were rejected.', + retryable: false, + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('OAuth login expired. Send /login to login.'); + expect(transcript).not.toContain('[auth.login_required]'); + expect(transcript).not.toContain('/export-debug-zip'); + }); + + it('shows a programmatic abort reason instead of reporting a user interruption', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + reason: 'aborted', + message: 'Tool execution timed out', + } as Event, + vi.fn(), + ); + + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Error: Tool execution timed out'); + expect(transcript).not.toContain('Interrupted by user'); + }); + + it('keeps unmessaged aborted events compatible with user interruptions', async () => { + const { driver } = await makeDriver(); - driver.handleUserInput('/btw What are you doing now?'); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.step.interrupted', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + step: 1, + reason: 'aborted', + } as Event, + vi.fn(), + ); - expect(session.startBtw).not.toHaveBeenCalled(); - expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + expect(stripSgr(renderTranscript(driver))).toContain('Interrupted by user'); }); - it('renders Dynamic Workflow markers from /workflow commands, not tool-triggered status updates', async () => { + it('appends the /export-debug-zip hint beneath session error messages', async () => { const { driver } = await makeDriver(); driver.sessionEventHandler.handleEvent( { - type: 'agent.status.updated', + type: 'error', agentId: 'main', sessionId: 'ses-1', - dynamicWorkflowMode: true, + code: 'compaction.failed', + message: "APIStatusError: 400 the message at position 82 with role 'assistant' must not be empty", + retryable: false, } as Event, vi.fn(), ); - expect(driver.state.appState.dynamicWorkflowMode).toBe(true); - expect(stripSgr(renderTranscript(driver))).not.toContain('Dynamic Workflow activated'); + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain('Error: [compaction.failed]'); + expect(transcript).toContain('If this persists, run `/export-debug-zip`'); + expect(transcript).toContain("Please don't share it publicly"); + expect(transcript).not.toContain('pythinker export'); + }); - let transcript = stripSgr(renderTranscript(driver)); - expect(countOccurrences(transcript, 'Dynamic Workflow activated')).toBe(0); + it('shows concise provider filter text for filtered session errors', async () => { + const { driver } = await makeDriver(); + const verboseMessage = + 'The API returned a response containing only thinking content without any text or tool calls. ' + + 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + + 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + + 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; driver.sessionEventHandler.handleEvent( { - type: 'agent.status.updated', + type: 'error', agentId: 'main', sessionId: 'ses-1', - dynamicWorkflowMode: false, + code: 'provider.api_error', + message: verboseMessage, + details: { + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }, + retryable: true, } as Event, vi.fn(), ); - expect(driver.state.appState.dynamicWorkflowMode).toBe(false); - transcript = stripSgr(renderTranscript(driver)); - expect(transcript).not.toContain('Dynamic Workflow deactivated'); - expect(transcript).not.toContain('Dynamic Workflow ended'); - - expect(countOccurrences(transcript, 'Dynamic Workflow activated')).toBe(0); - expect(countOccurrences(transcript, 'Dynamic Workflow deactivated')).toBe(0); - expect(countOccurrences(transcript, 'Dynamic Workflow ended')).toBe(0); + const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); + expect(transcript).toContain( + 'Error: [provider.api_error] Provider filtered the response before visible output', + ); + expect(transcript).toContain('finishReason=filtered'); + expect(transcript).toContain('rawFinishReason=content_filter'); + expect(transcript).not.toContain('only thinking content'); + expect(transcript).not.toContain('token budget'); + expect(transcript).not.toContain('stream was interrupted'); }); - it('renders an ended marker when a one-shot /workflow task exits', async () => { - const { driver, session } = await makeDriver(undefined); - driver.state.appState.permissionMode = 'auto'; - - driver.handleUserInput('/workflow Ship feature X'); - - await vi.waitFor(() => { - expect(session.setDynamicWorkflowMode).toHaveBeenCalledWith(true, 'task'); - }); - await vi.waitFor(() => { - expect(countOccurrences(stripSgr(renderTranscript(driver)), 'Dynamic Workflow activated')).toBe(1); - }); - let transcript = stripSgr(renderTranscript(driver)); - expect(countOccurrences(transcript, 'Dynamic Workflow activated')).toBe(1); - expect(transcript).not.toContain('Dynamic Workflow ended'); + it('skips the /export-debug-zip hint when no active session id is set', async () => { + const { driver } = await makeDriver(); + driver.state.appState.sessionId = ''; driver.sessionEventHandler.handleEvent( { - type: 'agent.status.updated', + type: 'error', agentId: 'main', - sessionId: 'ses-1', - dynamicWorkflowMode: false, + sessionId: '', + code: 'compaction.failed', + message: 'boom', + retryable: false, } as Event, vi.fn(), ); - expect(driver.state.appState.dynamicWorkflowMode).toBe(false); - transcript = stripSgr(renderTranscript(driver)); - expect(countOccurrences(transcript, 'Dynamic Workflow activated')).toBe(1); - expect(countOccurrences(transcript, 'Dynamic Workflow ended')).toBe(1); - expect(transcript).not.toContain('Dynamic Workflow deactivated'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Error: [compaction.failed]'); + expect(transcript).not.toContain('/export-debug-zip'); }); - it('queues Ctrl-S input instead of steering while /init is running', async () => { - let resolveInit: (() => void) | undefined; + it('shows ExitPlanMode plan only in the current-plan card during approval', async () => { + const planContent = '# No Duplicate Plan\n\n- Do the non-duplicated plan work'; const session = makeSession({ - init: vi.fn( - () => - new Promise<void>((resolve) => { - resolveInit = resolve; - }), - ), + getPlan: vi.fn(async () => ({ + id: 'no-duplicate-plan', + content: planContent, + path: '/tmp/no-duplicate-plan.md', + })), }); const { driver } = await makeDriver(session); - driver.handleUserInput('/init'); - await vi.waitFor(() => { - expect(session.init).toHaveBeenCalledTimes(1); - }); - - driver.state.editor.setText('apply after init'); - driver.state.editor.onCtrlS?.(); - - expect(session.steer).not.toHaveBeenCalled(); - expect(driver.state.queuedMessages).toEqual([{ text: 'apply after init', agentId: 'main' }]); - expect(stripSgr(driver.state.queueContainer.render(120).join('\n'))).not.toContain( - 'ctrl-s to steer immediately', + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_exit_plan', + name: 'ExitPlanMode', + args: {}, + } as Event, + vi.fn(), ); - resolveInit?.(); - await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith('apply after init'); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Current plan'); + expect(countOccurrences(transcript, 'non-duplicated plan work')).toBe(1); }); - expect(driver.state.queuedMessages).toEqual([]); - }); - it('cancels the active /init request through the session', async () => { - let resolveInit: (() => void) | undefined; - const session = makeSession({ - init: vi.fn( - () => - new Promise<void>((resolve) => { - resolveInit = resolve; - }), - ), + const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as + | ((request: ApprovalRequest) => Promise<ApprovalResponse>) + | undefined; + if (approvalHandler === undefined) throw new Error('expected approval handler'); + void approvalHandler({ + turnId: 1, + toolCallId: 'call_exit_plan', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { + kind: 'plan_review', + plan: planContent, + path: '/tmp/no-duplicate-plan.md', + }, }); - const { driver } = await makeDriver(session); - driver.handleUserInput('/init'); await vi.waitFor(() => { - expect(session.init).toHaveBeenCalledTimes(1); + const approval = stripSgr(driver.state.editorContainer.render(120).join('\n')); + expect(approval).toContain('Ready to build with this plan?'); + expect(approval).not.toContain('non-duplicated plan work'); + expect(approval).not.toContain('/tmp/no-duplicate-plan.md'); }); + }); - driver.state.editor.onEscape?.(); + it('renders AgentDynamicWorkflow progress in the transcript instead of the tool-card body', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); - await vi.waitFor(() => { - expect(session.cancel).toHaveBeenCalledTimes(1); - }); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); - resolveInit?.(); - }); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_dynamic_workflow', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + dynamicWorkflowIndex: 1, + runInBackground: false, + } as Event, + sendQueued, + ); - it('does not run /init when no model is selected', async () => { - const { driver, session } = await makeDriver(); - driver.state.appState.model = ''; + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_dynamic_workflow', + subagentId: 'agent-2', + subagentName: 'coder', + description: 'Review changed files #2 (coder)', + dynamicWorkflowIndex: 2, + runInBackground: false, + } as Event, + sendQueued, + ); - driver.handleUserInput('/init'); + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/a.ts' }, + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); - expect(session.init).not.toHaveBeenCalled(); - expect(driver.state.transcriptContainer.render(120).join('\n')).toContain('LLM not set'); - }); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + delta: 'Reviewing src/a.ts and checking imports for regressions in detail', + } as Event, + sendQueued, + ); + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.suspended', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + reason: 'Provider rate limit; subagent requeued for retry.', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Queued...'); + expect(transcript).not.toContain('Provider rate limit'); + expect(transcript).not.toContain('Failed'); + + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.started', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-1', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('01 ['); + expect(transcript).not.toContain('Suspended'); - it('shows the login prompt for auth.login_required session errors', async () => { - const { driver } = await makeDriver(); + vi.mocked(driver.state.ui.requestRender).mockClear(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-1', + sessionId: 'ses-1', + turnId: 2, + reason: 'completed', + } as Event, + sendQueued, + ); + expect(driver.state.ui.requestRender).toHaveBeenCalled(); + + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent DynamicWorkflow'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 ['); + expect(transcript).toContain('Reviewing src/a.ts'); + expect(transcript).not.toContain('Completed'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('002 ['); driver.sessionEventHandler.handleEvent( { - type: 'error', + type: 'subagent.completed', agentId: 'main', sessionId: 'ses-1', - code: 'auth.login_required', - message: 'OAuth provider credentials were rejected.', - retryable: false, + subagentId: 'agent-1', + resultSummary: 'Imports are stable', } as Event, - vi.fn(), + sendQueued, ); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('OAuth login expired. Send /login to login.'); - expect(transcript).not.toContain('[auth.login_required]'); - expect(transcript).not.toContain('/export-debug-zip'); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('✓ Imports are stable'); + expect(transcript).not.toContain('Completed'); }); - it('appends the /export-debug-zip hint beneath session error messages', async () => { + it('marks only core user-cancellation subagent failures as cancelled', async () => { const { driver } = await makeDriver(); + const sendQueued = vi.fn(); driver.sessionEventHandler.handleEvent( { - type: 'error', + type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', - code: 'compaction.failed', - message: "APIStatusError: 400 the message at position 82 with role 'assistant' must not be empty", - retryable: false, + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, } as Event, - vi.fn(), + sendQueued, ); - const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); - expect(transcript).toContain('Error: [compaction.failed]'); - expect(transcript).toContain('If this persists, run `/export-debug-zip`'); - expect(transcript).toContain("Please don't share it publicly"); - expect(transcript).not.toContain('pythinker export'); - }); - - it('shows concise provider filter text for filtered session errors', async () => { - const { driver } = await makeDriver(); - const verboseMessage = - 'The API returned a response containing only thinking content without any text or tool calls. ' + - 'This usually indicates the stream was interrupted or the output token budget was exhausted ' + - 'during reasoning. Provider stop details: finishReason=filtered, rawFinishReason=content_filter. ' + - 'The provider filtered the response before visible output was emitted. Provider: example-provider, model: example-model'; + for (const [index, subagentId] of ['agent-1', 'agent-2'].entries()) { + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_dynamic_workflow', + subagentId, + subagentName: 'coder', + description: `Review changed files #${String(index + 1)} (coder)`, + dynamicWorkflowIndex: index + 1, + runInBackground: false, + } as Event, + sendQueued, + ); + } driver.sessionEventHandler.handleEvent( { - type: 'error', + type: 'subagent.failed', agentId: 'main', sessionId: 'ses-1', - code: 'provider.api_error', - message: verboseMessage, - details: { - finishReason: 'filtered', - rawFinishReason: 'content_filter', - }, - retryable: true, + subagentId: 'agent-1', + error: 'Aborted by the user', } as Event, - vi.fn(), + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-1', + subagentId: 'agent-2', + error: 'The user manually interrupted this subagent x.', + } as Event, + sendQueued, ); const transcript = stripSgr(driver.state.transcriptContainer.render(200).join('\n')); - expect(transcript).toContain( - 'Error: [provider.api_error] Provider filtered the response before visible output', - ); - expect(transcript).toContain('finishReason=filtered'); - expect(transcript).toContain('rawFinishReason=content_filter'); - expect(transcript).not.toContain('only thinking content'); - expect(transcript).not.toContain('token budget'); - expect(transcript).not.toContain('stream was interrupted'); + expect(transcript).toContain('⊘ Cancelled.'); + expect(transcript).toContain('✗ The user manually interrupted this subagent x.'); }); - it('skips the /export-debug-zip hint when no active session id is set', async () => { + it('shows the spawned model on the subagent card at spawn, mapped through the model catalog', async () => { const { driver } = await makeDriver(); - driver.state.appState.sessionId = ''; + const sendQueued = vi.fn(); + driver.state.appState.availableModels = { + 'k2-cheap': { + provider: 'managed:pythinker-code', + model: 'kimi-k2-cheap', + maxContextSize: 100_000, + displayName: 'Kimi K2 Cheap', + capabilities: [], + }, + }; driver.sessionEventHandler.handleEvent( { - type: 'error', + type: 'subagent.spawned', agentId: 'main', - sessionId: '', - code: 'compaction.failed', - message: 'boom', - retryable: false, + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', } as Event, - vi.fn(), + sendQueued, ); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Error: [compaction.failed]'); - expect(transcript).not.toContain('/export-debug-zip'); + expect(stripSgr(renderTranscript(driver))).toContain('Kimi K2 Cheap'); }); - it('shows ExitPlanMode plan only in the current-plan card during approval', async () => { - const planContent = '# No Duplicate Plan\n\n- Do the non-duplicated plan work'; - const session = makeSession({ - getPlan: vi.fn(async () => ({ - id: 'no-duplicate-plan', - content: planContent, - path: '/tmp/no-duplicate-plan.md', - })), - }); - const { driver } = await makeDriver(session); + it('falls back to the raw alias when the spawned model is missing from the catalog', async () => { + const { driver } = await makeDriver(); + const sendQueued = vi.fn(); driver.sessionEventHandler.handleEvent( { - type: 'tool.call.started', + type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', - turnId: 1, - toolCallId: 'call_exit_plan', - name: 'ExitPlanMode', - args: {}, + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', } as Event, - vi.fn(), + sendQueued, ); - await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Current plan'); - expect(countOccurrences(transcript, 'non-duplicated plan work')).toBe(1); - }); - - const approvalHandler = vi.mocked(session.setApprovalHandler).mock.calls[0]?.[0] as - | ((request: ApprovalRequest) => Promise<ApprovalResponse>) - | undefined; - if (approvalHandler === undefined) throw new Error('expected approval handler'); - void approvalHandler({ - turnId: 1, - toolCallId: 'call_exit_plan', - toolName: 'ExitPlanMode', - action: 'Review plan', - display: { - kind: 'plan_review', - plan: planContent, - path: '/tmp/no-duplicate-plan.md', - }, - }); - - await vi.waitFor(() => { - const approval = stripSgr(driver.state.editorContainer.render(120).join('\n')); - expect(approval).toContain('Ready to build with this plan?'); - expect(approval).not.toContain('non-duplicated plan work'); - expect(approval).not.toContain('/tmp/no-duplicate-plan.md'); - }); + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); }); - it('routes Dynamic Workflow mission control, drains early lifecycle events, and preserves index order', async () => { + it('shows any concrete spawned effort, same as the session or not', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); + driver.state.appState.thinkingEffort = 'high'; - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_dynamic_workflow', name: 'DynamicWorkflow', - args: { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'] }, - } as Event); - expect(driver.state.footerState.activity.phase).toBe('hidden'); - expect(renderActivity(driver)).toBe(''); - dispatch({ - type: 'subagent.started', agentId: 'main', sessionId: 'ses-1', subagentId: 'agent-2', - parentToolCallId: 'call_dynamic_workflow', - } as Event); - dispatch({ - type: 'subagent.completed', agentId: 'main', sessionId: 'ses-1', subagentId: 'agent-2', - parentToolCallId: 'call_dynamic_workflow', resultSummary: 'Completed before spawn', - } as Event); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', parentToolCallId: 'call_dynamic_workflow', - subagentId: 'agent-2', subagentName: 'coder', dynamicWorkflowIndex: 2, runInBackground: false, - } as Event); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', parentToolCallId: 'call_dynamic_workflow', - subagentId: 'agent-1', subagentName: 'coder', dynamicWorkflowIndex: 1, runInBackground: false, - } as Event); - dispatch({ type: 'subagent.started', agentId: 'main', sessionId: 'ses-1', subagentId: 'agent-1' } as Event); - dispatch({ - type: 'assistant.delta', agentId: 'agent-1', sessionId: 'ses-1', turnId: 2, - delta: 'Reading src/a.ts', - } as Event); - dispatch({ - type: 'subagent.failed', agentId: 'main', sessionId: 'ses-1', subagentId: 'agent-2', error: 'Late failure', - } as Event); + // Same level as the main session — still shown (level info is level info). + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + model: 'k2-cheap', + thinkingEffort: 'high', + } as Event, + sendQueued, + ); + expect(stripSgr(renderTranscript(driver))).toContain('· high'); + }); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Dynamic Workflow'); - // The running row advances through the approved progress-glyph frames. - expect(transcript).toMatch(/001\s+[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+RUN\s+src\/a.ts/u); - expect(transcript).toMatch(/002\s+✓\s+DONE\s+src\/b.ts/u); - expect(transcript).toMatch(/Orchestrating\s+1\/2 complete/u); - expect(transcript).not.toContain('━'); - expect(transcript).toContain('Completed before spawn'); - expect(transcript).not.toContain('Late failure'); - expect(driver.streamingUI.getToolComponent('call_dynamic_workflow')).toBeUndefined(); - }); - - it('surfaces a workflow.warning on the live Dynamic Workflow mission control', async () => { + it('hides the boolean effort states on and off', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_warn_workflow', name: 'DynamicWorkflow', - args: { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'] }, - } as Event); - dispatch({ - type: 'workflow.warning', agentId: 'main', sessionId: 'ses-1', - workflowRunId: 'run-1', parentToolCallId: 'call_warn_workflow', - agentCount: 12, threshold: 8, - message: 'This Dynamic Workflow will launch 12 subagents, above the advisory ceiling of 8; the run is proceeding anyway.', - } as Event); + for (const effort of ['on', 'off']) { + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: `call_agent_${effort}`, + subagentId: `agent-${effort}`, + subagentName: 'explore', + description: `explore ${effort}`, + runInBackground: false, + model: 'k2-cheap', + thinkingEffort: effort, + } as Event, + sendQueued, + ); + } const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Dynamic Workflow'); - expect(transcript).toContain('12 subagents'); - expect(transcript).toContain('advisory ceiling of 8'); + expect(transcript).not.toContain('· on'); + expect(transcript).not.toContain('· off'); }); - it('falls back to the status line when a workflow.warning has no mission control', async () => { + it('keeps the child status update as the model fallback when spawned omits it', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const showStatus = vi - .spyOn(driver as unknown as { showStatus: (message: string, color?: unknown) => void }, 'showStatus') - .mockImplementation(() => {}); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - - dispatch({ - type: 'workflow.warning', agentId: 'main', sessionId: 'ses-1', - workflowRunId: 'run-1', parentToolCallId: 'call_retired_workflow', - agentCount: 12, threshold: 8, - message: 'This Dynamic Workflow will launch 12 subagents, above the advisory ceiling of 8; the run is proceeding anyway.', - } as Event); - expect(showStatus).toHaveBeenCalledWith( - 'This Dynamic Workflow will launch 12 subagents, above the advisory ceiling of 8; the run is proceeding anyway.', - 'warning', + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: false, + } as Event, + sendQueued, ); - }); - - it('mounts the framed workflow on the first named delta before the denominator is known', async () => { - const { driver } = await makeDriver(makeSession(), {}, 'fixed'); - driver.state.editorContainer.addChild(driver.state.editor); - driver.state.ui.setFocus(driver.state.editor); - const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - - dispatch({ - type: 'tool.call.delta', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_streaming_workflow', name: 'DynamicWorkflow', - argumentsPart: '{"description":"Review changed files","items":["src/a.ts","src/b', - } as Event); - - expect(driver.state.transcriptContainer.children.some( - (child) => child instanceof DynamicWorkflowMissionControlComponent, - )).toBe(true); - expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); - expect(driver.state.editor.focused).toBe(true); - let transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('╭─ Dynamic Workflow'); - expect(transcript).toContain('Waiting for delegated agents'); - expect(transcript).not.toMatch(/Orchestrating[^\n]*\b\d+%/u); - expect(transcript).not.toContain('━'); - expect(transcript).toContain('001'); - - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_streaming_workflow', name: 'DynamicWorkflow', - args: { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'] }, - } as Event); + expect(stripSgr(renderTranscript(driver))).not.toContain('k2-cheap'); - transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('0/2 complete'); - expect(transcript).toMatch(/001\s+○\s+WAIT\s+src\/a.ts/u); + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'agent-1', + sessionId: 'ses-1', + model: 'k2-cheap', + } as Event, + sendQueued, + ); + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); }); - it('keeps terminal Dynamic Workflow results static and does not fabricate child failures', async () => { + it('shows the spawned model in the dynamic_workflow panel header at spawn', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_terminal_workflow', name: 'DynamicWorkflow', - args: { description: 'Review changed files', items: ['src/a.ts', 'src/b.ts'] }, - } as Event); - dispatch({ - type: 'tool.result', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_terminal_workflow', isError: false, - output: [ - '<dynamic_workflow_result>', - '<summary>completed: 1, failed: 1, aborted: 0</summary>', - '<subagent index="1" outcome="completed">Imports are stable.</subagent>', - '<subagent index="2" outcome="failed">Agent timed out after 30s.</subagent>', - '</dynamic_workflow_result>', - ].join('\n'), - } as Event); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts'], + }, + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_dynamic_workflow', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + dynamicWorkflowIndex: 1, + runInBackground: false, + model: 'k2-cheap', + } as Event, + sendQueued, + ); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('✓ Completed'); - expect(transcript).toMatch(/001\s+✓\s+DONE\s+src\/a.ts/u); - expect(transcript).toMatch(/002\s+×\s+FAIL\s+src\/b.ts/u); - expect(transcript).toContain('Agent timed out after 30s.'); - expect(transcript).not.toContain('⠋ Orchestrating'); + const progress = driver.state.transcriptContainer.children.find( + (child): child is AgentDynamicWorkflowProgressComponent => child instanceof AgentDynamicWorkflowProgressComponent, + ); + if (progress === undefined) throw new Error('expected AgentDynamicWorkflow progress'); + expect(stripSgr(progress.render(118).join('\n'))).toContain('k2-cheap'); }); - it.each(['turn cleanup', 'session runtime reset', 'session error cleanup'] as const)( - 'does not drain old-generation lifecycle at %s into a later workflow with the same agent id', - async (cleanup) => { + it('includes the spawned model in the background-agent transcript entry', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - dispatch({ - type: 'subagent.completed', agentId: 'main', sessionId: 'ses-1', subagentId: 'reused-agent', - parentToolCallId: 'call_old_workflow', resultSummary: 'must not leak', - } as Event); - if (cleanup === 'turn cleanup') { - dispatch({ type: 'turn.started', agentId: 'main', sessionId: 'ses-1', turnId: 2 } as Event); - } else if (cleanup === 'session runtime reset') { - driver.sessionEventHandler.resetRuntimeState(); - } else { - dispatch({ - type: 'error', + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', - code: 'provider.connection_error', - message: 'Provider disconnected', - retryable: false, - } as Event); - } - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 2, - toolCallId: 'call_cleanup_workflow', name: 'DynamicWorkflow', - args: { description: 'Fresh workflow', items: ['src/fresh.ts'] }, - } as Event); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', parentToolCallId: 'call_cleanup_workflow', - subagentId: 'reused-agent', subagentName: 'coder', dynamicWorkflowIndex: 1, runInBackground: false, - } as Event); + parentToolCallId: 'call_agent', + subagentId: 'agent-1', + subagentName: 'explore', + description: 'explore project', + runInBackground: true, + model: 'k2-cheap', + } as Event, + sendQueued, + ); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+○\s+WAIT\s+src\/fresh.ts/u); - expect(transcript).not.toContain('must not leak'); - }, - ); + expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); + }); - it('keeps unrelated pending background lifecycle through /undo', async () => { - const { driver, session } = await makeDriver(); + it('does not let later transcript entries reduce the AgentDynamicWorkflow grid height', async () => { + const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); + const terminalColumns = 80; + setTerminalColumns(driver, terminalColumns); + const outerChildren = driver.state.ui.children; + const transcriptIndex = outerChildren.indexOf(driver.state.transcriptContainer); + const rowsAfterTranscript = outerChildren + .slice(transcriptIndex + 1) + .reduce((sum, child) => sum + child.render(terminalColumns).length, 0); + const nonGridRows = 20 - (agentDynamicWorkflowGridHeightForTerminalRows(20) ?? 0); + setTerminalRows(driver, rowsAfterTranscript + nonGridRows + 2); - driver.handleUserInput('launch unrelated workflow'); - dispatch({ - type: 'subagent.completed', agentId: 'main', sessionId: 'ses-1', subagentId: 'background-1', - parentToolCallId: 'call_background', resultSummary: 'Completed before spawn', - } as Event); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_unrelated_workflow', name: 'DynamicWorkflow', - args: { description: 'Unrelated workflow', items: ['src/a.ts'] }, - } as Event); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'], + }, + } as Event, + sendQueued, + ); - driver.state.appState.streamingPhase = 'idle'; - driver.handleUserInput('/undo'); - await confirmUndoSelection(driver); - await vi.waitFor(() => { - expect(session.undoHistory).toHaveBeenCalledWith(1); - }); + const dynamicWorkflowProgress = driver.state.transcriptContainer.children.find( + (child): child is AgentDynamicWorkflowProgressComponent => child instanceof AgentDynamicWorkflowProgressComponent, + ); + if (dynamicWorkflowProgress === undefined) throw new Error('expected AgentDynamicWorkflow progress'); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', - subagentId: 'background-1', subagentName: 'researcher', parentToolCallId: 'call_background', - description: 'Inspect the repository', runInBackground: true, - } as Event); + const transcriptWidth = Math.max(1, terminalColumns - 2); + const renderDynamicWorkflow = (): string => + stripSgr(dynamicWorkflowProgress.render(transcriptWidth).join('\n')); - expect(stripSgr(renderTranscript(driver))).toContain('researcher agent completed in background'); - }); + expect(renderDynamicWorkflow()).toContain('001 Queued...'); - it('keeps an early background completion buffered through Dynamic Workflow cancellation', async () => { - const { driver } = await makeDriver(); - const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_read', + name: 'Read', + args: { path: 'src/after.ts' }, + } as Event, + sendQueued, + ); - dispatch({ - type: 'subagent.completed', agentId: 'main', sessionId: 'ses-1', subagentId: 'background-cancelled', - parentToolCallId: 'call_background', resultSummary: 'Completed before cancellation', - } as Event); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_cancelled_workflow', name: 'DynamicWorkflow', - args: { description: 'Cancelled workflow', items: ['src/cancelled.ts'] }, - } as Event); - dispatch({ - type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, reason: 'cancelled', - } as Event); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', - subagentId: 'background-cancelled', subagentName: 'researcher', parentToolCallId: 'call_background', - description: 'Inspect the repository', runInBackground: true, - } as Event); + const transcriptChildren = driver.state.transcriptContainer.children; + const dynamicWorkflowIndex = transcriptChildren.indexOf( + dynamicWorkflowProgress as (typeof transcriptChildren)[number], + ); + expect(dynamicWorkflowIndex).toBeGreaterThanOrEqual(0); + + const rowsAfterDynamicWorkflowInTranscript = transcriptChildren + .slice(dynamicWorkflowIndex + 1) + .reduce((sum, child) => sum + child.render(transcriptWidth).length, 0); + expect(rowsAfterDynamicWorkflowInTranscript).toBeGreaterThan(0); - expect(stripSgr(renderTranscript(driver))).toContain('researcher agent completed in background'); + expect(renderDynamicWorkflow()).toContain('001 Queued...'); + const transcript = stripSgr( + driver.state.transcriptContainer.render(terminalColumns).join('\n'), + ); + expect(transcript).toContain('Using Read (src/after.ts)'); }); - it('keeps an early generic failure buffered across a result for a missing workflow control', async () => { + it('shows AgentDynamicWorkflow as completed when only some subagents fail', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_missing_workflow', name: 'DynamicWorkflow', - args: { description: 'Workflow removed before result', items: ['src/removed.ts'] }, - } as Event); - driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); - dispatch({ - type: 'subagent.failed', agentId: 'main', sessionId: 'ses-1', subagentId: 'generic-missing', - parentToolCallId: 'call_followup_workflow', error: 'Early generic failure', - } as Event); - dispatch({ - type: 'tool.result', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_missing_workflow', isError: false, output: 'result after cleanup', - } as Event); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_followup_workflow', name: 'DynamicWorkflow', - args: { description: 'Follow-up workflow', items: ['src/generic.ts'] }, - } as Event); - dispatch({ - type: 'subagent.spawned', agentId: 'main', sessionId: 'ses-1', parentToolCallId: 'call_followup_workflow', - subagentId: 'generic-missing', subagentName: 'coder', dynamicWorkflowIndex: 1, runInBackground: false, - } as Event); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + output: [ + '<agent_dynamic_workflow_result>', + '<summary>completed: 1, failed: 1</summary>', + '<subagent index="1" agent_id="agent-1" outcome="completed">Imports are stable.</subagent>', + '<subagent index="2" agent_id="agent-2" outcome="failed">Agent timed out after 30s.</subagent>', + '</agent_dynamic_workflow_result>', + ].join('\n'), + isError: undefined, + } as Event, + sendQueued, + ); const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toMatch(/001\s+×\s+FAIL\s+src\/generic.ts/u); - expect(transcript).toContain('Early generic failure'); + const totalStatusLine = transcript.split('\n').find((line) => line.includes('Completed.')); + expect(totalStatusLine).toBeDefined(); + expect(totalStatusLine).not.toContain('Failed.'); + expect(transcript).toContain('✓ Imports are stable.'); + expect(transcript).toContain('✗ Agent timed out after 30s.'); }); - it('marks an errored structured workflow result failed while preserving child statuses', async () => { + it('renders AgentDynamicWorkflow progress while tool args are still streaming', async () => { const { driver } = await makeDriver(); const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_error_workflow', name: 'DynamicWorkflow', - args: { description: 'Review changed files', items: ['src/a.ts'] }, - } as Event); - dispatch({ - type: 'tool.result', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_error_workflow', isError: true, - output: [ - '<dynamic_workflow_result>', - '<subagent index="1" outcome="completed">Child completed before request error</subagent>', - '</dynamic_workflow_result>', - ].join('\n'), - } as Event); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + argumentsPart: '{"description":"Review changed files', + } as Event, + sendQueued, + ); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('× Failed'); - expect(transcript).toMatch(/001\s+✓\s+DONE\s+src\/a.ts/u); - expect(transcript).toContain('Child completed before request error'); - }); + let transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent DynamicWorkflow'); + expect(transcript).toContain('Orchestrating...'); + expect(transcript).not.toContain('01'); - it('subtracts later transcript rows from fixed Mission Control height', async () => { - const { driver } = await makeDriver(makeSession(), {}, 'fixed'); - const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - setTerminalRows(driver, 10); - setTerminalColumns(driver, 100); - vi.spyOn(driver.state.layoutRoot, 'followingRows').mockReturnValue(2); - - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_fixed_workflow', name: 'DynamicWorkflow', - args: { description: 'Fixed layout work', items: ['One', 'Two', 'Three', 'Four', 'Five'] }, - } as Event); - const missionControl = driver.state.transcriptContainer.children.find( - (child): child is DynamicWorkflowMissionControlComponent => - child instanceof DynamicWorkflowMissionControlComponent, - ); - if (missionControl === undefined) throw new Error('expected Dynamic Workflow mission control'); - const followingTranscript: Component = { - render: () => ['Later transcript row one', 'Later transcript row two', 'Later transcript row three'], - invalidate: () => {}, - }; - driver.state.transcriptContainer.addTranscriptChild(followingTranscript, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + argumentsPart: '","items":["src/a.ts","src/b', + } as Event, + sendQueued, + ); - const lines = missionControl.render(100); - expect(lines).toHaveLength(5); - expect(stripSgr(lines.slice(0, 2).join('\n'))).toContain('Dynamic Workflow'); - expect(stripSgr(lines.slice(0, 2).join('\n'))).toContain('Orchestrating'); - }); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Agent DynamicWorkflow'); + expect(transcript).toContain('Review changed files'); + expect(transcript).toContain('001 src/a.ts'); + expect(transcript).toContain('002 src/b'); - it('keeps a cleaned-up fixed Mission Control bounded by later transcript siblings', async () => { - const { driver } = await makeDriver(makeSession(), {}, 'fixed'); - const sendQueued = vi.fn(); - const dispatch = (event: Event): void => driver.sessionEventHandler.handleEvent(event, sendQueued); - setTerminalRows(driver, 10); - setTerminalColumns(driver, 100); - vi.spyOn(driver.state.layoutRoot, 'followingRows').mockReturnValue(2); - - dispatch({ - type: 'tool.call.started', agentId: 'main', sessionId: 'ses-1', turnId: 1, - toolCallId: 'call_cleaned_fixed_workflow', name: 'DynamicWorkflow', - args: { description: 'Cleaned fixed layout work', items: ['One', 'Two', 'Three', 'Four', 'Five'] }, - } as Event); - const missionControl = driver.state.transcriptContainer.children.find( - (child): child is DynamicWorkflowMissionControlComponent => - child instanceof DynamicWorkflowMissionControlComponent, + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.spawned', + agentId: 'main', + sessionId: 'ses-1', + parentToolCallId: 'call_dynamic_workflow', + subagentId: 'agent-1', + subagentName: 'coder', + description: 'Review changed files #1 (coder)', + dynamicWorkflowIndex: 1, + runInBackground: false, + } as Event, + sendQueued, ); - if (missionControl === undefined) throw new Error('expected Dynamic Workflow mission control'); - driver.sessionEventHandler.clearDynamicWorkflowMissionControls(); - const followingTranscript: Component = { - render: () => ['Later transcript row one', 'Later transcript row two', 'Later transcript row three'], - invalidate: () => {}, - }; - driver.state.transcriptContainer.addTranscriptChild(followingTranscript, { - role: 'ephemeral', - edgeBlankPolicy: 'preserve', - }); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).toContain('002 src/b'); + + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: 'call_dynamic_workflow', + name: 'AgentDynamicWorkflow', + args: { + description: 'Review changed files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }, + } as Event, + sendQueued, + ); - const lines = missionControl.render(100); - expect(driver.state.transcriptContainer.children).toContain(missionControl); - expect(lines).toHaveLength(5); - expect(stripSgr(lines[0] ?? '')).toContain('Dynamic Workflow'); - expect(stripSgr(lines[1] ?? '')).toContain('Cancelled'); + transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('001 Queued...'); + expect(transcript).toContain('002 Queued...'); + expect(transcript).not.toContain('001 ['); + expect(transcript).not.toContain('002 ['); }); it('shows plan review reject on the plan card without an approval notice', async () => { @@ -4372,43 +6448,11 @@ command = "vim" }); }); - it('renders /cost from the active model rates and accumulated session spend', async () => { - const session = makeSession({ - getStatus: vi.fn(async () => ({ - model: 'k2', - modelCostRates: { input: 3, output: 15, cacheRead: 0.3 }, - thinkingLevel: 'off', - permission: 'manual', - planMode: false, - dynamicWorkflowMode: false, - contextTokens: 0, - maxContextTokens: 100, - contextUsage: 0, - usage: { totalCostUsd: 0.125 }, - })), - }); - const { driver } = await makeDriver(session); - - driver.handleUserInput('/cost'); - - await vi.waitFor(() => { - const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); - expect(output).toContain(' Cost '); - expect(output).toContain('Session spend'); - expect(output).toContain('$0.125'); - expect(output).toContain('Current model'); - expect(output).toContain('k2'); - expect(output).toContain('$3 / 1M tokens'); - expect(output).toContain('$15 / 1M tokens'); - expect(output).toContain('$0.3 / 1M tokens'); - }); - }); - it('renders /status using the active session runtime status', async () => { const session = makeSession({ getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'high', + thinkingEffort: 'high', permission: 'auto', planMode: true, contextTokens: 25, @@ -4426,7 +6470,7 @@ command = "vim" expect(getStatus).toHaveBeenCalledTimes(previousStatusCalls + 1); const output = stripSgr(driver.state.transcriptContainer.render(120).join('\n')); expect(output).toContain(' Status '); - expect(output).toContain('>_ Pythinker'); + expect(output).toContain('>_ Pythinker Code'); expect(output).toContain('Model'); expect(output).toContain('thinking high'); expect(output).toContain('Permissions auto'); @@ -4538,348 +6582,360 @@ command = "vim" }); }); - it('reloads plugins through the source-compatible command', async () => { - const session = makeSession({ - reloadPlugins: vi.fn(async () => ({ - added: ['demo'], - removed: [], - errors: [], - })), - }); + it('errors when /plugins install has no argument', async () => { + const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/reload-plugins'); + driver.handleUserInput('/plugins install'); await vi.waitFor(() => { - expect(session.reloadPlugins).toHaveBeenCalledOnce(); - expect(stripSgr(renderTranscript(driver))).toContain('Reload: +1 -0'); + expect(stripSgr(renderTranscript(driver))).toContain( + 'Usage: /plugins install <local-path-or-zip-url>', + ); }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('writes source-compatible heap diagnostics from the hidden command', async () => { - vi.mocked(performHeapDump).mockResolvedValueOnce({ - success: true, - heapPath: '/tmp/ses-1.heapsnapshot', - diagPath: '/tmp/ses-1-diagnostics.json', - }); - const { driver } = await makeDriver(); + it('installs from a positional source on /plugins install after trusting it', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); - driver.handleUserInput('/heapdump'); + driver.handleUserInput('/plugins install ./plugins/pythinker-datasource'); await vi.waitFor(() => { - expect(performHeapDump).toHaveBeenCalledWith('ses-1', '0.0.0-test'); - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Heap dump created'); - expect(transcript).toContain('/tmp/ses-1.heapsnapshot'); - expect(transcript).toContain('/tmp/ses-1-diagnostics.json'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); - }); - - it('shows the canonical Pythinker release notes link', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('/release-notes'); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Release notes'); - expect(transcript).toContain( - 'https://pymodel.github.io/pythinker-code/release-notes/changelog.html', + expect(session.installPlugin).toHaveBeenCalledWith( + resolve('/tmp/proj-a', './plugins/pythinker-datasource'), ); }); - expect(session.prompt).not.toHaveBeenCalled(); }); - it('reports native multiline input support through /terminal-setup', async () => { - vi.stubEnv('TERM_PROGRAM', 'Ghostty'); - const { driver, session } = await makeDriver(); + it('shows a quota note after installing a quota-consuming official plugin', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + })), + }); + const { driver } = await makeDriver(session); - driver.handleUserInput('/terminal-setup'); + // Official sources skip the trust prompt, so the install runs immediately. + driver.handleUserInput( + '/plugins install https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + ); await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Multiline input is ready'); - expect(transcript).toContain('Shift-Enter'); - expect(transcript).toContain('Ctrl-J'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).toContain('Note: This plugin consumes your quota.'); }); - expect(session.prompt).not.toHaveBeenCalled(); }); - it('expands /review into the source-compatible pull request workflow', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('/review 42'); - - await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('pull request 42')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('gh pr diff')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('security')); + it('does not show the quota note for a same-id fork installed from a local path', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'local-path', + })), }); - }); - - it('expands /security-review into a high-confidence branch security review', async () => { - const { driver, session } = await makeDriver(); + const { driver } = await makeDriver(session); - driver.handleUserInput('/security-review'); + driver.handleUserInput('/plugins install ./plugins/pythinker-datasource-fork'); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('security review')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('origin/HEAD')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('80%')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('Do not modify')); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); }); - }); - - it('expands /pr-comments into a formatted GitHub pull request comment query', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('/pr-comments 42'); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + // The manifest id matches a billed plugin, but a local-path install is + // not the official quota-consuming build. await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('pull request 42')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('issues/{number}/comments')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('pulls/{number}/comments')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('No comments found.')); + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Installed Pythinker Datasource'); }); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Note: This plugin consumes your quota.', + ); }); - it('expands /init-verifiers into Pythinker-native functional verifier skill setup', async () => { - const { driver, session } = await makeDriver(); + it('does not install when the third-party trust prompt is dismissed', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); - driver.handleUserInput('/init-verifiers'); + driver.handleUserInput('/plugins install ./plugins/pythinker-datasource'); await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith( - expect.stringContaining('.pythinker-code/skills/<verifier-name>/SKILL.md'), + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, ); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('functional verification')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('Do not install')); - }); - }); - - it('expands /commit into a guarded single-commit workflow', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('/commit include the focused TUI changes'); - - await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('Create one git commit')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('Never amend')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('focused TUI changes')); }); - }); - - it('expands /commit-push-pr into the complete guarded publishing workflow', async () => { - const { driver, session } = await makeDriver(); - - driver.handleUserInput('/commit-push-pr keep the PR focused'); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\r'); // default option is "Exit" await vi.waitFor(() => { - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('push the branch')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('pull request template')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('Never force-push')); - expect(session.prompt).toHaveBeenCalledWith(expect.stringContaining('keep the PR focused')); + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); }); + expect(session.installPlugin).not.toHaveBeenCalled(); }); - it('errors when /plugins install has no argument', async () => { + it('loads a local plugin marketplace file and installs from it', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'pythinker-datasource', + tier: 'official', + displayName: 'Pythinker Datasource', + description: 'Datasource plugin', + source: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins install'); + driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'Usage: /plugins install <local-path-or-zip-url>', - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - expect(session.installPlugin).not.toHaveBeenCalled(); - }); - - it('installs from a positional source on /plugins install', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session); - - driver.handleUserInput('/plugins install ./plugins/pythinker-datasource'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + // Official loads its catalog lazily; wait for the entry to render before install. + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Pythinker Datasource'); + }); + // The pinned Pythinker WebBridge row leads the Official tab, so move down to + // the Pythinker Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( - '/tmp/proj-a/plugins/pythinker-datasource', - undefined, + 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', ); }); + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Installed Demo'); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).not.toContain('Note: This plugin consumes your quota.'); + }); + // Installing closes the panel so the success notice / reload tip is visible. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBe(driver.state.editor); + }); }); - it('chooses Pythinker, shows loading progress, and returns to the plugin overview', async () => { - delete process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL']; - const originalFetch = globalThis.fetch; - let resolveMarketplace!: (response: Response) => void; - const marketplaceResponse = new Promise<Response>((resolveResponse) => { - resolveMarketplace = resolveResponse; + it('returns to the plugin list when a marketplace install fails', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ + plugins: [ + { + id: 'pythinker-datasource', + tier: 'official', + displayName: 'Pythinker Datasource', + source: 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', + }, + ], + }), + 'utf8', + ); + process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL'] = marketplacePath; + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); }); - const fetchMock = vi.fn(() => marketplaceResponse); - vi.stubGlobal('fetch', fetchMock); - const session = makeSession(); + const session = makeSession({ installPlugin }); const { driver } = await makeDriver(session); - const restoreEditor = vi.spyOn(driver as unknown as PythinkerTUI, 'restoreEditor'); - try { - driver.handleUserInput('/plugins marketplace'); + driver.handleUserInput('/plugins marketplace'); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); - }); - const sourcePicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - const sourcePickerOutput = stripSgr(sourcePicker.render(120).join('\n')); - expect(sourcePickerOutput).toContain('Pythinker'); - expect(sourcePickerOutput).toContain('Anthropic'); - expect(sourcePickerOutput).toContain('Custom marketplace'); - sourcePicker.handleInput('\r'); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Pythinker Datasource'); + }); + panel.handleInput('\r'); - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith( - PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, - { signal: expect.any(AbortSignal) }, - ); - expect(stripSgr(renderTranscript(driver))).toContain('Loading plugin marketplace…'); - }); - resolveMarketplace(new Response(JSON.stringify({ + // The panel must not get stuck on the one-way "Installing…" view; it should + // return to the list so the user can retry. + await vi.waitFor(() => { + const rendered = stripSgr(panel.render(120).join('\n')); + expect(rendered).toContain('Pythinker Datasource'); + expect(rendered).not.toContain('Installing'); + }); + }); + + it('prompts for trust before installing a third-party marketplace entry', async () => { + const marketplaceDir = await makeTempHome(); + const marketplacePath = join(marketplaceDir, 'marketplace.json'); + await writeFile( + marketplacePath, + JSON.stringify({ plugins: [ { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - source: './official/pythinker-datasource.zip', + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + description: 'Curated plugin', + source: './superpowers', }, ], - }))); + }), + 'utf8', + ); + const session = makeSession(); + const { driver } = await makeDriver(session); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); - }); - const marketplacePicker = driver.state.editorContainer - .children[0] as PluginMarketplaceSelectorComponent; - marketplacePicker.handleInput('\u001B'); + // Passing the marketplace path opens the panel directly on the Third-party tab. + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); - }); - expect(restoreEditor).not.toHaveBeenCalled(); - } finally { - vi.stubGlobal('fetch', originalFetch); - } + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); + }); + panel.handleInput('\r'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.installPlugin).toHaveBeenCalledWith(join(marketplaceDir, 'superpowers')); + }); }); - it('loads a custom marketplace and forwards its normalized install definition', async () => { + it('restores the panel when a third-party marketplace install fails', async () => { const marketplaceDir = await makeTempHome(); const marketplacePath = join(marketplaceDir, 'marketplace.json'); await writeFile( marketplacePath, JSON.stringify({ - name: 'local-marketplace', - owner: { name: 'Example Owner' }, plugins: [ { - name: 'local-review', - displayName: 'Local Review', - source: './local-review', - skills: './skills', + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + source: './superpowers', }, ], }), 'utf8', ); - const installedSummary = { - id: 'local-review', - displayName: 'Local Review', - version: '1.0.0', - description: undefined, - enabled: true, - state: 'ok' as const, - source: 'local-path' as const, - originalSource: join(marketplaceDir, 'local-review'), - skillCount: 1, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - }; - let resolveInstall!: (summary: typeof installedSummary) => void; - const installPlugin = vi.fn(() => new Promise<typeof installedSummary>((resolveSummary) => { - resolveInstall = resolveSummary; - })); + const installPlugin = vi.fn(async () => { + throw new Error('install failed'); + }); const session = makeSession({ installPlugin }); const { driver } = await makeDriver(session); - const restoreEditor = vi.spyOn(driver as unknown as PythinkerTUI, 'restoreEditor'); - driver.handleUserInput('/plugins marketplace'); + driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const sourcePicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - sourcePicker.handleInput('\u001B[B'); - sourcePicker.handleInput('\u001B[B'); - sourcePicker.handleInput('\r'); - + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ApiKeyInputDialogComponent); + expect(stripSgr(panel.render(120).join('\n'))).toContain('Superpowers'); }); - const sourceInput = driver.state.editorContainer.children[0] as ApiKeyInputDialogComponent; - for (const char of marketplacePath) sourceInput.handleInput(char); - expect(stripSgr(sourceInput.render(200).join('\n'))).toContain(marketplacePath); - sourceInput.handleInput('\r'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, + PluginInstallTrustConfirmComponent, ); }); - const marketplacePicker = driver.state.editorContainer - .children[0] as PluginMarketplaceSelectorComponent; - marketplacePicker.handleInput('\r'); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + // The failed install must return the user to the marketplace panel so they + // can retry, rather than dropping them back at the editor. await vi.waitFor(() => { - expect(installPlugin).toHaveBeenCalledWith( - join(marketplaceDir, 'local-review'), - expect.objectContaining({ - definition: expect.objectContaining({ - id: 'local-review', - components: { skills: './skills' }, - }), - }), - ); - expect(stripSgr(renderTranscript(driver))).toContain('Installing or updating Local Review…'); + expect(driver.state.editorContainer.children[0]).toBe(panel); }); - expect(restoreEditor).not.toHaveBeenCalled(); + }); + + it('removes a plugin record without auto-running any cleanup skill', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins remove pythinker-webbridge'); - resolveInstall(installedSummary); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, + PluginRemoveConfirmComponent, ); }); - expect(restoreEditor).not.toHaveBeenCalled(); + const confirm = driver.state.editorContainer.children[0] as PluginRemoveConfirmComponent; + confirm.handleInput('\u001B[B'); + confirm.handleInput('\r'); + + await vi.waitFor(() => { + expect(session.removePlugin).toHaveBeenCalledWith('pythinker-webbridge'); + }); + expect(session.activateSkill).not.toHaveBeenCalled(); }); - it('loads Anthropic relative entries with repository install options', async () => { - delete process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL']; + it('installs default marketplace entries through plain install', async () => { const originalFetch = globalThis.fetch; - const fetchMock = vi.fn(async () => new Response(JSON.stringify({ - name: 'claude-plugins-official', - owner: { name: 'Anthropic' }, + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ plugins: [ { - name: 'review', - displayName: 'Review', - source: './plugins/review', - skills: './skills', + id: 'pythinker-datasource', + tier: 'official', + displayName: 'Pythinker Datasource', + description: 'Datasource plugin', + source: './official/pythinker-datasource.zip', }, ], - }))); - vi.stubGlobal('fetch', fetchMock); + })))); const session = makeSession(); const { driver } = await makeDriver(session); @@ -4887,109 +6943,63 @@ command = "vim" driver.handleUserInput('/plugins marketplace'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(ChoicePickerComponent); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const sourcePicker = driver.state.editorContainer.children[0] as ChoicePickerComponent; - sourcePicker.handleInput('\u001B[B'); - sourcePicker.handleInput('\r'); - + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); + expect(stripSgr(panel.render(120).join('\n'))).toContain('Pythinker Datasource'); }); - expect(fetchMock).toHaveBeenCalledWith( - ANTHROPIC_PLUGIN_MARKETPLACE_URL, - { signal: expect.any(AbortSignal) }, - ); - const marketplacePicker = driver.state.editorContainer - .children[0] as PluginMarketplaceSelectorComponent; - marketplacePicker.handleInput('\r'); + // The pinned Pythinker WebBridge row leads the Official tab, so move down to + // the Pythinker Datasource entry before installing. + panel.handleInput('\u001B[B'); + panel.handleInput('\r'); await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( - 'https://github.com/anthropics/claude-plugins-official/tree/HEAD', - expect.objectContaining({ - repositorySubdirectory: 'plugins/review', - definition: expect.objectContaining({ - id: 'review', - components: { skills: './skills' }, - }), - }), + 'https://code.kimi.com/pythinker-code/plugins/official/pythinker-datasource.zip', ); }); + expect(globalThis.fetch).toHaveBeenCalledWith(PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL); } finally { vi.stubGlobal('fetch', originalFetch); } }); - it('remounts the marketplace selector after a failed install so Enter can retry', async () => { - const marketplaceDir = await makeTempHome(); - const marketplacePath = join(marketplaceDir, 'marketplace.json'); - await writeFile( - marketplacePath, - JSON.stringify({ - plugins: [ - { - id: 'retry-plugin', - displayName: 'Retry Plugin', - source: './retry-plugin', - }, - ], + it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { + const originalFetch = globalThis.fetch; + process.env['PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('fetch failed'); }), - 'utf8', ); - const installedSummary = { - id: 'retry-plugin', - displayName: 'Retry Plugin', - version: '1.0.0', - description: undefined, - enabled: true, - state: 'ok' as const, - source: 'local-path' as const, - originalSource: join(marketplaceDir, 'retry-plugin'), - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - }; - const installPlugin = vi.fn() - .mockRejectedValueOnce(new Error('temporary install failure')) - .mockResolvedValueOnce(installedSummary); - const session = makeSession({ installPlugin }); + const session = makeSession(); const { driver } = await makeDriver(session); - const restoreEditor = vi.spyOn(driver as unknown as PythinkerTUI, 'restoreEditor'); - - driver.handleUserInput(`/plugins marketplace ${marketplacePath}`); - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginMarketplaceSelectorComponent, - ); - }); - const firstPicker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - firstPicker.handleInput('\r'); + try { + driver.handleUserInput('/plugins'); - await vi.waitFor(() => { - const current = driver.state.editorContainer.children[0]; - expect(current).toBeInstanceOf(PluginMarketplaceSelectorComponent); - expect(current).not.toBe(firstPicker); - }); - expect(installPlugin).toHaveBeenCalledTimes(1); - expect(restoreEditor).not.toHaveBeenCalled(); + // The panel opens immediately on the Installed tab — no marketplace fetch. + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); + }); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('\t'); // → Official, which lazily (and unsuccessfully) loads - const retryPicker = driver.state.editorContainer.children[0] as PluginMarketplaceSelectorComponent; - retryPicker.handleInput('\r'); - await vi.waitFor(() => { - expect(installPlugin).toHaveBeenCalledTimes(2); - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); - }); - expect(restoreEditor).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect(stripSgr(panel.render(120).join('\n'))).toContain( + 'Marketplace unavailable: fetch failed', + ); + }); + // The panel stays mounted; the failure does not close /plugins. + expect(driver.state.editorContainer.children[0]).toBe(panel); + } finally { + vi.stubGlobal('fetch', originalFetch); + } }); - it('toggles plugins from the overview with space', async () => { + it('toggles plugins from the Installed tab with space', async () => { let enabled = true; const session = makeSession({ listPlugins: vi.fn(async () => [ @@ -5003,6 +7013,7 @@ command = "vim" mcpServerCount: 0, enabledMcpServerCount: 0, hasErrors: false, + source: 'local-path', }, ]), setPluginEnabled: vi.fn(async (_id: string, nextEnabled: boolean) => { @@ -5014,31 +7025,25 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput(' '); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput(' '); - // Toggling refreshes the picker in place: it must not flash back to the - // editor between the keypress and the refreshed picker mounting. - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + // Toggling refreshes the panel in place: it must not flash back to the + // editor between the keypress and the refreshed panel mounting. + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); await vi.waitFor(() => { expect(session.setPluginEnabled).toHaveBeenCalledWith('demo', false); }); - // The picker stays mounted the whole time (no editor flash), so wait for the - // refreshed render rather than for an instance swap. await vi.waitFor(() => { const refreshed = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(refreshed).toContain('❯ Demo disabled require run /new to apply'); + expect(refreshed).toContain('❯ Demo disabled run /reload or /new to apply'); }); - const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).not.toContain('Space enable'); - expect(stripSgr(renderTranscript(driver))).not.toContain('Disabled demo. Run /new to apply.'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Disabled demo. Run /reload or /new to apply.', + ); }); it('toggles plugin MCP servers from the overview MCP picker', async () => { @@ -5102,12 +7107,10 @@ command = "vim" driver.handleUserInput('/plugins'); await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf( - PluginsOverviewSelectorComponent, - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginsPanelComponent); }); - const overview = driver.state.editorContainer.children[0] as PluginsOverviewSelectorComponent; - overview.handleInput('m'); + const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; + panel.handleInput('m'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -5129,9 +7132,9 @@ command = "vim" expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PluginMcpSelectorComponent); }); const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); - expect(out).toContain('❯ data disabled require run /new to apply'); + expect(out).toContain('❯ data disabled run /reload or /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for pythinker-datasource. Run /new to apply.', + 'Disabled MCP server data for pythinker-datasource. Run /reload or /new to apply.', ); }); @@ -5190,22 +7193,22 @@ command = "vim" getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], }, turbo: { - provider: 'managed:kimi-code', + provider: 'managed:pythinker-code', model: 'pythinker-turbo', maxContextSize: 100, - displayName: 'Kimi Turbo', + displayName: 'Pythinker Turbo', capabilities: ['thinking'], }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); @@ -5217,322 +7220,290 @@ command = "vim" }); const picker = driver.state.editorContainer.children[0]; const pickerOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(pickerOutput).toMatch(/Kimi K2\s+kimi-code ← current/); - expect(pickerOutput).toMatch(/❯ Kimi Turbo\s+kimi-code/); + expect(pickerOutput).toMatch(/Kimi K2\s+Pythinker Code ← current/); + expect(pickerOutput).toMatch(/❯ Pythinker Turbo\s+Pythinker Code/); (picker as TabbedModelSelectorComponent).handleInput('t'); (picker as TabbedModelSelectorComponent).handleInput('u'); const filteredOutput = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); expect(filteredOutput).toContain('Search: tu'); - expect(filteredOutput).toContain('Kimi Turbo'); + expect(filteredOutput).toContain('Pythinker Turbo'); expect(filteredOutput).not.toContain('Kimi K2'); - // Turbo is not the active model, but it keeps the live effort (off here) - // instead of resetting to its first level and persisting that as default. + // Turbo keeps the live Off effort instead of resetting the saved preference. (picker as TabbedModelSelectorComponent).handleInput('\r'); await vi.waitFor(() => { expect(session.setModel).toHaveBeenCalledWith('turbo'); expect(setConfig).toHaveBeenCalledWith({ defaultModel: 'turbo', - defaultThinking: false, - thinking: { effort: 'off', mode: 'off' }, + thinking: { enabled: false }, }); }); expect(session.setThinking).not.toHaveBeenCalled(); expect(driver.state.appState.model).toBe('turbo'); - expect(driver.state.appState.thinkingLevel).toBe('off'); + expect(driver.state.appState.thinkingEffort).toBe('off'); }); - it('persists /model selection even when runtime state is unchanged', async () => { + it('applies /model selection to the session only on Alt+S without persisting', async () => { const session = makeSession(); const setConfig = vi.fn(async () => ({ providers: {} })); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], }, - }, - defaultModel: 'old-default', - defaultThinking: true, - })), - setConfig, - }); - - driver.handleUserInput('/model k2'); - - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); - }); - const picker = driver.state.editorContainer.children[0]; - (picker as TabbedModelSelectorComponent).handleInput('\r'); - - await vi.waitFor(() => { - expect(setConfig).toHaveBeenCalledWith({ - defaultModel: 'k2', - defaultThinking: false, - thinking: { effort: 'off', mode: 'off' }, - }); - }); - expect(session.setModel).not.toHaveBeenCalled(); - expect(session.setThinking).not.toHaveBeenCalled(); - }); - - it('applies /effort with a positional level and persists it', async () => { - const session = makeSession(); - const setConfig = vi.fn(async () => ({ providers: {} })); - const { driver } = await makeDriver(session, { - getConfig: vi.fn(async () => ({ - models: { - k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + turbo: { + provider: 'managed:pythinker-code', + model: 'pythinker-turbo', maxContextSize: 100, - displayName: 'Kimi K2', + displayName: 'Pythinker Turbo', capabilities: ['thinking'], }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: false }, })), setConfig, }); - driver.handleUserInput('/effort high'); + driver.handleUserInput('/model turbo'); await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenCalledWith('high'); - expect(setConfig).toHaveBeenCalledWith({ - defaultModel: 'k2', - defaultThinking: true, - thinking: { effort: 'high', mode: 'on' }, - }); - }); - expect(driver.state.appState.thinkingLevel).toBe('high'); - }); - - it('rejects an unknown /effort level and lists the valid ones', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session, { - getConfig: vi.fn(async () => ({ - models: { - k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', - maxContextSize: 100, - displayName: 'Kimi K2', - capabilities: ['thinking'], - supportEfforts: ['low', 'high'], - }, - }, - defaultModel: 'k2', - })), + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); }); - - driver.handleUserInput('/effort max'); + const picker = driver.state.editorContainer.children[0]; + // /model turbo preselects turbo; Alt+S applies it to the current session only. + (picker as TabbedModelSelectorComponent).handleInput(`${ESC}s`); await vi.waitFor(() => { - const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Unknown thinking effort "max"'); - expect(transcript).toContain('off, low, high'); + expect(session.setModel).toHaveBeenCalledWith('turbo'); + expect(driver.state.appState.model).toBe('turbo'); }); expect(session.setThinking).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + expect(driver.state.appState.thinkingEffort).toBe('off'); }); - it('opens the effort selector with /effort and applies the picked level', async () => { - const session = makeSession(); - const { driver } = await makeDriver(session, { - getConfig: vi.fn(async () => ({ - models: { - k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', - maxContextSize: 100, - displayName: 'Kimi K2', - capabilities: ['thinking'], - }, - }, - defaultModel: 'k2', - defaultThinking: false, - })), - }); - - driver.handleUserInput('/effort'); - - await vi.waitFor(() => { - expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); - }); - const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; - const out = stripSgr(picker.render(100).join('\n')); - expect(out).toContain('Thinking effort'); - expect(out).toContain('off ← current'); - picker.handleInput(`${ESC}[B`); // off -> low - picker.handleInput('\r'); - - await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenCalledWith('low'); - }); - expect(driver.state.appState.thinkingLevel).toBe('low'); - }); - - it('cycles the thinking effort with Ctrl-T', async () => { - const session = makeSession(); + it('uses the effective effort returned after a model-switch fallback', async () => { + let switched = false; + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: switched ? 'turbo' : 'k2', + thinkingEffort: switched ? 'mid' : 'ultra', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + setModel: vi.fn(async () => { + switched = true; + }), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'ultra'], + defaultEffort: 'ultra', + }, + turbo: { + provider: 'managed:pythinker-code', + model: 'pythinker-turbo', maxContextSize: 100, - displayName: 'Kimi K2', capabilities: ['thinking'], + supportEfforts: ['low', 'mid', 'high'], + defaultEffort: 'mid', }, }, defaultModel: 'k2', - defaultThinking: false, + thinking: { enabled: true, effort: 'ultra' }, })), + setConfig, }); - driver.state.editor.handleInput('\u0014'); + driver.handleUserInput('/model turbo'); await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenCalledWith('low'); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); }); - expect(driver.state.appState.thinkingLevel).toBe('low'); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); - driver.state.editor.handleInput('\u0014'); await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenCalledWith('medium'); + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true, effort: 'mid' }, + }); }); - expect(driver.state.appState.thinkingLevel).toBe('medium'); + expect(driver.state.appState.model).toBe('turbo'); + expect(driver.state.appState.thinkingEffort).toBe('mid'); + expect(renderTranscript(driver)).toContain('Switched to pythinker-turbo with thinking mid.'); }); - it('wraps the thinking effort back to off with Shift-Tab', async () => { + it('persists /model selection even when runtime state is unchanged', async () => { const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], }, }, - defaultModel: 'k2', - defaultThinking: false, + defaultModel: 'old-default', + thinking: { enabled: true }, })), + setConfig, }); - // Shift-Tab now dispatches the same cycle as Ctrl-T (chat:thinkingToggle). - const shiftTab = String.fromCodePoint(0x1b) + '[Z'; - for (const expected of ['low', 'medium', 'high']) { - driver.state.editor.handleInput(shiftTab); - await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenLastCalledWith(expected); - }); - } - driver.state.editor.handleInput(shiftTab); + driver.handleUserInput('/model k2'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0]; + (picker as TabbedModelSelectorComponent).handleInput('\r'); + await vi.waitFor(() => { - expect(session.setThinking).toHaveBeenLastCalledWith('off'); + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'k2', + thinking: { enabled: false }, + }); }); - expect(driver.state.appState.thinkingLevel).toBe('off'); + expect(session.setModel).not.toHaveBeenCalled(); + expect(session.setThinking).not.toHaveBeenCalled(); }); - it('keeps the prompt-box border neutral across thinking effort and permission mode', async () => { - const session = makeSession(); + it('does not write config when re-confirming the current effort in the picker', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'high', + permission: 'manual', + planMode: false, + contextTokens: 0, + maxContextTokens: 100, + contextUsage: 0, + })), + }); + const setConfig = vi.fn(async () => ({ providers: {} })); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', }, }, defaultModel: 'k2', - defaultThinking: false, + // No persisted effort: re-confirming the shown level must not turn the + // runtime default into a stored preference. + thinking: { enabled: true }, })), + setConfig, }); - // Non-TTY test env strips ANSI at chalk level 0; force truecolor so the - // painted border actually carries the per-effort color codes. - const previousLevel = chalk.level; - chalk.level = 3; - try { - const tui = driver as unknown as PythinkerTUI; - const paintAt = (thinkingLevel: string): string => { - tui.setAppState({ thinkingLevel }); - return driver.state.editor.borderColor('─'); - }; - const offPaint = paintAt('off'); - tui.setAppState({ permissionMode: 'yolo' }); - expect(driver.state.editor.borderColor('─')).toBe(offPaint); + driver.handleUserInput('/effort'); - tui.setAppState({ permissionMode: 'manual' }); - const perLevel = ['low', 'medium', 'high'].map(paintAt); - for (const painted of perLevel) expect(painted).toBe(offPaint); + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + (driver.state.editorContainer.children[0] as EffortSelectorComponent).handleInput('\r'); - tui.setAppState({ planMode: true }); - expect(driver.state.editor.borderColor('─')).toBe(currentTheme.fg('primary', '─')); - expect(driver.state.editor.borderColor('─')).not.toBe(offPaint); - } finally { - chalk.level = previousLevel; - } + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Already using Kimi K2 with thinking high.'); + }); + expect(setConfig).not.toHaveBeenCalled(); + expect(session.setThinking).not.toHaveBeenCalled(); }); - it('shows a notice instead of cycling effort when the model has no selectable levels', async () => { + it('persists only the model when a switch keeps the same effort', async () => { + let switched = false; const session = makeSession({ getStatus: vi.fn(async () => ({ - model: 'plain', - thinkingLevel: 'off', + model: switched ? 'turbo' : 'k2', + thinkingEffort: 'high', permission: 'manual', planMode: false, contextTokens: 0, maxContextTokens: 100, contextUsage: 0, })), + setModel: vi.fn(async () => { + switched = true; + }), }); + const setConfig = vi.fn(async () => ({ providers: {} })); const { driver } = await makeDriver(session, { getConfig: vi.fn(async () => ({ models: { - plain: { - provider: 'managed:kimi-code', - model: 'pythinker-plain', + k2: { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }, + turbo: { + provider: 'managed:pythinker-code', + model: 'pythinker-turbo', maxContextSize: 100, - displayName: 'Plain Model', - capabilities: [], + displayName: 'Turbo', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', }, }, - defaultModel: 'plain', - defaultThinking: false, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'high' }, })), + setConfig, }); - const ctrlT = String.fromCodePoint(0x14); - driver.state.editor.handleInput(ctrlT); + driver.handleUserInput('/model turbo'); await vi.waitFor(() => { - expect(stripSgr(renderTranscript(driver))).toContain( - 'does not offer selectable thinking effort levels', - ); + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + // The effort matches the value shown when the picker opened, so the patch + // carries no effort key; the stored preference stays as-is via the merge. + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: true }, + }); }); - expect(session.setThinking).not.toHaveBeenCalled(); }); - it('opens /model picker immediately from cached models and refreshes all providers in background', async () => { + it('refreshes only OAuth provider models before opening /model picker', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Old Kimi K2', capabilities: ['thinking'], @@ -5541,65 +7512,50 @@ command = "vim" })), }); const tui = driver as unknown as PythinkerTUI; - const refreshOAuthProviderModels = vi - .spyOn(tui.authFlow, 'refreshOAuthProviderModels') - .mockRejectedValue(new Error('OAuth-only refresh should not run')); - let resolveRefresh: (() => void) | undefined; - const refreshProviderModels = vi.fn(async () => { - await new Promise<void>((resolve) => { - resolveRefresh = () => { - tui.setAppState({ - availableModels: { - k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', - maxContextSize: 100, - displayName: 'Fresh Kimi K2', - capabilities: ['thinking'], - }, - }, - }); - resolve(); - }; + const refreshProviderModels = vi + .spyOn(tui.authFlow, 'refreshProviderModels') + .mockRejectedValue(new Error('full provider refresh should not run')); + const refreshOAuthProviderModels = vi.fn(async () => { + await Promise.resolve(); + tui.setAppState({ + availableModels: { + k2: { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Fresh Kimi K2', + capabilities: ['thinking'], + }, + }, }); - return { changed: ['managed:kimi-code'], unchanged: [], failed: [] }; + return { changed: [], unchanged: ['managed:pythinker-code'], failed: [] }; }); ( tui.authFlow as unknown as { - refreshProviderModels: typeof refreshProviderModels; + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; } - ).refreshProviderModels = refreshProviderModels; + ).refreshOAuthProviderModels = refreshOAuthProviderModels; driver.handleUserInput('/model'); - await Promise.resolve(); - - const firstPicker = driver.state.editorContainer.children[0]; - expect(firstPicker).toBeInstanceOf(TabbedModelSelectorComponent); - expect(stripSgr((firstPicker as TabbedModelSelectorComponent).render(120).join('\n'))).toContain( - 'Old Kimi K2', - ); - - resolveRefresh?.(); await vi.waitFor(() => { const picker = driver.state.editorContainer.children[0]; expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); - expect(picker).not.toBe(firstPicker); const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); expect(output).toContain('Fresh Kimi K2'); expect(output).not.toContain('Old Kimi K2'); }); - expect(refreshProviderModels).toHaveBeenCalledOnce(); - expect(refreshOAuthProviderModels).not.toHaveBeenCalled(); + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(refreshProviderModels).not.toHaveBeenCalled(); }); - it('opens /model picker immediately while the provider refresh is still pending', async () => { + it('opens /model picker after 2s when OAuth refresh is still pending', async () => { const { driver } = await makeDriver(makeSession(), { getConfig: vi.fn(async () => ({ models: { k2: { - provider: 'managed:kimi-code', - model: 'pythinker-k2', + provider: 'managed:pythinker-code', + model: 'kimi-k2', maxContextSize: 100, displayName: 'Kimi K2', capabilities: ['thinking'], @@ -5608,130 +7564,49 @@ command = "vim" })), }); const tui = driver as unknown as PythinkerTUI; - const refreshProviderModels = vi.fn(() => new Promise<never>(() => {})); - ( - tui.authFlow as unknown as { - refreshProviderModels: typeof refreshProviderModels; - } - ).refreshProviderModels = refreshProviderModels; - - driver.handleUserInput('/model'); - await Promise.resolve(); - - expect(refreshProviderModels).toHaveBeenCalledOnce(); - const picker = driver.state.editorContainer.children[0]; - expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); - const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(output).toContain('Kimi K2'); - }); - - it('preserves the live provider tab and highlighted model when refresh resolves after moving', async () => { - const { driver } = await makeDriver(makeSession(), { - getConfig: vi.fn(async () => ({ - models: { - 'terra/one': { - provider: 'terra', - model: 'one', - maxContextSize: 100, - displayName: 'Terra One', - capabilities: ['thinking'], - }, - 'terra/two': { - provider: 'terra', - model: 'two', - maxContextSize: 100, - displayName: 'Terra Two', - capabilities: ['thinking'], - }, - gpt: { - provider: 'openai', - model: 'gpt-5', - maxContextSize: 100, - displayName: 'GPT-5', - capabilities: ['thinking'], - }, - }, - })), - }); - const tui = driver as unknown as PythinkerTUI; - let resolveRefresh: (() => void) | undefined; - const refreshProviderModels = vi.fn(async () => { - await new Promise<void>((resolve) => { - resolveRefresh = () => { - tui.setAppState({ - availableModels: { - 'terra/one': { - provider: 'terra', - model: 'one', - maxContextSize: 100, - displayName: 'Terra One Fresh', - capabilities: ['thinking'], - }, - 'terra/two': { - provider: 'terra', - model: 'two', - maxContextSize: 100, - displayName: 'Terra Two Fresh', - capabilities: ['thinking'], - }, - gpt: { - provider: 'openai', - model: 'gpt-5', - maxContextSize: 100, - displayName: 'GPT-5 Fresh', - capabilities: ['thinking'], - }, - }, - }); - resolve(); - }; - }); - return { changed: ['terra', 'openai'], unchanged: [], failed: [] }; - }); + const refreshOAuthProviderModels = vi.fn(() => new Promise<never>(() => {})); ( tui.authFlow as unknown as { - refreshProviderModels: typeof refreshProviderModels; + refreshOAuthProviderModels: typeof refreshOAuthProviderModels; } - ).refreshProviderModels = refreshProviderModels; + ).refreshOAuthProviderModels = refreshOAuthProviderModels; - driver.handleUserInput('/model'); - await Promise.resolve(); + vi.useFakeTimers(); + try { + driver.handleUserInput('/model'); + await Promise.resolve(); - const firstPicker = driver.state.editorContainer.children[0]; - expect(firstPicker).toBeInstanceOf(TabbedModelSelectorComponent); - (firstPicker as TabbedModelSelectorComponent).handleInput('\t'); - (firstPicker as TabbedModelSelectorComponent).handleInput('\u001B[B'); + expect(refreshOAuthProviderModels).toHaveBeenCalledOnce(); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); - resolveRefresh?.(); + await vi.advanceTimersByTimeAsync(1_999); + expect(driver.state.editorContainer.children[0]).not.toBeInstanceOf(TabbedModelSelectorComponent); - await vi.waitFor(() => { + await vi.advanceTimersByTimeAsync(1); const picker = driver.state.editorContainer.children[0]; expect(picker).toBeInstanceOf(TabbedModelSelectorComponent); - expect(picker).not.toBe(firstPicker); const output = stripSgr((picker as TabbedModelSelectorComponent).render(120).join('\n')); - expect(output).toContain('Terra One Fresh'); - expect(output).toContain('Terra Two Fresh'); - expect(output).not.toContain('GPT-5 Fresh'); - expect(output).toMatch(/❯ Terra Two Fresh/); - expect(output).not.toMatch(/❯ Terra One Fresh/); - }); + expect(output).toContain('Kimi K2'); + } finally { + vi.useRealTimers(); + } }); it('enables search in the shared model selector helper', async () => { const { driver } = await makeDriver(); const selection = runModelSelector(driver as any, { alpha: { - provider: 'managed:kimi-code', + provider: 'managed:pythinker-code', model: 'pythinker-alpha', maxContextSize: 100, displayName: 'Pythinker Alpha', capabilities: ['thinking'], }, turbo: { - provider: 'managed:kimi-code', + provider: 'managed:pythinker-code', model: 'pythinker-turbo', maxContextSize: 100, - displayName: 'Kimi Turbo', + displayName: 'Pythinker Turbo', capabilities: ['thinking'], }, }); @@ -5743,7 +7618,7 @@ command = "vim" const output = stripSgr((picker as ModelSelectorComponent).render(120).join('\n')); expect(output).toContain('Search: tu'); - expect(output).toContain('Kimi Turbo'); + expect(output).toContain('Pythinker Turbo'); expect(output).not.toContain('Pythinker Alpha'); (picker as ModelSelectorComponent).handleInput('\u001B'); @@ -5791,7 +7666,7 @@ command = "vim" } }); - it('forks the active session and switches to the returned session', async () => { + it('forks the active session and stays in the source session', async () => { const originalTitle = process.title; const source = makeSession({ id: 'ses-source', @@ -5814,18 +7689,91 @@ command = "vim" id: 'ses-source', title: 'Fork: Source title', }); - expect(driver.getCurrentSessionId()).toBe('ses-fork'); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( + 'Session forked (ses-fork). Still in the original session; switch to the fork via /sessions.', + ); }); - expect(setTitle).toHaveBeenCalledWith('Fork: Source title'); + expect(copyTextToClipboard).toHaveBeenCalledWith( + "cd '/tmp/proj-a' && pythinker --resume 'ses-fork'", + ); + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + expect(transcript).toContain( + "To enter the fork in a new process, run: cd '/tmp/proj-a' && pythinker --resume 'ses-fork'", + ); + expect(transcript).toContain('Command copied to clipboard'); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + expect(source.close).not.toHaveBeenCalled(); + expect(forked.close).toHaveBeenCalledOnce(); + expect(forked.onEvent).not.toHaveBeenCalled(); + expect(setTitle).not.toHaveBeenCalled(); expect(process.title).toBe('pythinker-test-runner'); - expect(source.close).toHaveBeenCalledOnce(); - expect(forked.onEvent).toHaveBeenCalledOnce(); expect(harness.resumeSession).not.toHaveBeenCalled(); + } finally { + process.title = originalTitle; + } + }); + + it('still prints the fork resume command when the clipboard copy fails', async () => { + vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error('no clipboard')); + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + expect(transcript).toContain( + "To enter the fork in a new process, run: cd '/tmp/proj-a' && pythinker --resume 'ses-fork'", + ); + expect(transcript).toContain('Failed to copy command to clipboard'); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + }); + + it('labels OSC 52 clipboard delivery as unverified after a fork', async () => { + vi.mocked(copyTextToClipboard).mockResolvedValueOnce('osc52'); + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( - 'Session forked (ses-fork). To return to the original session: pythinker -r ses-source', + 'Command copied via terminal escape sequence (unverified)', ); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + }); + + it('prints a pushd-based fork resume command on Windows', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }, { + ...makeStartupInput(), + workDir: 'D:\\proj', + }); + + driver.handleUserInput('/fork'); + + // cmd.exe's `cd` does not switch drives; pushd works in cmd + PowerShell. + await vi.waitFor(() => { + expect(copyTextToClipboard).toHaveBeenCalledWith( + 'pushd "D:\\proj" && pythinker --resume "ses-fork"', + ); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); } finally { - process.title = originalTitle; + if (platformDescriptor !== undefined) { + Object.defineProperty(process, 'platform', platformDescriptor); + } } }); @@ -5849,6 +7797,25 @@ command = "vim" }); }); + it('reports when the forked runtime cannot be released', async () => { + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + forked.close.mockRejectedValueOnce(new Error('close unavailable')); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { + expect(forked.close).toHaveBeenCalledOnce(); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( + 'Session forked (ses-fork), but failed to release its runtime: close unavailable', + ); + }); + expect(source.close).not.toHaveBeenCalled(); + }); + it('does not create a thinking component for empty thinking deltas', async () => { const { driver } = await makeDriver(); driver.state.appState.streamingPhase = 'thinking'; @@ -5882,71 +7849,105 @@ command = "vim" ); driver.streamingUI.flushNow(); + // Nothing to render: no component, and the phase is not hijacked into thinking. + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + + // Real thinking text after the whitespace still starts thinking normally. + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: 'actual reasoning', + } as Event, + vi.fn(), + ); + driver.streamingUI.flushNow(); + + expect(driver.state.appState.streamingPhase).toBe('thinking'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); + expect(stripSgr(renderTranscript(driver))).not.toContain('actual reasoning'); + }); + + it('does not create a thinking component for whitespace-only thinking on session replay', async () => { + const { driver } = await makeDriver(); + + // Session replay flushes stored thinking verbatim through onThinkingUpdate + // (see SessionReplayRenderer.flushAssistant), so a persisted whitespace-only + // think part must not become a bare bullet line. + driver.streamingUI.onThinkingUpdate(' '); + driver.streamingUI.onThinkingEnd(); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ThinkingComponent, + ), + ).toHaveLength(0); + + // Real stored thinking still creates a component, but stays collapsed. + driver.streamingUI.onThinkingUpdate('visible reasoning'); + driver.streamingUI.onThinkingEnd(); + + expect( + driver.state.transcriptContainer.children.filter( + (child) => child instanceof ThinkingComponent, + ), + ).toHaveLength(1); + expect(stripSgr(renderTranscript(driver))).not.toContain('visible reasoning'); + }); + + it('keeps the waiting moon spinner while reasoning streams only empty (encrypted) thinking deltas', async () => { + const { driver } = await makeDriver(); + + // Turn begins -> waiting mode shows the moon spinner. + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + + // Encrypted reasoning: thinking.delta events whose visible text is empty. + for (let i = 0; i < 3; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'main', + sessionId: 'ses-1', + delta: '', + } as Event, + vi.fn(), + ); + } + + // The indicator must stay up: still waiting, no orphan thinking component, + // and the activity pane still renders it (no blank, spinner-less gap). expect(driver.state.appState.streamingPhase).toBe('waiting'); + expect(driver.state.livePane.mode).toBe('waiting'); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); + const activity = stripSgr(renderActivity(driver)); + expect(MOON_SPINNER_FRAMES.some((frame) => activity.includes(frame))).toBe(true); + // Real thinking text finally arrives -> transition into thinking mode. driver.sessionEventHandler.handleEvent( { type: 'thinking.delta', agentId: 'main', sessionId: 'ses-1', - delta: 'visible reasoning', + delta: 'actual reasoning', } as Event, vi.fn(), ); driver.streamingUI.flushNow(); - - expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); expect(driver.state.appState.streamingPhase).toBe('thinking'); - // Collapsed live thinking renders only the spinner header, never the text. - expect(stripSgr(renderTranscript(driver))).not.toContain('visible reasoning'); - }); - it('keeps the live thinking spinner in prompt chrome, not the transcript', async () => { - const { driver } = await makeDriver(); - - driver.sessionEventHandler.handleEvent( - { - type: 'thinking.delta', - agentId: 'main', - sessionId: 'ses-1', - delta: 'visible reasoning', - } as Event, - vi.fn(), - ); - driver.streamingUI.flushNow(); - - const activity = stripSgr(renderActivity(driver)); - const transcript = stripSgr(renderTranscript(driver)); - expect(activity).toContain(BRAILLE_SPINNER_FRAMES[0]); - expect(transcript).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/u); - expect( - driver.state.activityContainer.children.some((child) => child instanceof ThinkingComponent), - ).toBe(true); - expect( - driver.state.transcriptContainer.children.some((child) => child instanceof ThinkingComponent), - ).toBe(false); - }); - it('expands the prompt-mounted thinking component with the shared toggle', async () => { - const { driver } = await makeDriver(); - - driver.streamingUI.onThinkingUpdate('line one\nline two'); - (driver as unknown as PythinkerTUI).toggleToolOutputExpansion(); - - expect(stripSgr(renderActivity(driver))).toContain('line two'); - }); - - it('does not create a thinking component for whitespace-only replay content', async () => { - const { driver } = await makeDriver(); - - driver.streamingUI.onThinkingUpdate(' \n\t'); - driver.streamingUI.onThinkingEnd(); - - expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(false); - expect( - driver.state.transcriptContainer.children.filter( - (child) => child instanceof ThinkingComponent, - ), - ).toHaveLength(0); + expect(driver.streamingUI.hasActiveThinkingComponent()).toBe(true); }); it('finalizes an orphaned thinking component on turn end', async () => { @@ -6053,86 +8054,347 @@ command = "vim" }); }); -describe('message-flow feature parity baseline', () => { - it('links streaming completion and interaction behavior to active parity scenarios', () => { - const linked = PARITY_CASES.filter( - ({ legacyTest }) => legacyTest === LEGACY_TEST_PATHS.messageFlow, - ); - expect(linked.length).toBeGreaterThan(0); - expect( - linked.every(({ status, scenarioId }) => status === 'active' && scenarioId.length > 0), - ).toBe(true); +describe('/model status displayName override', () => { + it('shows the overridden display name in the switch status', async () => { + const session = makeSession(); + const setConfig = vi.fn(async () => ({ providers: {} })); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { + provider: 'managed:pythinker-code', + model: 'kimi-k2', + maxContextSize: 100, + displayName: 'Kimi K2', + capabilities: ['thinking'], + }, + turbo: { + provider: 'managed:pythinker-code', + model: 'pythinker-turbo', + maxContextSize: 100, + displayName: 'Remote Turbo', + capabilities: ['thinking'], + overrides: { displayName: 'Custom Turbo' }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: false }, + })), + setConfig, + }); + + driver.handleUserInput('/model turbo'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(TabbedModelSelectorComponent); + }); + (driver.state.editorContainer.children[0] as TabbedModelSelectorComponent).handleInput('\r'); + + await vi.waitFor(() => { + expect(setConfig).toHaveBeenCalledWith({ + defaultModel: 'turbo', + thinking: { enabled: false }, + }); + }); + + expect(renderTranscript(driver)).toContain('Switched to Custom Turbo with thinking off.'); + expect(renderTranscript(driver)).not.toContain('Remote Turbo'); }); }); -describe('scrollback bridge wiring', () => { - it('mirrors assistant text into scrollback when a bridge is attached', async () => { - const { driver } = await makeDriver(); - const written: string[] = []; - driver.streamingUI.setScrollbackBridge( - new ScrollbackBridge({ sink: (text) => written.push(text) }), +describe('/effort support_efforts override', () => { + it('warns and applies efforts hidden by an Anthropic support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'pythinker', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + displayName: 'Compatible Model', + capabilities: ['thinking'], + supportEfforts: ['low', 'high', 'max'], + overrides: { supportEfforts: ['low', 'high'] }, + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(session.setThinking).toHaveBeenCalledWith('max'); + }); + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain('Thinking set to max.'); + }); + const transcript = renderTranscript(driver).replaceAll(/\s+/g, ' '); + expect(transcript).toContain( + 'Thinking effort "max" is not listed for k2 (known: low, high). Sending "max" unchanged; the configured provider will validate it.', ); + expect(transcript).toContain('Thinking set to max.'); + }); - driver.sessionEventHandler.handleEvent( - { - type: 'assistant.delta', - agentId: 'main', - sessionId: 'ses-1', - turnId: 1, - delta: '# Heading\n\ntail text', - } as Event, - vi.fn(), + it('offers the latest Opus efforts for an unknown Claude-marked Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-claude-model', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('offers no fallback efforts for a clearly non-Claude Anthropic-compatible model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'anthropic', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).not.toContain('Max'); + }); + + it('offers no fallback efforts for an unknown model on a Pythinker provider using the Anthropic protocol', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: { + compatible: { type: 'pythinker', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'compatible', + model: 'compatible-model', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).not.toContain('Max'); + }); + + it('offers the latest Opus efforts for a flat providerless Claude-marked Anthropic model', async () => { + const { driver } = await makeDriver(makeSession(), { + getConfig: vi.fn(async () => ({ + providers: {}, + models: { + // v2 flat model shape: no named provider, inline endpoint + protocol. + k2: { + model: 'compatible-claude-model', + baseUrl: 'https://anthropic.example.test', + protocol: 'anthropic', + maxContextSize: 100, + }, + }, + defaultModel: 'k2', + })), + }); + + driver.handleUserInput('/effort'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf(EffortSelectorComponent); + }); + const picker = driver.state.editorContainer.children[0] as EffortSelectorComponent; + expect(picker.render(80).join('\n')).toContain('Max'); + }); + + it('keeps rejecting efforts hidden by a Pythinker support_efforts override', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session, { + getConfig: vi.fn(async () => ({ + providers: { + pythinker: { type: 'pythinker', apiKey: 'test-key' }, + }, + models: { + k2: { + provider: 'pythinker', + model: 'pythinker-model', + maxContextSize: 100, + capabilities: ['thinking'], + supportEfforts: ['low', 'high'], + }, + }, + defaultModel: 'k2', + thinking: { enabled: true, effort: 'low' }, + })), + }); + + driver.handleUserInput('/effort max'); + + await vi.waitFor(() => { + expect(renderTranscript(driver)).toContain( + 'Unsupported thinking effort "max" for k2. Available: off, low, high', + ); + }); + expect(session.setThinking).not.toHaveBeenCalled(); + }); +}); + +describe('transcript step and assistant folding', () => { + function driveSteps(driver: MessageDriver, cycles: number): void { + for (let i = 0; i < cycles; i++) { + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: `msg-${i} `, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.call.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + name: 'Bash', + args: { command: 'ls' }, + } as Event, + vi.fn(), + ); + driver.sessionEventHandler.handleEvent( + { + type: 'tool.result', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + toolCallId: `call_${i}`, + output: 'ok', + isError: undefined, + } as Event, + vi.fn(), + ); + } + } + + it('folds the oldest assistant messages and steps beyond their per-turn caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('fold me'); + + const cycles = Math.max(TRANSCRIPT_KEEP_RECENT_ASSISTANT, TRANSCRIPT_KEEP_RECENT_STEPS) + 7; + driveSteps(driver, cycles); + + const children = driver.state.transcriptContainer.children; + const assistantCount = children.filter( + (child) => child instanceof AssistantMessageComponent, + ).length; + const toolCount = children.filter((child) => child instanceof ToolCallComponent).length; + expect(assistantCount).toBe(TRANSCRIPT_KEEP_RECENT_ASSISTANT); + expect(toolCount).toBe(TRANSCRIPT_KEEP_RECENT_STEPS); + + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`call ${cycles - TRANSCRIPT_KEEP_RECENT_STEPS} tools`); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT} messages`); + + // Folding drops mounted components only; every transcript entry is kept. + const assistantEntries = driver.state.transcriptEntries.filter( + (entry) => entry.kind === 'assistant', ); - driver.streamingUI.flushNow(); + expect(assistantEntries).toHaveLength(cycles); + }); - // The completed block is committed; the incomplete tail is still retained. - expect(written).toEqual(['# Heading\n']); + it('does not fold a turn within the caps', async () => { + const { driver } = await makeDriver(); + driver.handleUserInput('small turn'); + driveSteps(driver, 3); - driver.streamingUI.finalizeAssistantStream(); - expect(written).toEqual(['# Heading\n', 'tail text\n']); + const children = driver.state.transcriptContainer.children; + expect(children.filter((child) => child instanceof AssistantMessageComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(3); + expect(children.filter((child) => child instanceof StepSummaryComponent)).toHaveLength(0); }); - it('writes nothing when no bridge is attached', async () => { + it('folds a completed turn down to its conclusion tail on turn end', async () => { const { driver } = await makeDriver(); + driver.handleUserInput('round one'); + const cycles = 10; + driveSteps(driver, cycles); + + // Below the active-turn caps, nothing folds while the turn is live. + let children = driver.state.transcriptContainer.children; + expect( + children.filter((child) => child instanceof AssistantMessageComponent), + ).toHaveLength(cycles); driver.sessionEventHandler.handleEvent( { - type: 'assistant.delta', + type: 'turn.ended', agentId: 'main', sessionId: 'ses-1', turnId: 1, - delta: '# Heading\n\ntail text', + reason: 'completed', } as Event, vi.fn(), ); - driver.streamingUI.flushNow(); - driver.streamingUI.finalizeAssistantStream(); - expect(stripSgr(renderTranscript(driver))).toContain('Heading'); - }); -}); - -describe('scrollback bridge thinking wiring', () => { - it('mirrors thinking text into scrollback and closes it on end', async () => { - const { driver } = await makeDriver(); - const written: string[] = []; - driver.streamingUI.setScrollbackBridge( - new ScrollbackBridge({ sink: (text) => written.push(text) }), - ); + children = driver.state.transcriptContainer.children; + const assistants = children.filter((child) => child instanceof AssistantMessageComponent); + expect(assistants).toHaveLength(TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED); - driver.streamingUI.onThinkingUpdate('Considering options\n\nstill going'); - expect(written).toEqual(['Considering options\n']); + const summaries = children.filter((child) => child instanceof StepSummaryComponent); + expect(summaries).toHaveLength(1); + const summaryText = stripSgr(summaries[0]!.render(120).join('\n')); + expect(summaryText).toContain(`${cycles - TRANSCRIPT_KEEP_RECENT_ASSISTANT_COMPLETED} messages`); - driver.streamingUI.onThinkingEnd(); - expect(written).toEqual(['Considering options\n', 'still going\n']); + // Steps below the step cap are untouched by the completed-turn fold. + expect(children.filter((child) => child instanceof ToolCallComponent)).toHaveLength(cycles); - // A second block must not reopen the finished entry. - driver.streamingUI.onThinkingUpdate('A later thought\n\n'); - driver.streamingUI.onThinkingEnd(); - expect(written).toEqual([ - 'Considering options\n', - 'still going\n', - 'A later thought\n', - ]); + // The conclusion stays mounted. + const lastAssistant = assistants.at(-1)!; + expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`); }); }); diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 27640c7c..32358d8e 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -1,40 +1,21 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { - deleteAllKittyImages, - resetCapabilitiesCache, - setCapabilities, -} from '@earendil-works/pi-tui'; -import { - CATALOG_PLATFORM_VALUE_PREFIX, - log, - type GoalSnapshot, -} from '@pymodel/pythinker-code-sdk'; +import { log, type GoalSnapshot } from '@pymodel/pythinker-code-sdk'; +import type { MigrationPlan } from '@pymodel/migration-legacy'; import { describe, expect, it, vi } from 'vitest'; import { BannerProvider } from '#/tui/banner/banner-provider'; import { readBannerDisplayState } from '#/tui/banner/state'; import { handleLoginCommand, handleLogoutCommand } from '#/tui/commands/auth'; -import { - promptApiKey, - promptModelSelectionForCatalog, - promptPlatformSelection, - promptLogoutProviderSelection, -} from '#/tui/commands/prompts'; +import { promptPlatformSelection, promptLogoutProviderSelection } from '#/tui/commands/prompts'; import { BannerComponent } from '#/tui/components/chrome/banner'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; -import type { TuiPresentation } from '#/tui/runtime/contracts'; -import type { - FooterStatusRowViewModel, - FooterViewModel, -} from '#/tui/runtime/footer/footer-model'; -import type { AppState } from '#/tui/types'; -import { REPLAY_TURN_LIMIT } from '#/tui/utils/message-replay'; +import { REPLAY_FETCH_TURN_LIMIT } from '#/tui/utils/message-replay'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { quoteShellArg } from '#/utils/shell-quote'; import { DISABLE_TERMINAL_THEME_REPORTING, ENABLE_TERMINAL_THEME_REPORTING, @@ -42,23 +23,11 @@ import { QUERY_TERMINAL_THEME, TERMINAL_THEME_LIGHT, } from '#/tui/utils/terminal-theme'; -import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; - -/** The picker colours labels and values separately, so raw frames interleave SGR escapes. */ -const ANSI_SGR = /\u001B\[[0-9;]*m/g; -const stripAnsi = (frame: string): string => frame.replaceAll(ANSI_SGR, ''); vi.mock('#/tui/commands/prompts', async (importOriginal) => { const actual = await importOriginal<typeof import('#/tui/commands/prompts')>(); - return { - ...actual, - promptApiKey: vi.fn(), - promptModelSelectionForCatalog: vi.fn(), - promptPlatformSelection: vi.fn(), - promptLogoutProviderSelection: vi.fn(), - }; + return { ...actual, promptPlatformSelection: vi.fn(), promptLogoutProviderSelection: vi.fn() }; }); - vi.mock('#/utils/clipboard/clipboard-text', () => ({ copyTextToClipboard: vi.fn(async () => {}), })); @@ -67,6 +36,7 @@ const copyTextToClipboardMock = vi.mocked(copyTextToClipboard); interface StartupDriver { state: TUIState; + authFlow: PythinkerTUI['authFlow']; init(): Promise<boolean>; handleLoginCommand(): Promise<void>; handleLogoutCommand(): Promise<void>; @@ -77,109 +47,29 @@ interface RuntimeStateDriver extends StartupDriver { closeSession(reason: string): Promise<void>; } -interface UpdatePollDriver extends StartupDriver { - startUpdateStatusPolling(): void; - stopUpdateStatusPolling(): void; -} - interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } -interface InitMainTuiDriver extends StartupDriver { - initMainTui(): Promise<boolean>; -} - -interface StartFailureDriver extends StartupDriver { +interface MigrateExitDriver extends StartupDriver { start(): Promise<void>; - startEventLoop(): void; + onExit?: (code?: number) => Promise<void>; + runMigrationScreen(plan: unknown): Promise<unknown>; initMainTui(): Promise<boolean>; terminalFocusTrackingDispose?: () => void; } -interface PresentationDriver extends StartupDriver { - readonly presentation: TuiPresentation; - startEventLoop(): void; - clearTerminalInlineImages(): void; - persistInputHistory(text: string): Promise<void>; - updateTerminalTitle(): void; - updateActivityPane(): void; - updateEditorBorderHighlight(text?: string): void; - restoreInputText(text: string): void; - setAppState(patch: Partial<AppState>): void; - patchLivePane(patch: { readonly mode?: 'idle' | 'waiting' | 'thinking' | 'tool' | 'session' }): void; - resetLivePane(): void; -} - -class RecordingPresentation implements TuiPresentation { - readonly events: string[] = []; - readonly footerModels: FooterViewModel[] = []; - composerText = 'composer draft'; - resizeHandler: (() => void) | undefined; - - start(onResize: () => void): void { - this.events.push('start'); - this.resizeHandler = onResize; - this.events.push('resize:registered'); - } - - stop(): void { - this.events.push('stop'); - } - - async drainInput(): Promise<void> { - this.events.push('drainInput'); - } - - setTerminalTitle(title: string): void { - this.events.push(`title:${title}`); - } - - setTerminalProgress(active: boolean): void { - this.events.push(`progress:${String(active)}`); - } - - writeTerminalControl(sequence: string): void { - this.events.push(`control:${sequence}`); - } - - getComposerText(): string { - this.events.push('composer:getText'); - return this.composerText; - } - - setComposerText(text: string): void { - this.events.push(`composer:setText:${text}`); - this.composerText = text; - } - - focusComposer(): void { - this.events.push('composer:focus'); - } - - addComposerHistory(text: string): void { - this.events.push(`composer:history:${text}`); - } - - notifyIdle(): void { - this.events.push('idle'); - } - - updateFooter(viewModel: FooterViewModel): void { - this.footerModels.push(viewModel); - } -} - -function footerStatusItems(viewModel: FooterViewModel | undefined): readonly string[] { - const status = viewModel?.rows.find( - (row): row is FooterStatusRowViewModel => row.kind === 'status', - ); - return status?.items ?? []; -} - -function footerRowKinds(viewModel: FooterViewModel | undefined): readonly string[] { - return viewModel?.rows.map((row) => row.kind) ?? []; -} +const MIGRATION_PLAN: MigrationPlan = { + sourceHome: '/x/.pythinker', + hasConfig: false, + hasMcp: false, + hasUserHistory: false, + oauthCredentials: [], + workdirs: [], + detectedPlugins: [], + detectedMcpOauthServers: [], + totalSessions: 0, +}; function makeStartupInput( cliOptions: Partial<PythinkerTUIStartupInput['cliOptions']> = {}, @@ -189,7 +79,6 @@ function makeStartupInput( cliOptions: { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -197,17 +86,18 @@ function makeStartupInput( outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], ...cliOptions, }, tuiConfig: { theme: 'dark', - layout: 'inline', + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, + statusLine: { items: null, command: null }, ...tuiConfig, - copyFullResponse: tuiConfig.copyFullResponse ?? false, - statusLine: tuiConfig.statusLine ?? DEFAULT_STATUS_LINE_CONFIG, }, version: '0.0.0-test', workDir: '/tmp/proj-a', @@ -221,7 +111,7 @@ function makeSession(overrides: Record<string, unknown> = {}) { summary: { title: 'Session title' }, getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -281,7 +171,7 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool config: { cwd: '/tmp/proj-a', modelCapabilities: { max_context_tokens: 100 }, - thinkingLevel: 'off', + thinkingEffort: 'off', systemPrompt: '', }, context: { history: [], tokenCount: 10 }, @@ -298,16 +188,16 @@ function createResumeState(overrides: { permissionMode?: string; planMode?: bool } function loginRequiredError(): Error & { readonly code: string } { - return Object.assign(new Error('OAuth provider "managed:kimi-code" requires login.'), { + return Object.assign(new Error('OAuth provider "managed:pythinker-code" requires login.'), { code: 'auth.login_required', }); } function makeHarness(session = makeSession(), overrides: Record<string, unknown> = {}) { - return { + const harness = { getConfig: vi.fn(async () => ({ models: { - k2: { model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, })), createSession: vi.fn(async () => session), @@ -317,6 +207,7 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> track: vi.fn(), setTelemetryContext: vi.fn(), getExperimentalFeatures: vi.fn(async () => []), + supportsAtomicSectionReplace: vi.fn(() => false), auth: { status: vi.fn(async () => ({ providers: [] })), login: vi.fn(async () => {}), @@ -325,6 +216,23 @@ function makeHarness(session = makeSession(), overrides: Record<string, unknown> }, ...overrides, }; + // The TUI lists sessions through keyset pages; derive the page mock from + // the (possibly overridden) full-list mock unless a test overrides paging. + if (!('listSessionsPage' in harness)) { + const listSessions = harness.listSessions as (input?: { + workDir?: string; + sessionId?: string; + }) => Promise<unknown[]>; + Object.assign(harness, { + listSessionsPage: vi.fn( + async (input: { workDir?: string; sessionId?: string } = {}) => ({ + items: await listSessions({ workDir: input.workDir, sessionId: input.sessionId }), + nextCursor: undefined, + }), + ), + }); + } + return harness; } function makeDriver(harness: ReturnType<typeof makeHarness>, input: PythinkerTUIStartupInput) { @@ -353,262 +261,295 @@ function captureInputListeners(driver: StartupDriver) { } describe('PythinkerTUI startup', () => { - it('projects normalized status-line configuration into app state and the first footer model', () => { - const presentation = new RecordingPresentation(); - const statusLine = { - ...DEFAULT_STATUS_LINE_CONFIG, - showModel: false, - showContextBar: false, - }; - const driver = new PythinkerTUI( - makeHarness() as never, - makeStartupInput( - { model: 'hidden-model' }, - { statusLine }, - ), - presentation, - ) as unknown as PresentationDriver; - - expect(driver.state.appState.statusLine).toEqual(statusLine); - expect(footerStatusItems(presentation.footerModels.at(-1))).not.toEqual( - expect.arrayContaining([ - expect.stringContaining('hidden-model'), - expect.stringContaining('▱'), - ]), + it('creates a fresh session from startup flags and syncs runtime state', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', + planMode: true, + contextTokens: 25, + maxContextTokens: 200, + contextUsage: 0.125, + })), + }); + const harness = makeHarness(session); + const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + permission: 'yolo', + planMode: true, + }); + expect(session.setApprovalHandler).toHaveBeenCalledOnce(); + expect(session.setQuestionHandler).toHaveBeenCalledOnce(); + expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); + expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState).toMatchObject({ + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', + planMode: true, + contextTokens: 25, + maxContextTokens: 200, + contextUsage: 0.125, + sessionTitle: 'Session title', + }); + }); + + it('starts session-less on the v2 engine and carries startup flags to appState', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + // CLI --yolo must win over the config default. + defaultPermissionMode: 'auto', + })), + }); + const driver = makeDriver( + harness, + { ...makeStartupInput({ model: 'k2', yolo: true }), engineV2: true }, ); - driver.state.footer.dispose(); + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.startupState).toBe('ready'); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'yolo', + }); }); - it('moves thinking effort into the shared footer model instead of the editor frame', () => { - const presentation = new RecordingPresentation(); - const driver = new PythinkerTUI( - makeHarness() as never, - makeStartupInput(), - presentation, - ) as unknown as PresentationDriver; - vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + it('mounts the docked fullscreen layout when PYTHINKER_CODE_TUI_FULL_SCREEN=1', async () => { + const harness = makeHarness(makeSession()); + vi.stubEnv('PYTHINKER_CODE_TUI_FULL_SCREEN', '1'); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + vi.unstubAllEnvs(); - driver.setAppState({ model: 'DeepSeek V4 Flash', thinkingLevel: 'max' }); + // buildLayout() runs in the constructor: fullscreen keeps the root + // children list empty and mounts the layout root instead. + expect(driver.state.ui.mode).toBe('fullscreen'); + expect(driver.state.ui.children).toHaveLength(0); - const editor = driver.state.editor - .render(40) - .map((line) => line.replaceAll(/\u001B\[[0-9;]*m/g, '')) - .join('\n'); - const footer = presentation.footerModels.at(-1); + await expect(driver.init()).resolves.toBe(false); + (driver as unknown as { mountFooter(): void }).mountFooter(); - expect(editor).not.toContain('● max'); - expect(footer?.rows).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: 'status', - items: expect.arrayContaining(['DeepSeek V4 Flash · max']), - }), - ]), - ); + // Dock = 5 chrome containers + footer wrap, below the transcript viewport. + expect(driver.state.dockContainer?.children).toHaveLength(6); }); - it('refreshes shared elapsed once per second and stops when streaming becomes idle', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-08-02T00:00:00.000Z')); - const presentation = new RecordingPresentation(); - const driver = new PythinkerTUI( - makeHarness() as never, - makeStartupInput(), - presentation, - ) as unknown as PresentationDriver; - vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + it('shows a session-less notice on v2 startup', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); - try { - driver.setAppState({ - dynamicWorkflowMode: true, - streamingPhase: 'waiting', - streamingStartTime: Date.now(), - }); - expect(footerStatusItems(presentation.footerModels.at(-1))).toContain('elapsed 00:00'); + await expect(driver.init()).resolves.toBe(false); + await ( + driver as unknown as { finishStartup(shouldReplayHistory: boolean): Promise<void> } + ).finishStartup(false); - vi.advanceTimersByTime(3_000); - expect(footerStatusItems(presentation.footerModels.at(-1))).toContain('elapsed 00:03'); + const transcript = driver.state.transcriptContainer.render(160).join('\n'); + expect(transcript).toContain('No session yet — one will be created on your first message.'); + }); - driver.setAppState({ streamingPhase: 'idle' }); - expect(footerStatusItems(presentation.footerModels.at(-1))).not.toContain('elapsed 00:03'); - const footerUpdatesAtIdle = presentation.footerModels.length; + it('shows config defaults in appState before the lazy session exists (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { model: 'moonshot-v1', maxContextSize: 200 }, + }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + thinking: { enabled: true, effort: 'high' }, + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); - vi.advanceTimersByTime(2_000); - expect(presentation.footerModels).toHaveLength(footerUpdatesAtIdle); - } finally { - driver.state.footer.dispose(); - vi.useRealTimers(); - } - }); + await expect(driver.init()).resolves.toBe(false); - it('projects live non-workflow activity into the injected footer and hides it when idle', () => { - const presentation = new RecordingPresentation(); - const driver = new PythinkerTUI( - makeHarness() as never, - makeStartupInput(), - presentation, - ) as unknown as PresentationDriver; - vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + maxContextTokens: 200, + permissionMode: 'auto', + planMode: true, + thinkingEffort: 'high', + }); + }); - try { - driver.patchLivePane({ mode: 'waiting' }); - - expect(footerRowKinds(presentation.footerModels.at(-1))).toEqual([ - 'activity', - 'composer', - 'status', - ]); - expect(presentation.footerModels.at(-1)?.rows[0]).toMatchObject({ - kind: 'activity', - primary: '⠋ Waiting…', - }); - expect(driver.state.activityContainer.children).toHaveLength(1); + it('hydrates the model default effort when thinking is enabled without an effort (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 200, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }, + }, + defaultModel: 'k2', + thinking: { enabled: true }, + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); - driver.resetLivePane(); + await expect(driver.init()).resolves.toBe(false); - expect(footerRowKinds(presentation.footerModels.at(-1))).toEqual([ - 'composer', - 'status', - ]); - } finally { - driver.state.footer.dispose(); - } + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState.thinkingEffort).toBe('high'); }); - it('routes host presentation operations through the injected contract in order', async () => { - const presentation = new RecordingPresentation(); - const harness = makeHarness(); - const driver = new PythinkerTUI( - harness as never, - makeStartupInput(), - presentation, - ) as unknown as PresentationDriver; - vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - const requestRender = vi.spyOn(driver.state.ui, 'requestRender').mockImplementation(() => {}); - driver.state.appState.sessionTitle = 'Presentation contract'; - driver.state.terminalState.supportsProgress = true; - const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); - Object.defineProperty(process.stdout, 'columns', { configurable: true, value: 80 }); + it('hydrates the model default effort when no [thinking] section exists (v2)', async () => { + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ + models: { + k2: { + model: 'moonshot-v1', + maxContextSize: 200, + capabilities: ['thinking'], + supportEfforts: ['low', 'medium', 'high'], + }, + }, + defaultModel: 'k2', + })), + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); - try { - driver.startEventLoop(); - expect(presentation.resizeHandler).toBeTypeOf('function'); - requestRender.mockClear(); - presentation.resizeHandler?.(); - expect(requestRender).toHaveBeenCalledOnce(); - driver.updateTerminalTitle(); - driver.state.appState.streamingPhase = 'waiting'; - driver.state.livePane.mode = 'waiting'; - driver.updateActivityPane(); - setCapabilities({ images: 'kitty', trueColor: true, hyperlinks: true }); - driver.clearTerminalInlineImages(); - driver.updateEditorBorderHighlight(); - driver.restoreInputText('restored draft'); - await driver.persistInputHistory('saved input'); - driver.state.livePane.mode = 'idle'; - driver.setAppState({ streamingPhase: 'idle' }); - await driver.stop(); - - expect(driver.presentation).toBe(presentation); - expect(presentation.events).toEqual([ - 'start', - 'resize:registered', - 'title:Presentation contract', - 'progress:true', - `control:${deleteAllKittyImages()}`, - 'composer:focus', - 'composer:setText:restored draft', - 'composer:history:saved input', - 'progress:false', - 'idle', - 'drainInput', - 'stop', - ]); - } finally { - if (stdoutColumns === undefined) { - Reflect.deleteProperty(process.stdout, 'columns'); - } else { - Object.defineProperty(process.stdout, 'columns', stdoutColumns); - } - resetCapabilitiesCache(); - } - }); + await expect(driver.init()).resolves.toBe(false); - it('mounts the fixed full-height layout root when layout is fixed', () => { - const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({}, { layout: 'fixed' })); - expect(driver.state.ui.children).toEqual([driver.state.layoutRoot]); + expect(driver.state.appState.thinkingEffort).toBe('medium'); }); - it('places the status bar directly below the editor in inline layout', () => { - const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({}, { layout: 'inline' })); - const children = driver.state.ui.children; - expect(children[0]).toBe(driver.state.transcriptContainer); - expect(children.indexOf(driver.state.mcpStatusContainer)).toBe( - children.indexOf(driver.state.editorContainer) - 1, - ); - expect(children.indexOf(driver.state.editorContainer)).toBe( - children.indexOf(driver.state.statusBarContainer) - 1, - ); + it('hydrates permission/plan defaults after a session-less v2 login', async () => { + let loggedIn = false; + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => + loggedIn + ? { + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultModel: 'k2', + defaultPermissionMode: 'auto', + defaultPlanMode: true, + } + : { models: {} }, + ), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => { + loggedIn = true; + }), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + permissionMode: 'manual', + planMode: false, + }); + + // Simulate a completed provider login (the managed OAuth entry is gone; + // any login path ends in refreshConfigAfterLogin). + loggedIn = true; + await driver.authFlow.refreshConfigAfterLogin(); + + // Login must not create a session on v2, but the refreshed config + // defaults must reach the first lazy-created session. + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: 'k2', + permissionMode: 'auto', + planMode: true, + configDefaultPlanMode: true, + }); }); - it('places MCP startup status immediately above the editor in fixed layout', () => { - const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({}, { layout: 'fixed' })); - const component = (label: string) => ({ - render: () => [label], - invalidate: () => {}, + it('hydrates permission defaults after a session-less v2 login without a default model', async () => { + let loggedIn = false; + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => + loggedIn + ? { + models: { k2: { model: 'moonshot-v1', maxContextSize: 100 } }, + defaultPermissionMode: 'auto', + } + : { models: {} }, + ), + auth: { + status: vi.fn(async () => ({ providers: [] })), + login: vi.fn(async () => { + loggedIn = true; + }), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, }); - driver.state.btwPanelContainer.addChild(component('btw')); - driver.state.mcpStatusContainer.addChild(component('mcp')); - driver.state.editorContainer.clear(); - driver.state.editorContainer.addChild(component('editor')); - Object.defineProperty(driver.state.terminal, 'rows', { get: () => 20 }); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + + await expect(driver.init()).resolves.toBe(false); + + loggedIn = true; + await driver.authFlow.refreshConfigAfterLogin(); - const output = driver.state.layoutRoot.render(80).join('\n'); - expect(output.indexOf('btw')).toBeLessThan(output.indexOf('mcp')); - expect(output.indexOf('mcp')).toBeLessThan(output.indexOf('editor')); + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + permissionMode: 'auto', + }); }); - it('creates a fresh session from startup flags and syncs runtime state', async () => { - const session = makeSession({ - getStatus: vi.fn(async () => ({ - model: 'k2', - thinkingLevel: 'off', - permission: 'yolo', - planMode: true, - contextTokens: 25, - maxContextTokens: 200, - contextUsage: 0.125, - })), + it('carries the --agent/--agent-file binding for the lazy-created first session (v2)', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver( + harness, + { + ...makeStartupInput({ model: 'k2', agentFiles: ['agent.md'] }), + engineV2: true, + agentProfile: 'reviewer', + }, + ); + + await expect(driver.init()).resolves.toBe(false); + + expect(harness.createSession).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + agentProfile: 'reviewer', + agentFiles: ['agent.md'], }); + }); + + it('binds the resolved agent profile and agent files to the startup session', async () => { + const session = makeSession(); const harness = makeHarness(session); - const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', + }); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', - permission: 'yolo', - planMode: true, + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], }); - expect(session.setApprovalHandler).toHaveBeenCalledOnce(); - expect(session.setQuestionHandler).toHaveBeenCalledOnce(); - expect(harness.setTelemetryContext).toHaveBeenCalledWith({ sessionId: null }); - expect(harness.setTelemetryContext).toHaveBeenLastCalledWith({ sessionId: 'ses-1' }); expect(driver.state.startupState).toBe('ready'); - expect(driver.state.appState).toMatchObject({ - sessionId: 'ses-1', - model: 'k2', - permissionMode: 'yolo', - planMode: true, - contextTokens: 25, - maxContextTokens: 200, - contextUsage: 0.125, - sessionTitle: 'Session title', - }); }); it('resumes the latest session for --continue and marks history for replay', async () => { @@ -622,7 +563,7 @@ describe('PythinkerTUI startup', () => { expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest', - replayTurnLimit: REPLAY_TURN_LIMIT, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); @@ -635,7 +576,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -663,7 +604,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -691,7 +632,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode, contextTokens: 10, @@ -718,7 +659,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -745,7 +686,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -770,7 +711,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-latest', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -836,7 +777,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-target', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -910,13 +851,13 @@ describe('PythinkerTUI startup', () => { it('passes the CLI model override when creating a fresh startup session', async () => { const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput({ model: 'kimi-code/k2.5' })); + const driver = makeDriver(harness, makeStartupInput({ model: 'pythinker-code/k2.5' })); await expect(driver.init()).resolves.toBe(false); expect(harness.createSession).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', - model: 'kimi-code/k2.5', + model: 'pythinker-code/k2.5', permission: undefined, planMode: undefined, }); @@ -930,7 +871,7 @@ describe('PythinkerTUI startup', () => { }), getStatus: vi.fn(async () => ({ model, - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: false, contextTokens: 10, @@ -943,13 +884,13 @@ describe('PythinkerTUI startup', () => { }); const driver = makeDriver( harness, - makeStartupInput({ continue: true, model: 'kimi-code/k2.5' }), + makeStartupInput({ continue: true, model: 'pythinker-code/k2.5' }), ); await expect(driver.init()).resolves.toBe(true); - expect(session.setModel).toHaveBeenCalledWith('kimi-code/k2.5'); - expect(driver.state.appState.model).toBe('kimi-code/k2.5'); + expect(session.setModel).toHaveBeenCalledWith('pythinker-code/k2.5'); + expect(driver.state.appState.model).toBe('pythinker-code/k2.5'); }); it('enters picker startup for bare --session without creating a session', async () => { @@ -969,7 +910,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission, planMode: false, contextTokens: 10, @@ -1009,7 +950,7 @@ describe('PythinkerTUI startup', () => { id: 'ses-picked', getStatus: vi.fn(async () => ({ model: 'k2', - thinkingLevel: 'off', + thinkingEffort: 'off', permission: 'manual', planMode: true, contextTokens: 10, @@ -1154,17 +1095,138 @@ describe('PythinkerTUI startup', () => { expect(mountSessionPicker).toHaveBeenCalledTimes(1); }); - it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { - const currentWorkDirSession = { - id: 'ses-cwd', - title: 'Current cwd session', + function makePagedListSessionsPage() { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, workDir: '/tmp/proj-a', - updatedAt: Date.now(), - }; - const otherWorkDirSession = { - id: 'ses-other-cwd', - title: 'Other cwd session', - workDir: '/tmp/proj-b', + updatedAt: Date.now() - index * 1000, + })); + return vi.fn(async (input: { workDir?: string; before?: string } = {}) => + input.before === undefined + ? { items: firstPage, nextCursor: 'ses-page1-49' } + : { + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }, + ); + } + + it('fetches the next session page when the picker scrolls to the fetched end', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + expect(listSessionsPage).toHaveBeenCalledWith({ workDir: '/tmp/proj-a', limit: 50 }); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + expect(driver.state.sessions.map((session) => session.id)).toContain('ses-page2-0'); + }); + + it('drains the remaining session pages in the background once a query is typed', async () => { + const listSessionsPage = makePagedListSessionsPage(); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + expect(driver.state.sessions).toHaveLength(50); + + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + picker.handleInput('x'); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(51); + }); + + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + it('continues the search drain after an in-flight scroll fetch settles', async () => { + const firstPage = Array.from({ length: 50 }, (_, index) => ({ + id: `ses-page1-${String(index).padStart(2, '0')}`, + workDir: '/tmp/proj-a', + updatedAt: Date.now() - index * 1000, + })); + let resolveScrollPage!: (page: { items: unknown[]; nextCursor?: string }) => void; + const listSessionsPage = vi.fn((input: { workDir?: string; before?: string } = {}) => { + if (input.before === undefined) { + return Promise.resolve({ items: firstPage, nextCursor: 'ses-page1-49' }); + } + if (input.before === 'ses-page1-49') { + // The scroll-triggered page fetch stays pending until the test resolves it. + return new Promise<{ items: unknown[]; nextCursor?: string }>((resolve) => { + resolveScrollPage = resolve; + }); + } + return Promise.resolve({ + items: [{ id: 'ses-page3-0', workDir: '/tmp/proj-a', updatedAt: 0 }], + nextCursor: undefined, + }); + }); + const harness = makeHarness(makeSession({ id: 'ses-current' }), { listSessionsPage }); + const driver = makeDriver(harness, makeStartupInput()); + await expect(driver.init()).resolves.toBe(false); + + await (driver as unknown as { showSessionPicker(): Promise<void> }).showSessionPicker(); + const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; + // Reach the fetched end: the scroll-triggered fetch for page 2 starts. + for (let i = 0; i < 49; i++) { + picker.handleInput('\u001B[B'); + } + await vi.waitFor(() => { + expect(listSessionsPage).toHaveBeenCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page1-49', + }); + }); + + // Typing a query while that fetch is in flight must join it, not stop the + // drain: the remaining pages arrive after the in-flight one settles. + picker.handleInput('x'); + resolveScrollPage({ + items: [{ id: 'ses-page2-0', workDir: '/tmp/proj-a', updatedAt: 1 }], + nextCursor: 'ses-page2-0', + }); + await vi.waitFor(() => { + expect(driver.state.sessions).toHaveLength(52); + }); + expect(listSessionsPage).toHaveBeenLastCalledWith({ + workDir: '/tmp/proj-a', + limit: 50, + before: 'ses-page2-0', + }); + }); + + it('clears the sessions picker search query when toggling scope with Ctrl+A', async () => { + const currentWorkDirSession = { + id: 'ses-cwd', + title: 'Current cwd session', + workDir: '/tmp/proj-a', + updatedAt: Date.now(), + }; + const otherWorkDirSession = { + id: 'ses-other-cwd', + title: 'Other cwd session', + workDir: '/tmp/proj-b', updatedAt: Date.now() - 1000, }; const listSessions = vi.fn(async (input: { workDir?: string } = {}) => { @@ -1183,7 +1245,7 @@ describe('PythinkerTUI startup', () => { firstPicker.handleInput('c'); firstPicker.handleInput('w'); firstPicker.handleInput('d'); - expect(stripAnsi(firstPicker.render(160).join('\n'))).toContain('Search: cwd'); + expect(firstPicker.render(160).join('\n')).toContain('Search: cwd'); firstPicker.handleInput('\u0001'); await new Promise((resolve) => setImmediate(resolve)); @@ -1192,7 +1254,7 @@ describe('PythinkerTUI startup', () => { handleInput(data: string): void; render(width: number): string[]; }; - const output = stripAnsi(allPicker.render(160).join('\n')); + const output = allPicker.render(160).join('\n'); expect(driver.state.sessionsScope).toBe('all'); expect(output).toContain('All sessions'); @@ -1230,17 +1292,12 @@ describe('PythinkerTUI startup', () => { expect(resumeSession).not.toHaveBeenCalled(); expect(driver.state.activeDialog).toBeNull(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && pythinker --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && pythinker --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); expect(transcript).toContain('Current session is in a different working directory.'); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj-b' && pythinker --resume 'ses-other-cwd'", - ); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj-b' && pythinker --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); expect(transcript).toContain('Command copied to clipboard'); }); @@ -1273,13 +1330,10 @@ describe('PythinkerTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj$(touch /tmp/pwned)' && pythinker --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj$(touch /tmp/pwned)')} && pythinker --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); const transcript = driver.state.transcriptContainer.render(160).join('\n'); - expect(transcript).toContain( - "To resume, run: cd '/tmp/proj$(touch /tmp/pwned)' && pythinker --resume 'ses-other-cwd'", - ); + expect(transcript).toContain(`To resume, run: ${expectedResumeCmd}`); }); it('exits after picking another cwd from the startup picker', async () => { @@ -1304,7 +1358,7 @@ describe('PythinkerTUI startup', () => { const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); copyTextToClipboardMock.mockClear(); - await expect((driver as unknown as InitMainTuiDriver).initMainTui()).resolves.toBe(false); + await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; @@ -1313,9 +1367,8 @@ describe('PythinkerTUI startup', () => { await new Promise((resolve) => setImmediate(resolve)); expect(resumeSession).not.toHaveBeenCalled(); - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - "cd '/tmp/proj-b' && pythinker --resume 'ses-other-cwd'", - ); + const expectedResumeCmd = `cd ${quoteShellArg('/tmp/proj-b')} && pythinker --resume ${quoteShellArg('ses-other-cwd')}`; + expect(copyTextToClipboardMock).toHaveBeenCalledWith(expectedResumeCmd); expect(stop).toHaveBeenCalledOnce(); expect(stop).toHaveBeenCalledWith(0); }); @@ -1370,7 +1423,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver(harness, makeStartupInput({ session: '' })); const stop = vi.spyOn(driver, 'stop').mockResolvedValue(undefined); - await expect((driver as unknown as InitMainTuiDriver).initMainTui()).resolves.toBe(false); + await expect((driver as unknown as MigrateExitDriver).initMainTui()).resolves.toBe(false); await (driver as unknown as { bootstrapFromPicker(): Promise<void> }).bootstrapFromPicker(); const picker = driver.state.editorContainer.children[0] as { handleInput(data: string): void }; @@ -1461,6 +1514,121 @@ describe('PythinkerTUI startup', () => { expect(showStatus).toHaveBeenCalledWith("New Models · +2 models."); }); + it("stages provider-refresh removals and persists one atomic write on atomic-capable harnesses", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async (_sections: Record<string, unknown>) => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async () => ({})); + const harness = makeHarness(makeSession(), { + supportsAtomicSectionReplace: vi.fn(() => true), + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + thinking: { enabled: true }, + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(result.changed).toContainEqual({ providerId: "b", providerName: "b", added: 0, removed: 1 }); + // The removal was staged in memory: no destructive pre-write, exactly + // one atomic section replace carrying the complete records — with the + // dangling default model / thinking expressed as cleared sections. + expect(removeProvider).not.toHaveBeenCalled(); + expect(setConfig).not.toHaveBeenCalled(); + expect(replaceConfigSections).toHaveBeenCalledTimes(1); + const sections = replaceConfigSections.mock.calls[0]?.[0] as Record<string, unknown>; + expect(Object.keys(sections["providers"] as object)).toEqual(["a"]); + expect(sections["models"]).not.toHaveProperty("b/m1"); + expect(sections["defaultModel"]).toBeUndefined(); + expect(sections["thinking"]).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the two-phase removeProvider/setConfig host on harnesses without atomic replace", async () => { + const registryUrl = "https://registry.example.test/v1/models/api.json"; + const source = { kind: "apiJson", url: registryUrl, apiKey: "sk-test-token" }; + const replaceConfigSections = vi.fn(async () => {}); + const removeProvider = vi.fn(async () => ({})); + const setConfig = vi.fn(async (patch: Record<string, unknown>) => patch); + const harness = makeHarness(makeSession(), { + replaceConfigSections, + removeProvider, + setConfig, + getConfig: vi.fn(async () => ({ + providers: { + a: { type: "openai", baseUrl: "https://a.example.test/v1", apiKey: "sk-test-token", source }, + b: { type: "openai", baseUrl: "https://b.example.test/v1", apiKey: "sk-test-token", source }, + }, + models: { + "a/m1": { provider: "a", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + "b/m1": { provider: "b", model: "m1", maxContextSize: 100, capabilities: ["tool_use"] }, + }, + defaultModel: "b/m1", + })), + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + a: { + id: "a", + name: "Provider A", + api: "https://a.example.test/v1", + type: "openai", + models: { m1: { id: "m1" } }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + try { + const result = await (driver as any).authFlow.refreshProviderModels(); + + expect(result.failed).toEqual([]); + expect(removeProvider).toHaveBeenCalledWith("b"); + expect(setConfig).toHaveBeenCalledTimes(1); + expect(replaceConfigSections).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("starts TUI without a session when fresh startup needs OAuth login", async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { @@ -1476,7 +1644,7 @@ describe('PythinkerTUI startup', () => { expect(driver.state.appState).toMatchObject({ sessionId: '', model: '', - thinkingLevel: 'off', + thinkingEffort: 'off', contextTokens: 0, maxContextTokens: 0, contextUsage: 0, @@ -1484,178 +1652,208 @@ describe('PythinkerTUI startup', () => { }); }); + it('preserves fresh startup yolo and plan intent after OAuth login', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'yolo', + planMode: true, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), + }); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), + createSession, + }); + const driver = makeDriver(harness, makeStartupInput({ yolo: true, plan: true })); + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + permissionMode: 'yolo', + planMode: true, + }); + await driver.authFlow.refreshConfigAfterLogin(); - - it('connects a catalog provider with an environment API key', async () => { - const setConfig = vi.fn(async (patch: unknown) => patch); - const harness = makeHarness(makeSession(), { - getConfig: vi.fn(async () => ({ providers: {}, models: {} })), - removeProvider: vi.fn(), - setConfig, + expect(createSession).toHaveBeenNthCalledWith(1, { + workDir: '/tmp/proj-a', + permission: 'yolo', + planMode: true, }); - const driver = makeDriver(harness, makeStartupInput()); - vi.spyOn((driver as any).authFlow, 'refreshConfigAfterLogin').mockResolvedValue(undefined); - const catalog = { - deepseek: { - id: 'deepseek', - name: 'Example provider', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.example.test', - env: ['DEEPSEEK_API_KEY'], + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: 'yolo', + planMode: true, + }); + expect(driver.state.appState).toMatchObject({ + sessionId: 'ses-1', + model: 'k2', + permissionMode: 'yolo', + planMode: true, + }); + }); + + it('carries the agent binding into the post-login startup session', async () => { + const session = makeSession(); + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, models: { - chat: { - id: 'example-chat', - limit: { context: 128_000 }, - reasoning: true, - reasoning_options: [{ type: 'effort' as const, values: ['high', 'max'] }], - }, + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, - }, - }; - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, - catalog, + })), + createSession, + }); + const driver = makeDriver(harness, { + ...makeStartupInput({ agent: 'reviewer', agentFiles: ['reviewer.md'] }), + agentProfile: 'reviewer', }); - vi.mocked(promptApiKey).mockClear(); - vi.mocked(promptModelSelectionForCatalog).mockImplementation( - async (_host, _providerId, models) => ({ model: models[0]!, effort: 'max' }), - ); - try { - vi.stubEnv('DEEPSEEK_API_KEY', 'runtime-secret'); - - await handleLoginCommand(driver as any); - - expect(setConfig).toHaveBeenCalledWith( - expect.objectContaining({ - providers: { - deepseek: expect.objectContaining({ - type: 'openai', - baseUrl: 'https://api.example.test', - apiKeyEnvVar: 'DEEPSEEK_API_KEY', - source: { - kind: 'modelsDev', - url: 'https://models.dev/api.json', - }, - }), - }, - models: { - 'deepseek/example-chat': expect.objectContaining({ - supportEfforts: ['high', 'max'], - capabilities: ['thinking', 'tool_use', 'always_thinking'], - }), - }, - defaultModel: 'deepseek/example-chat', - defaultThinking: true, - }), - ); - const configPatch = setConfig.mock.calls[0]?.[0] as { - providers: Record<string, { apiKey?: string }>; - }; - expect(configPatch.providers['deepseek']?.apiKey).toBeUndefined(); - expect(promptApiKey).not.toHaveBeenCalled(); - expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'deepseek', - method: 'api_key_env', - }); - } finally { - vi.unstubAllEnvs(); - } + await expect(driver.init()).resolves.toBe(false); + + await driver.authFlow.refreshConfigAfterLogin(); + + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: undefined, + planMode: undefined, + agentProfile: 'reviewer', + agentFiles: ['reviewer.md'], + }); }); - it('prompts for an API key when the catalog provider environment variable is unset', async () => { - const setConfig = vi.fn(async (patch: unknown) => patch); - const harness = makeHarness(makeSession(), { - getConfig: vi.fn(async () => ({ providers: {}, models: {} })), - removeProvider: vi.fn(), - setConfig, + it('does not force manual permission after OAuth login without --yolo', async () => { + const session = makeSession({ + getStatus: vi.fn(async () => ({ + model: 'k2', + thinkingEffort: 'off', + permission: 'auto', + planMode: false, + contextTokens: 10, + maxContextTokens: 100, + contextUsage: 0.1, + })), }); - const driver = makeDriver(harness, makeStartupInput()); - vi.spyOn((driver as any).authFlow, 'refreshConfigAfterLogin').mockResolvedValue(undefined); - const showError = vi.spyOn(driver as any, 'showError').mockImplementation(() => {}); - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, - catalog: { - deepseek: { - id: 'deepseek', - name: 'Example provider', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.example.test', - env: ['DEEPSEEK_API_KEY'], - models: { - chat: { id: 'example-chat', limit: { context: 128_000 } }, - }, + const createSession = vi + .fn() + .mockRejectedValueOnce(loginRequiredError()) + .mockResolvedValueOnce(session); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: false }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, }, - }, + })), + createSession, }); - vi.mocked(promptApiKey).mockClear(); - vi.mocked(promptApiKey).mockResolvedValue('typed-in-secret'); - vi.mocked(promptModelSelectionForCatalog).mockImplementation( - async (_host, _providerId, models) => ({ model: models[0]!, effort: 'off' }), - ); + const driver = makeDriver(harness, makeStartupInput()); - try { - vi.stubEnv('DEEPSEEK_API_KEY', ''); - await handleLoginCommand(driver as any); + await expect(driver.init()).resolves.toBe(false); + await driver.authFlow.refreshConfigAfterLogin(); - expect(showError).not.toHaveBeenCalled(); - expect(promptApiKey).toHaveBeenCalledTimes(1); - const configPatch = setConfig.mock.calls[0]?.[0] as { - providers: Record<string, { apiKey?: string; apiKeyEnvVar?: string }>; - }; - expect(configPatch.providers['deepseek']?.apiKey).toBe('typed-in-secret'); - expect(configPatch.providers['deepseek']?.apiKeyEnvVar).toBeUndefined(); - expect(harness.track).toHaveBeenCalledWith('login', { - provider: 'deepseek', - method: 'api_key', - }); - } finally { - vi.unstubAllEnvs(); - } + expect(createSession).toHaveBeenNthCalledWith(2, { + workDir: '/tmp/proj-a', + model: 'k2', + thinking: 'off', + permission: undefined, + planMode: undefined, + }); + expect(driver.state.appState).toMatchObject({ + permissionMode: 'auto', + }); }); - it('aborts catalog provider login when the API key prompt is cancelled', async () => { - const setConfig = vi.fn(async (patch: unknown) => patch); - const harness = makeHarness(makeSession(), { - getConfig: vi.fn(async () => ({ providers: {}, models: {} })), - removeProvider: vi.fn(), - setConfig, + it('does not override active session thinking when configured thinking is enabled after OAuth login', async () => { + const session = makeSession(); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + defaultModel: 'k2', + thinking: { enabled: true }, + models: { + k2: { model: 'moonshot-v1', maxContextSize: 100 }, + }, + })), }); const driver = makeDriver(harness, makeStartupInput()); - vi.mocked(promptPlatformSelection).mockResolvedValue({ - platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, - catalog: { - deepseek: { - id: 'deepseek', - name: 'Example provider', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.example.test', - env: ['DEEPSEEK_API_KEY'], - models: { - chat: { id: 'example-chat', limit: { context: 128_000 } }, - }, + + await expect(driver.init()).resolves.toBe(false); + expect(driver.state.appState.thinkingEffort).toBe('off'); + + await driver.authFlow.refreshConfigAfterLogin(); + + expect(session.setModel).toHaveBeenCalledWith('k2'); + // `thinking.enabled === true` means "leave the session's current thinking + // level alone" — only an explicit `enabled === false` forces `'off'`. + expect(session.setThinking).not.toHaveBeenCalled(); + expect(driver.state.appState).toMatchObject({ + model: 'k2', + thinkingEffort: 'off', + maxContextTokens: 100, + }); + }); + + it('tracks logout after managed credentials and session state are cleared', async () => { + const session = makeSession(); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { provider: 'managed:pythinker-code', model: 'moonshot-v1', maxContextSize: 100 }, }, + providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + })), + auth: { + status: vi.fn(async () => ({ + providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], + })), + login: vi.fn(async () => {}), + logout: vi.fn(), + getManagedUsage: vi.fn(), }, }); - vi.mocked(promptApiKey).mockClear(); - vi.mocked(promptApiKey).mockResolvedValue(undefined); - vi.mocked(promptModelSelectionForCatalog).mockClear(); + const driver = makeDriver(harness, makeStartupInput()); - try { - vi.stubEnv('DEEPSEEK_API_KEY', undefined); - await handleLoginCommand(driver as any); + await expect(driver.init()).resolves.toBe(false); + harness.track.mockClear(); - expect(promptApiKey).toHaveBeenCalledTimes(1); - expect(promptModelSelectionForCatalog).not.toHaveBeenCalled(); - expect(setConfig).not.toHaveBeenCalled(); - } finally { - vi.unstubAllEnvs(); - } - }); + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + await handleLogoutCommand(driver as any); + expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + expect(session.close).toHaveBeenCalledOnce(); + expect(driver.state.appState).toMatchObject({ + sessionId: '', + model: '', + sessionTitle: null, + }); + expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'managed:pythinker-code' }); + }); it('keeps the active session when logging out a different provider', async () => { const session = makeSession(); @@ -1663,17 +1861,17 @@ describe('PythinkerTUI startup', () => { const harness = makeHarness(session, { getConfig: vi.fn(async () => ({ models: { - k2: { provider: 'managed:kimi-code', model: 'pythoughts-v1', maxContextSize: 100 }, + k2: { provider: 'managed:pythinker-code', model: 'moonshot-v1', maxContextSize: 100 }, }, providers: { - 'managed:kimi-code': { type: 'pythinker' }, + 'managed:pythinker-code': { type: 'pythinker' }, openai: { type: 'openai', baseUrl: 'https://api.openai.com/v1' }, }, })), removeProvider, auth: { status: vi.fn(async () => ({ - providers: [{ providerName: 'managed:kimi-code', hasToken: true }], + providers: [{ providerName: 'managed:pythinker-code', hasToken: true }], })), login: vi.fn(async () => {}), logout: vi.fn(), @@ -1698,6 +1896,35 @@ describe('PythinkerTUI startup', () => { expect(harness.track).toHaveBeenCalledWith('logout', { provider: 'openai' }); }); + it('can log out a stale managed entry even after the OAuth token is gone', async () => { + const session = makeSession(); + const harness = makeHarness(session, { + getConfig: vi.fn(async () => ({ + models: { + k2: { provider: 'managed:pythinker-code', model: 'moonshot-v1', maxContextSize: 100 }, + }, + providers: { 'managed:pythinker-code': { type: 'pythinker' } }, + })), + auth: { + // Token gone (e.g. credentials file deleted) but the managed entry + // is still sitting in config.providers. + status: vi.fn(async () => ({ + providers: [{ providerName: 'managed:pythinker-code', hasToken: false }], + })), + login: vi.fn(async () => {}), + logout: vi.fn(), + getManagedUsage: vi.fn(), + }, + }); + const driver = makeDriver(harness, makeStartupInput()); + + await expect(driver.init()).resolves.toBe(false); + + vi.mocked(promptLogoutProviderSelection).mockResolvedValue('managed:pythinker-code'); + await handleLogoutCommand(driver as any); + + expect(harness.auth.logout).toHaveBeenCalledWith('managed:pythinker-code'); + }); it('starts TUI without replaying when --continue needs OAuth login', async () => { const harness = makeHarness(makeSession(), { @@ -1712,7 +1939,7 @@ describe('PythinkerTUI startup', () => { expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-latest', - replayTurnLimit: REPLAY_TURN_LIMIT, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); expect(harness.createSession).not.toHaveBeenCalled(); expect(driver.state.startupState).toBe('ready'); @@ -1732,12 +1959,133 @@ describe('PythinkerTUI startup', () => { expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target', - replayTurnLimit: REPLAY_TURN_LIMIT, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); expect(driver.state.startupState).toBe('ready'); expect(driver.state.appState.sessionId).toBe(''); }); + it('disposes terminal focus/theme tracking on the pythinker migrate exit', async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + }) as unknown as MigrateExitDriver; + // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + // The migration screen would await user input; resolve it immediately. + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + await driver.start(); + + // `pythinker migrate` exits via process.exit; startEventLoop() installed focus + // tracking, so the exit path must dispose it — otherwise the terminal + // keeps emitting focus/OSC sequences after the command finishes. + expect(driver.terminalFocusTrackingDispose).toBeUndefined(); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('disposes terminal tracking when post-migration startup fails', async () => { + const harness = makeHarness(); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: false, + }) as unknown as MigrateExitDriver; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + // The migration screen resolves "later"; startup then continues into + // initMainTui(), which fails (e.g. a session-resume error). + vi.spyOn(driver, 'runMigrationScreen').mockResolvedValue({ decision: 'later' }); + vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); + + await expect(driver.start()).rejects.toThrow('resume boom'); + + // The focus tracking installed by startEventLoop() must be torn down + // before the error propagates — not left active after the process exits. + expect(driver.terminalFocusTrackingDispose).toBeUndefined(); + }); + + it('checks workspace trust before entering the migration screen', async () => { + // The migration branch used to skip the trust gate entirely: a workspace + // with legacy ~/.pythinker data went straight to the migration screen, and + // later startup steps spawned child processes in an untrusted directory. + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: true, + gatedMcpServers: [], + })); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + await driver.start(); + + expect(getWorkspaceTrustInfo).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('prompts for workspace trust before migrating an untrusted workspace', async () => { + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: false, + gatedMcpServers: [], + })); + const trustWorkspace = vi.fn(async () => {}); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver & { + mountEditorReplacement(panel: { handleInput(data: string): void }): void; + }; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const mountSpy = vi.spyOn(driver, 'mountEditorReplacement'); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + const startPromise = driver.start(); + await vi.waitFor(() => { + expect(mountSpy).toHaveBeenCalled(); + }); + // Move from the safe default to the explicit trust choice, then confirm. + mountSpy.mock.calls[0]![0].handleInput('\u001B[A'); + mountSpy.mock.calls[0]![0].handleInput('\r'); + await startPromise; + + expect(trustWorkspace).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { @@ -1760,7 +2108,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'missing-session' }), - ) as unknown as InitMainTuiDriver; + ) as unknown as MigrateExitDriver; await expect(driver.initMainTui()).rejects.toThrow('Session "missing-session" not found.'); expect(uiContainsFooter(driver)).toBe(false); @@ -1774,7 +2122,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as InitMainTuiDriver; + ) as unknown as MigrateExitDriver; // Not mounted until init() succeeds. expect(uiContainsFooter(driver)).toBe(false); @@ -1800,7 +2148,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as InitMainTuiDriver; + ) as unknown as MigrateExitDriver; await driver.initMainTui(); @@ -1845,7 +2193,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as InitMainTuiDriver; + ) as unknown as MigrateExitDriver; await driver.initMainTui(); @@ -1855,6 +2203,16 @@ describe('PythinkerTUI startup', () => { ).toBe(true); }); + // writeBannerDisplayState runs after renderBanner; on Windows the atomic + // write can lag behind the render, so wait for the state to land before + // asserting it. + await vi.waitFor( + async () => { + const state = await readBannerDisplayState(); + expect(state.shown['once-banner']?.lastShownAt).toBeDefined(); + }, + { timeout: 5000 }, + ); await expect(readBannerDisplayState()).resolves.toMatchObject({ version: 1, shown: { @@ -1892,7 +2250,7 @@ describe('PythinkerTUI startup', () => { const driver = makeDriver( harness, makeStartupInput({ session: 'ses-target' }), - ) as unknown as InitMainTuiDriver; + ) as unknown as MigrateExitDriver; await driver.initMainTui(); @@ -1932,7 +2290,7 @@ describe('PythinkerTUI startup', () => { }); expect(harness.resumeSession).toHaveBeenCalledWith({ id: 'ses-target', - replayTurnLimit: REPLAY_TURN_LIMIT, + replayTurnLimit: REPLAY_FETCH_TURN_LIMIT, }); expect(driver.state.appState.sessionId).toBe('ses-target'); }); @@ -1947,127 +2305,3 @@ function uiContainsFooter(driver: StartupDriver): boolean { }; return visit(driver.state.ui); } - -describe('startup feature parity baseline', () => { - it('links startup behavior to active parity scenarios', () => { - const linked = PARITY_CASES.filter( - ({ legacyTest }) => legacyTest === LEGACY_TEST_PATHS.startup, - ); - expect(linked.length).toBeGreaterThan(0); - expect( - linked.every(({ status, scenarioId }) => status === 'active' && scenarioId.length > 0), - ).toBe(true); - }); -}); - -describe('footer update status poll', () => { - /** - * The poll is the only thing that puts an update into the footer, and it is - * wired from `finishStartup` — so nothing else in this suite would notice if - * it stopped dispatching. Drive it against real state files. - */ - it('dispatches availability and then live progress into the status row', async () => { - const home = mkdtempSync(join(tmpdir(), 'pk-footer-update-')); - vi.stubEnv('PYTHINKER_CODE_HOME', home); - const updates = join(home, 'updates'); - mkdirSync(updates, { recursive: true }); - const manifest = { - version: '9.9.9', - publishedAt: '2026-08-07T00:00:00.000Z', - rollout: [], - }; - writeFileSync( - join(updates, 'latest.json'), - JSON.stringify({ - source: 'cdn', - checkedAt: '2026-08-07T00:00:00.000Z', - latest: '9.9.9', - manifest, - }), - ); - - const presentation = new RecordingPresentation(); - const driver = new PythinkerTUI( - makeHarness() as never, - makeStartupInput(), - presentation, - ) as unknown as UpdatePollDriver; - - try { - driver.startUpdateStatusPolling(); - await vi.waitFor( - () => { - expect(footerStatusItems(presentation.footerModels.at(-1))).toContain('↑ v9.9.9'); - }, - { timeout: 10_000, interval: 50 }, - ); - - writeFileSync( - join(updates, 'install.json'), - JSON.stringify({ - active: { - version: '9.9.9', - source: 'native', - startedAt: new Date().toISOString(), - pid: process.pid, - progress: { - state: 'downloading', - percent: 42, - transferred: 5_320_000, - total: 12_600_000, - updatedAt: new Date().toISOString(), - }, - }, - pending: null, - lastFailure: null, - lastSuccess: null, - }), - ); - await vi.waitFor( - () => { - expect(footerStatusItems(presentation.footerModels.at(-1))).toContain( - '↓ v9.9.9 ▰▰▰▱▱▱▱▱ 42%', - ); - }, - { timeout: 10_000, interval: 50 }, - ); - } finally { - driver.stopUpdateStatusPolling(); - driver.state.footer.dispose(); - vi.unstubAllEnvs(); - rmSync(home, { recursive: true, force: true }); - } - }, 30_000); - - it('disposes terminal tracking when startup fails', async () => { - const harness = makeHarness(); - const driver = makeDriver(harness, makeStartupInput()) as unknown as StartFailureDriver; - // pi-tui start/stop and focus tracking touch the real TTY — stub the I/O. - vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); - vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); - vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); - vi.spyOn(driver, 'initMainTui').mockRejectedValue(new Error('resume boom')); - - // startEventLoop() only installs focus tracking once the terminal reports a - // width, so give it one — otherwise there is nothing to dispose and the - // assertion below would hold no matter what the teardown does. - const stdoutColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns'); - Object.defineProperty(process.stdout, 'columns', { configurable: true, value: 80 }); - try { - driver.startEventLoop(); - expect(driver.terminalFocusTrackingDispose).toBeDefined(); - - await expect(driver.start()).rejects.toThrow('resume boom'); - - // The focus tracking installed by startEventLoop() must be torn down before - // the error propagates — not left active after the process exits. - expect(driver.terminalFocusTrackingDispose).toBeUndefined(); - } finally { - if (stdoutColumns === undefined) { - Reflect.deleteProperty(process.stdout, 'columns'); - } else { - Object.defineProperty(process.stdout, 'columns', stdoutColumns); - } - } - }); -}); diff --git a/apps/pythinker-code/test/tui/render-memo.bench.ts b/apps/pythinker-code/test/tui/render-memo.bench.ts new file mode 100644 index 00000000..8141b126 --- /dev/null +++ b/apps/pythinker-code/test/tui/render-memo.bench.ts @@ -0,0 +1,115 @@ +/** + * Benchmark for the message-component render cache (Phase 1 + 1.5). + * + * Measures the cost of re-rendering a long transcript when *nothing* has + * changed — the common steady-state frame. With the render cache enabled + * ("cached (warm)") every message returns its previously computed lines, and + * the GutterContainer returns its cached concatenation, so the cost is roughly + * O(number of messages). With it disabled ("uncached") every message rebuilds + * its output (Markdown, Text, truncation) and the container rebuilds the full + * line array, which is O(total rendered lines) and dominates CPU as the + * transcript grows. + * + * Run: + * pnpm --filter @pymodel/pythinker-code exec vitest bench test/tui/render-memo.bench.ts + */ + +import { bench, describe } from 'vitest'; + +import type { Component } from '@pymodel/pi-tui'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { ThinkingComponent } from '#/tui/components/messages/thinking'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { setRenderCacheEnabled } from '#/tui/utils/render-cache'; + +const WIDTH = 100; +const TRANSCRIPT_TURNS = 200; +const GUTTER = 2; + +const USER_TEXT = + 'Can you refactor the streaming renderer so that finalized assistant messages stop being re-rendered on every frame? Please keep the diff minimal and avoid touching the engine.'; + +const ASSISTANT_TEXT = [ + 'Here is a summary of the change:', + '', + '- cache the rendered lines per message component', + '- invalidate the cache when content, theme, or width changes', + '- keep the diff renderer untouched', + '', + '```ts', + 'render(width: number): string[] {', + ' if (this.cache && this.cache.width === width) return this.cache.lines;', + ' const lines = this.compute(width);', + ' this.cache = { width, lines };', + ' return lines;', + '}', + '```', + '', + 'This keeps the steady-state frame cheap while preserving correctness.', +].join('\n'); + +const THINKING_TEXT = [ + 'Let me reason through the invalidation paths carefully.', + 'The cache must be cleared on content changes, theme switches, and width changes.', + 'Width changes already trigger a full repaint, so they fall out naturally.', + 'Theme switches flow through invalidate(), so that is the hook to clear the cache.', + 'Streaming updates go through updateContent/setText, which already short-circuit when unchanged.', +].join('\n'); + +function buildMessages(turns: number): Component[] { + const components: Component[] = []; + for (let i = 0; i < turns; i++) { + components.push(new UserMessageComponent(`[${i}] ${USER_TEXT}`)); + + const assistant = new AssistantMessageComponent(); + assistant.updateContent(`[${i}] ${ASSISTANT_TEXT}`); + components.push(assistant); + + components.push(new ThinkingComponent(`[${i}] ${THINKING_TEXT}`, true, 'finalized')); + } + return components; +} + +function buildGutter(turns: number): GutterContainer { + const gutter = new GutterContainer(GUTTER, GUTTER); + for (const message of buildMessages(turns)) gutter.addChild(message); + return gutter; +} + +describe('render memo — flat child render', () => { + const messages = buildMessages(TRANSCRIPT_TURNS); + + // Warm up: populate every component's cache so the "cached" case measures + // steady-state cache hits rather than first-render cost. + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + for (const message of messages) message.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + for (const message of messages) message.render(WIDTH); + }); +}); + +describe('render memo — via GutterContainer', () => { + const gutter = buildGutter(TRANSCRIPT_TURNS); + + setRenderCacheEnabled(true); + gutter.render(WIDTH); + + bench('cached (warm)', () => { + setRenderCacheEnabled(true); + gutter.render(WIDTH); + }); + + bench('uncached', () => { + setRenderCacheEnabled(false); + gutter.render(WIDTH); + }); +}); diff --git a/apps/pythinker-code/test/tui/reverse-rpc/approval-adapter.test.ts b/apps/pythinker-code/test/tui/reverse-rpc/approval-adapter.test.ts index 7ef5e992..ccd08fa6 100644 --- a/apps/pythinker-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/pythinker-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -42,48 +42,6 @@ describe('approval adapter', () => { ]); }); - it.each([ - ['git reset --hard HEAD~1', 'discard uncommitted changes'], - ['terraform destroy -auto-approve', 'destroy infrastructure'], - ['kubectl delete namespace production', 'delete Kubernetes resources'], - ])('labels destructive command %s', (command, danger) => { - const adapted = adaptApprovalRequest({ - toolCallId: 'tc-danger', - toolName: 'Bash', - action: 'run', - display: { - kind: 'command', - command, - language: 'bash', - }, - }); - - expect(adapted.display).toEqual([ - expect.objectContaining({ type: 'shell', command, danger }), - ]); - }); - - it('preserves PowerShell language metadata for the native approval panel', () => { - const adapted = adaptApprovalRequest({ - toolCallId: 'tc-powershell', - toolName: 'PowerShell', - action: 'run', - display: { - kind: 'command', - command: 'Get-Location', - language: 'powershell', - }, - }); - - expect(adapted.display).toEqual([ - expect.objectContaining({ - type: 'shell', - command: 'Get-Location', - language: 'powershell', - }), - ]); - }); - it('emits only a diff block for Edit — no separate file_op title row', () => { const adapted = adaptApprovalRequest( { @@ -253,60 +211,94 @@ describe('approval adapter', () => { ]); }); - // A DynamicWorkflow approval is the one place the fan-out can still be - // refused, so the plan has to survive the trip into the panel rather than - // being flattened into the "N subagents" label. - it('carries a Dynamic Workflow plan through as its own display block', () => { + it('renders the /goal start menu for a CreateGoal approval in manual mode', () => { const adapted = adaptApprovalRequest({ - toolCallId: 'tc-workflow', - toolName: 'DynamicWorkflow', - action: 'run', + toolCallId: 'tc-goal', + toolName: 'CreateGoal', + action: 'Creating a goal', display: { - kind: 'agent_call', - agent_name: 'Dynamic Workflow (3 subagents)', - prompt: 'Review the diff', - workflow: { - agent_count: 3, - items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], - prompt_tokens: 42, - prompt_template: 'Review {{item}}', - model: 'claude-sonnet-4', - }, + kind: 'goal_start', + objective: 'Fix the failing auth tests', + completionCriterion: 'npm test -- auth exits 0', + mode: 'manual', }, }); + // Objective + criterion are previewed as a brief block. expect(adapted.display).toEqual([ { - type: 'invocation', - kind: 'agent', - name: 'Dynamic Workflow (3 subagents)', - description: 'Review the diff', + type: 'brief', + text: 'Start goal: Fix the failing auth tests\nDone when: npm test -- auth exits 0', + }, + ]); + // Choices mirror the manual-mode /goal start menu; mode options approve and + // carry the mode in selected_label, "Do not start" cancels. Each keeps the + // /goal menu's description. + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Pythinker Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Switch to YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes are approved automatically. Pythinker Code may still ask you questions.', + }, + { + label: 'Start in Manual', + response: 'approved', + selected_label: 'manual', + description: + 'Keep approvals on. Pythinker Code will ask before risky actions, so the goal may stop and wait for you.', }, { - type: 'workflow_plan', - agent_count: 3, - items: ['src/a.ts', 'src/b.ts', 'src/c.ts'], - prompt_tokens: 42, - prompt_template: 'Review {{item}}', - model: 'claude-sonnet-4', + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', }, ]); }); - it('adds no plan block to a plain agent call', () => { + it('renders the yolo-mode /goal start menu for a CreateGoal approval', () => { const adapted = adaptApprovalRequest({ - toolCallId: 'tc-agent', - toolName: 'Agent', - action: 'run', + toolCallId: 'tc-goal-yolo', + toolName: 'CreateGoal', + action: 'Creating a goal', display: { - kind: 'agent_call', - agent_name: 'coder', - prompt: 'Fix the build', + kind: 'goal_start', + objective: 'Ship the feature', + mode: 'yolo', }, }); - expect(adapted.display).toEqual([ - { type: 'invocation', kind: 'agent', name: 'coder', description: 'Fix the build' }, + expect(adapted.display).toEqual([{ type: 'brief', text: 'Start goal: Ship the feature' }]); + expect(adapted.choices).toEqual([ + { + label: 'Switch to Auto and start', + response: 'approved', + selected_label: 'auto', + description: + 'Best if you want Pythinker Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + label: 'Keep YOLO and start', + response: 'approved', + selected_label: 'yolo', + description: + 'Tools and plan changes stay approved automatically. Pythinker Code may still ask you questions.', + }, + { + label: 'Do not start', + response: 'cancelled', + selected_label: 'cancel', + description: 'Return to the input box with your goal command.', + }, ]); }); diff --git a/apps/pythinker-code/test/tui/reverse-rpc/question.test.ts b/apps/pythinker-code/test/tui/reverse-rpc/question.test.ts index 23928915..e2ab3d11 100644 --- a/apps/pythinker-code/test/tui/reverse-rpc/question.test.ts +++ b/apps/pythinker-code/test/tui/reverse-rpc/question.test.ts @@ -35,13 +35,7 @@ describe('question reverse-rpc', () => { const controller = new QuestionController(); const show = vi .spyOn(controller, 'show') - .mockResolvedValue({ - answers: ['Alpha'], - method: 'number_key', - annotations: { - 'Q1?': { preview: 'Alpha preview', notes: 'Use the first option.' }, - }, - }); + .mockResolvedValue({ answers: ['Alpha'], method: 'number_key' }); const handler = createQuestionAskHandler(controller); const event = questionEvent({ questions: [ @@ -50,16 +44,9 @@ describe('question reverse-rpc', () => { header: 'Pick', body: 'Choose one', multiSelect: true, - allowOther: false, otherLabel: 'Other', otherDescription: 'Type a custom answer', - options: [ - { - label: 'Alpha', - description: 'First option', - preview: 'Alpha preview', - }, - ], + options: [{ label: 'Alpha', description: 'First option' }], }, ], }); @@ -67,9 +54,6 @@ describe('question reverse-rpc', () => { await expect(handler(event)).resolves.toEqual({ answers: { 'Q1?': 'Alpha' }, method: 'number_key', - annotations: { - 'Q1?': { preview: 'Alpha preview', notes: 'Use the first option.' }, - }, }); expect(show).toHaveBeenCalledWith({ id: 'q-1', @@ -80,16 +64,9 @@ describe('question reverse-rpc', () => { header: 'Pick', body: 'Choose one', multi_select: true, - allow_other: false, other_label: 'Other', other_description: 'Type a custom answer', - options: [ - { - label: 'Alpha', - description: 'First option', - preview: 'Alpha preview', - }, - ], + options: [{ label: 'Alpha', description: 'First option' }], }, ], }); @@ -154,48 +131,4 @@ describe('question reverse-rpc', () => { ], }); }); - - it('preserves and opens the URL attached to a selected question option', async () => { - const controller = new QuestionController(); - const show = vi - .spyOn(controller, 'show') - .mockResolvedValue({ answers: ['Open URL'], method: 'enter' }); - const openUrl = vi.fn(); - const handler = createQuestionAskHandler(controller, openUrl); - const event = questionEvent({ - questions: [ - { - question: 'Open account?', - options: [ - { - label: 'Open URL', - description: 'example.test', - url: 'https://example.test/account', - }, - { label: 'Decline' }, - ], - }, - ], - }); - - await expect(handler(event)).resolves.toMatchObject({ - answers: { 'Open account?': 'Open URL' }, - }); - expect(show).toHaveBeenCalledWith( - expect.objectContaining({ - questions: [ - expect.objectContaining({ - options: [ - expect.objectContaining({ - label: 'Open URL', - url: 'https://example.test/account', - }), - expect.objectContaining({ label: 'Decline' }), - ], - }), - ], - }), - ); - expect(openUrl).toHaveBeenCalledWith('https://example.test/account'); - }); }); diff --git a/apps/pythinker-code/test/tui/runtime/composer-state.test.ts b/apps/pythinker-code/test/tui/runtime/composer-state.test.ts deleted file mode 100644 index 8024fa76..00000000 --- a/apps/pythinker-code/test/tui/runtime/composer-state.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - capturePaste, - clearComposer, - createComposerState, - deleteBackwardGrapheme, - deleteBackwardWord, - deleteForwardGrapheme, - deleteForwardWord, - detectComposerPrefix, - expandPasteMarkerAtCursor, - getComposerText, - historyDown, - historyUp, - insertNewline, - insertText, - moveCursorDown, - moveCursorLeft, - moveCursorLineEnd, - moveCursorLineStart, - moveCursorRight, - moveCursorTextEnd, - moveCursorTextStart, - moveCursorUp, - moveCursorWordLeft, - moveCursorWordRight, -} from '#/tui/runtime/footer/composer-state'; - -describe('composer state', () => { - it('creates state and inserts single-line and multiline text', () => { - const initial = createComposerState('ac'); - const positioned = moveCursorLeft(initial); - const inserted = insertText(positioned, 'b'); - const multiline = insertText(inserted, '\none'); - - expect(getComposerText(initial)).toBe('ac'); - expect(getComposerText(inserted)).toBe('abc'); - expect(multiline.lines).toEqual(['ab', 'onec']); - expect(multiline.cursorLine).toBe(1); - expect(multiline.cursorCol).toBe(3); - }); - - it('inserts a newline at the cursor', () => { - const state = moveCursorLeft(createComposerState('ab')); - const result = insertNewline(state); - - expect(result.lines).toEqual(['a', 'b']); - expect(result.cursorLine).toBe(1); - expect(result.cursorCol).toBe(0); - }); - - it('deletes emoji and combining sequences as single graphemes', () => { - const emoji = createComposerState('a👨‍👩‍👧‍👦'); - const withoutEmoji = deleteBackwardGrapheme(emoji); - const combining = moveCursorTextStart(createComposerState('e\u0301x')); - const withoutCombining = deleteForwardGrapheme(combining); - - expect(getComposerText(withoutEmoji)).toBe('a'); - expect(withoutEmoji.cursorCol).toBe(1); - expect(getComposerText(withoutCombining)).toBe('x'); - }); - - it('joins lines when deleting graphemes at line boundaries', () => { - const atSecondStart = moveCursorLineStart(createComposerState('one\ntwo')); - const backward = deleteBackwardGrapheme(atSecondStart); - const atFirstEnd = moveCursorLineEnd( - moveCursorTextStart(createComposerState('one\ntwo')), - ); - const forward = deleteForwardGrapheme(atFirstEnd); - - expect(getComposerText(backward)).toBe('onetwo'); - expect(backward.cursorCol).toBe(3); - expect(getComposerText(forward)).toBe('onetwo'); - expect(forward.cursorCol).toBe(3); - }); - - it('deletes whitespace and non-whitespace word runs', () => { - const backward = deleteBackwardWord(createComposerState('one two')); - const backwardWhitespace = deleteBackwardWord(backward); - const atStart = moveCursorTextStart(createComposerState('one two')); - const forward = deleteForwardWord(atStart); - const forwardWhitespace = deleteForwardWord(forward); - - expect(getComposerText(backward)).toBe('one '); - expect(getComposerText(backwardWhitespace)).toBe('one'); - expect(getComposerText(forward)).toBe(' two'); - expect(getComposerText(forwardWhitespace)).toBe('two'); - }); - - it('moves by grapheme, word, line, and full text boundaries', () => { - const state = createComposerState('one two\nx'); - const textStart = moveCursorTextStart(state); - const wordRight = moveCursorWordRight(textStart); - const whitespaceRight = moveCursorWordRight(wordRight); - const wordLeft = moveCursorWordLeft(whitespaceRight); - const lineEnd = moveCursorLineEnd(textStart); - const nextLine = moveCursorRight(lineEnd); - const previousLine = moveCursorLeft(nextLine); - const textEnd = moveCursorTextEnd(textStart); - - expect(wordRight.cursorCol).toBe(3); - expect(whitespaceRight.cursorCol).toBe(5); - expect(wordLeft.cursorCol).toBe(3); - expect(nextLine).toMatchObject({ cursorLine: 1, cursorCol: 0 }); - expect(previousLine).toMatchObject({ cursorLine: 0, cursorCol: 8 }); - expect(textEnd).toMatchObject({ cursorLine: 1, cursorCol: 1 }); - expect(moveCursorLineStart(textEnd).cursorCol).toBe(0); - }); - - it('clamps the cursor column when moving up and down', () => { - const fromBottom = createComposerState('ab\n12345'); - const up = moveCursorUp(fromBottom); - const fromTop = moveCursorTextStart(createComposerState('12345\nab')); - const atTopEnd = moveCursorLineEnd(fromTop); - const down = moveCursorDown(atTopEnd); - - expect(up).toMatchObject({ cursorLine: 0, cursorCol: 2 }); - expect(down).toMatchObject({ cursorLine: 1, cursorCol: 2 }); - }); - - it('clears text while preserving captured paste state', () => { - const captured = capturePaste(createComposerState(), 'x'.repeat(1001)); - const cleared = clearComposer(captured); - - expect(cleared.lines).toEqual(['']); - expect(cleared.cursorLine).toBe(0); - expect(cleared.cursorCol).toBe(0); - expect(cleared.pastes).toBe(captured.pastes); - expect(cleared.pasteCounter).toBe(1); - }); - - it('navigates history and exits past the newest entry', () => { - const history = ['first', 'second']; - const initial = createComposerState(); - const latest = historyUp(initial, history, null); - const older = historyUp(latest.state, history, latest.historyIndex); - const newer = historyDown(older.state, history, older.historyIndex); - const exited = historyDown(newer.state, history, newer.historyIndex); - - expect(getComposerText(latest.state)).toBe('second'); - expect(latest.historyIndex).toBe(1); - expect(getComposerText(older.state)).toBe('first'); - expect(older.historyIndex).toBe(0); - expect(getComposerText(newer.state)).toBe('second'); - expect(newer.historyIndex).toBe(1); - expect(getComposerText(exited.state)).toBe(''); - expect(exited.historyIndex).toBeNull(); - }); - - it('only navigates history from the required first or last line', () => { - const history = ['entry']; - const onLastLine = createComposerState('one\ntwo'); - const blockedUp = historyUp(onLastLine, history, null); - const onFirstLine = moveCursorTextStart(onLastLine); - const blockedDown = historyDown(onFirstLine, history, 0); - - expect(blockedUp.state).toBe(onLastLine); - expect(blockedUp.historyIndex).toBeNull(); - expect(blockedDown.state).toBe(onFirstLine); - expect(blockedDown.historyIndex).toBe(0); - }); - - it('captures large pastes using both legacy marker formats', () => { - const manyLines = Array.from({ length: 11 }, (_, index) => - String(index), - ).join('\n'); - const lineMarker = capturePaste(createComposerState(), manyLines); - const longText = 'x'.repeat(1001); - const charMarker = capturePaste(lineMarker, longText); - - expect(getComposerText(lineMarker)).toBe('[paste #1 +11 lines]'); - expect(getComposerText(charMarker)).toBe( - '[paste #1 +11 lines][paste #2 1001 chars]', - ); - expect(charMarker.pastes.get(1)).toBe(manyLines); - expect(charMarker.pastes.get(2)).toBe(longText); - }); - - it('inserts small pastes verbatim', () => { - const pasted = capturePaste(createComposerState('a'), 'b\nc'); - - expect(getComposerText(pasted)).toBe('ab\nc'); - expect(pasted.pasteCounter).toBe(0); - expect(pasted.pastes.size).toBe(0); - }); - - it('expands the marker under the cursor and preserves other pastes', () => { - const firstText = 'a'.repeat(1001); - const secondText = 'b'.repeat(1001); - const withFirst = capturePaste(createComposerState(), firstText); - const withBoth = capturePaste(withFirst, secondText); - const atFirstMarker = moveCursorTextStart(withBoth); - const expanded = expandPasteMarkerAtCursor(atFirstMarker); - - expect(expanded.expanded).toBe(true); - expect(getComposerText(expanded.state)).toBe( - `${firstText}[paste #2 1001 chars]`, - ); - expect(expanded.state.pastes.has(1)).toBe(false); - expect(expanded.state.pastes.get(2)).toBe(secondText); - }); - - it('does not expand a marker when the cursor is outside it', () => { - const captured = capturePaste( - createComposerState('prefix '), - 'x'.repeat(1001), - ); - const outside = moveCursorTextStart(captured); - const result = expandPasteMarkerAtCursor(outside); - - expect(result.expanded).toBe(false); - expect(result.state).toBe(outside); - }); - - it('detects slash and mention prefixes under the cursor', () => { - const slash = detectComposerPrefix(createComposerState(' /help')); - const mention = detectComposerPrefix(createComposerState('hello @ali')); - const embeddedMention = detectComposerPrefix( - createComposerState('hello x@ali'), - ); - - expect(slash).toEqual({ kind: 'slash', query: 'help', start: 2 }); - expect(mention).toEqual({ kind: 'mention', query: 'ali', start: 6 }); - expect(embeddedMention).toBeNull(); - }); - - it('only detects slash prefixes on the first line', () => { - const secondLineSlash = detectComposerPrefix( - createComposerState('first\n/help'), - ); - const nonLeadingSlash = detectComposerPrefix( - createComposerState('text /help'), - ); - - expect(secondLineSlash).toBeNull(); - expect(nonLeadingSlash).toBeNull(); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/dialog-list-view.test.tsx b/apps/pythinker-code/test/tui/runtime/dialog-list-view.test.tsx deleted file mode 100644 index 5c6b7d4b..00000000 --- a/apps/pythinker-code/test/tui/runtime/dialog-list-view.test.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import type { BaseRenderable, KeyEvent } from '@opentui/core'; -import chalk from 'chalk'; -import { describe, expect, it, vi } from 'vitest'; - -import { KeybindingResolver, parseKeybindingBlocks } from '#/tui/keybindings'; -import type { DialogViewModel } from '../../../src/tui/presentation/dialog-list-model'; -import { renderDialogListRows } from '../../../src/tui/runtime/dialogs/dialog-list-rows'; - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); -} - -function withAnsiColors<T>(fn: () => T): T { - const previousChalkLevel = chalk.level; - chalk.level = 3; - try { - return fn(); - } finally { - chalk.level = previousChalkLevel; - } -} - -function descendants(root: BaseRenderable): readonly BaseRenderable[] { - return root.getChildren().flatMap((child) => [child, ...descendants(child)]); -} - -function baseViewModel(overrides: Partial<DialogViewModel> = {}): DialogViewModel { - return { - title: 'Pick a theme', - rows: [ - { id: 'a', label: 'Auto' }, - { id: 'b', label: 'Dark', current: true }, - { id: 'c', label: 'Light', disabled: true }, - ], - selectedIndex: 1, - ...overrides, - }; -} - -describe('renderDialogListRows', () => { - it('styles selected, current, and disabled row semantics', () => { - const rows = withAnsiColors(() => renderDialogListRows(baseViewModel(), 40)); - const border = '─'.repeat(40); - - expect(rows.join('\n')).toContain('\u001B'); - expect(rows[5] ?? '').toContain('\u001B[1m'); - expect(rows[4] ?? '').not.toContain('\u001B[1m'); - expect(rows.map(stripAnsi)).toEqual([ - border, - 'Pick a theme (type to search)', - '↑↓ navigate · Enter select · Esc cancel', - '', - ' Auto', - '❯ Dark ← current', - ' Light (disabled)', - border, - ]); - }); - - it('renders a non-empty query as a separate search row', () => { - const border = '─'.repeat(80); - expect(renderDialogListRows(baseViewModel({ query: 'da' }), 80).map(stripAnsi)).toEqual([ - border, - 'Pick a theme', - '↑↓ navigate · Enter select · Esc cancel · Backspace clear', - '', - 'Search: da', - ' Auto', - '❯ Dark ← current', - ' Light (disabled)', - border, - ]); - }); - - it('renders the empty state inside the standard dialog chrome', () => { - const border = '─'.repeat(40); - expect( - renderDialogListRows( - { title: 'Pick a theme', rows: [], selectedIndex: 0, hint: 'No matches' }, - 40, - ).map(stripAnsi), - ).toEqual([ - border, - 'Pick a theme (type to search)', - '↑↓ navigate · Enter select · Esc cancel', - '', - 'No matches', - border, - ]); - }); - - it('renders the empty filtered state with its search row', () => { - const border = '─'.repeat(80); - expect( - renderDialogListRows( - { - title: 'Pick a theme', - rows: [], - selectedIndex: 0, - query: 'missing', - hint: 'No matches', - }, - 80, - ).map(stripAnsi), - ).toEqual([ - border, - 'Pick a theme', - '↑↓ navigate · Enter select · Esc cancel · Backspace clear', - '', - 'Search: missing', - 'No matches', - border, - ]); - }); - - it('truncates every line to the given width', () => { - const rows = renderDialogListRows( - baseViewModel({ title: 'A very long dialog title that will not fit' }), - 10, - ); - for (const line of rows) { - expect(stripAnsi(line).length).toBeLessThanOrEqual(10); - } - }); -}); - -const ffiEnabled = - process.execArgv.some((arg) => arg.includes('experimental-ffi')) || - (process.env['NODE_OPTIONS'] ?? '').includes('experimental-ffi'); - -// OpenTUI's test renderer requires experimental FFI; these tests skip without it. -describe.skipIf(!ffiEnabled)('DialogListView', () => { - it('renders the same ordered row semantics without ANSI text content', async () => { - const { TextRenderable } = await import('@opentui/core'); - const { testRender } = await import('@opentui/solid'); - const { DialogListView } = await import('../../../src/tui/runtime/dialogs/dialog-list-view'); - const viewModel = baseViewModel(); - const previousChalkLevel = chalk.level; - chalk.level = 3; - - try { - const setup = await testRender( - () => <DialogListView viewModel={viewModel} width={40} />, - { width: 40, height: 10 }, - ); - try { - await setup.renderOnce(); - const frame = setup.captureCharFrame(); - const textContent = descendants(setup.renderer.root) - .filter((node) => node instanceof TextRenderable) - .map((node) => node.plainText) - .join('\n'); - const legacyRows = renderDialogListRows(viewModel, 40).map(stripAnsi); - - expect(textContent).not.toContain('\u001B'); - expect(frame).toContain('❯ Dark'); - expect(frame).not.toContain('❯ Auto'); - let previousIndex = -1; - for (const row of legacyRows.filter((row) => row !== '')) { - const index = frame.indexOf(row, previousIndex + 1); - expect(index).toBeGreaterThan(previousIndex); - previousIndex = index; - } - } finally { - setup.renderer.destroy(); - } - } finally { - chalk.level = previousChalkLevel; - } - }, 30_000); - - it('renders the same empty-state hint as the legacy rows', async () => { - const { testRender } = await import('@opentui/solid'); - const { DialogListView } = await import('../../../src/tui/runtime/dialogs/dialog-list-view'); - const viewModel = baseViewModel({ rows: [], selectedIndex: 0, hint: 'No matches' }); - const legacyRows = withAnsiColors(() => - renderDialogListRows(viewModel, 40).map(stripAnsi), - ); - - const setup = await testRender( - () => <DialogListView viewModel={viewModel} width={40} />, - { width: 40, height: 6 }, - ); - try { - await setup.renderOnce(); - const frame = setup.captureCharFrame(); - expect(frame).toContain(legacyRows[4]); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('applies a different native color to the selected row', async () => { - const { TextRenderable } = await import('@opentui/core'); - const { testRender } = await import('@opentui/solid'); - const { DialogListView } = await import('../../../src/tui/runtime/dialogs/dialog-list-view'); - - const setup = await testRender( - () => <DialogListView viewModel={baseViewModel()} width={40} />, - { width: 40, height: 8 }, - ); - try { - await setup.renderOnce(); - const textNodes = descendants(setup.renderer.root).filter( - (node) => node instanceof TextRenderable, - ); - const selectedRow = textNodes.find((node) => node.plainText.includes('Dark')); - const unselectedRow = textNodes.find((node) => node.plainText.includes('Auto')); - - expect(selectedRow).toBeDefined(); - expect(unselectedRow).toBeDefined(); - expect(selectedRow?.fg.equals(unselectedRow?.fg)).toBe(false); - } finally { - setup.renderer.destroy(); - } - }, 30_000); -}); - -describe.skipIf(!ffiEnabled)('ChoicePickerView', () => { - it('matches pi-tui Select actions through OpenTUI keyboard events', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const cases = [ - { binding: 'x', rawInput: 'x', key: 'x', modifiers: undefined }, - { binding: 'ctrl+k', rawInput: '\u000B', key: 'k', modifiers: { ctrl: true } }, - { binding: 'alt+m', rawInput: '\u001Bm', key: 'm', modifiers: { meta: true } }, - { binding: 'shift+tab', rawInput: '\u001B[Z', key: 'TAB', modifiers: { shift: true } }, - { binding: 'super+w', rawInput: '\u001B[119;9u', key: 'w', modifiers: { super: true } }, - { binding: 'enter', rawInput: '\r', key: 'RETURN', modifiers: undefined }, - ] as const; - - for (const testCase of cases) { - const bindings = parseKeybindingBlocks([ - { context: 'Select', bindings: { [testCase.binding]: 'select:accept' } }, - ]); - const piResolver = new KeybindingResolver(bindings); - let piSelected = false; - expect( - piResolver.dispatch(testCase.rawInput, ['Select'], { - 'select:accept': () => { - piSelected = true; - }, - }), - ).toBe(true); - expect(piSelected).toBe(true); - - const selected: DialogViewModel['rows'][number][] = []; - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - bindings={bindings} - context="Select" - onSelect={(row) => { - selected.push(row); - }} - onCancel={() => undefined} - /> - ), - { width: 40, height: 8, kittyKeyboard: true }, - ); - - try { - await setup.renderOnce(); - setup.mockInput.pressKey(testCase.key, testCase.modifiers); - await setup.waitFor(() => selected.length === 1); - expect(selected[0]?.id).toBe('a'); - } finally { - setup.renderer.destroy(); - } - } - }, 30_000); - - it('consumes Select and Global null bindings but leaves unknown OpenTUI events untouched', async () => { - const { testRender, useKeyboard } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const events: KeyEvent[] = []; - const Probe = () => { - useKeyboard((key) => { - events.push(key); - }); - return ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - bindings={parseKeybindingBlocks([ - { context: 'Select', bindings: { q: null } }, - { context: 'Global', bindings: { w: null } }, - ])} - context="Select" - onSelect={() => undefined} - onCancel={() => undefined} - /> - ); - }; - const setup = await testRender(Probe, { - width: 40, - height: 8, - kittyKeyboard: true, - }); - - try { - await setup.renderOnce(); - setup.mockInput.pressKey('q'); - await setup.waitFor(() => events.length === 1); - expect(events[0]?.defaultPrevented).toBe(true); - expect(events[0]?.propagationStopped).toBe(true); - - setup.mockInput.pressKey('w'); - await setup.waitFor(() => events.length === 2); - expect(events[1]?.defaultPrevented).toBe(true); - expect(events[1]?.propagationStopped).toBe(true); - await setup.renderOnce(); - expect(setup.captureCharFrame()).not.toContain('Search: w'); - - setup.mockInput.pressKey('F1'); - await setup.waitFor(() => events.length === 3); - expect(events[2]?.defaultPrevented).toBe(false); - expect(events[2]?.propagationStopped).toBe(false); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('uses a handled Global Select fallback action', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - let cancellations = 0; - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - bindings={parseKeybindingBlocks([ - { context: 'Global', bindings: { q: 'select:cancel' } }, - ])} - onSelect={() => undefined} - onCancel={() => { - cancellations += 1; - }} - /> - ), - { width: 40, height: 8, kittyKeyboard: true }, - ); - - try { - await setup.renderOnce(); - setup.mockInput.pressKey('q'); - await setup.waitFor(() => cancellations === 1); - expect(setup.captureCharFrame()).not.toContain('Search: q'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('falls back to local search for an unsupported Select chord', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - bindings={parseKeybindingBlocks([ - { context: 'Select', bindings: { 'x y': 'command:search' } }, - ])} - onSelect={() => undefined} - onCancel={() => undefined} - /> - ), - { width: 40, height: 8, kittyKeyboard: true }, - ); - - try { - await setup.renderOnce(); - setup.mockInput.pressKey('x'); - await setup.waitForFrame((frame) => frame.includes('Search: x')); - setup.mockInput.pressKey('y'); - await setup.waitForFrame((frame) => frame.includes('Search: xy')); - expect(setup.captureCharFrame()).toContain('Search: xy'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('reactively navigates, filters, and selects through OpenTUI keyboard input', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const selected: DialogViewModel['rows'][number][] = []; - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - onSelect={(row) => { - selected.push(row); - }} - onCancel={() => undefined} - /> - ), - { width: 40, height: 8, kittyKeyboard: true }, - ); - - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('❯ Auto'); - - setup.mockInput.pressArrow('down'); - await setup.waitForFrame((frame) => frame.includes('❯ Dark')); - - await setup.mockInput.typeText('da'); - await setup.waitForFrame((frame) => frame.includes('Search: da')); - const filteredFrame = setup.captureCharFrame(); - expect(filteredFrame).toContain('❯ Dark'); - expect(filteredFrame).not.toContain('Auto'); - - setup.mockInput.pressEnter(); - await setup.waitFor(() => selected.length === 1); - expect(selected[0]?.id).toBe('b'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('clears search before canceling on Escape', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - let cancellations = 0; - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Pick a theme', rows: baseViewModel().rows }} - width={40} - onSelect={() => undefined} - onCancel={() => { - cancellations += 1; - }} - /> - ), - { width: 40, height: 8 }, - ); - - try { - await setup.renderOnce(); - await setup.mockInput.typeText('da'); - await setup.waitForFrame((frame) => frame.includes('Search: da')); - - setup.mockInput.pressEscape(); - await vi.waitFor(() => { - const frame = setup.captureCharFrame(); - expect(frame).toContain('❯ Auto'); - expect(frame).not.toContain('Search: da'); - }); - expect(cancellations).toBe(0); - - setup.mockInput.pressEscape(); - await vi.waitFor(() => { - expect(cancellations).toBe(1); - }); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('handles Home, End, PageUp, and PageDown navigation', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const rows = Array.from({ length: 6 }, (_, index) => ({ - id: String(index), - label: `Row ${index}`, - })); - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ title: 'Rows', rows, pageSize: 2 }} - width={40} - onSelect={() => undefined} - onCancel={() => undefined} - /> - ), - { width: 40, height: 7 }, - ); - - try { - await setup.renderOnce(); - setup.mockInput.pressKey('END'); - await setup.waitForFrame((frame) => frame.includes('❯ Row 5')); - - setup.mockInput.pressKey('\u001B[5~'); - await setup.waitForFrame((frame) => frame.includes('❯ Row 3')); - - setup.mockInput.pressKey('HOME'); - await setup.waitForFrame((frame) => frame.includes('❯ Row 0')); - - setup.mockInput.pressKey('\u001B[6~'); - await setup.waitForFrame((frame) => frame.includes('❯ Row 2')); - expect(setup.captureCharFrame()).toContain('❯ Row 2'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('moves up and backspaces an active search', async () => { - const { testRender } = await import('@opentui/solid'); - const { ChoicePickerView } = await import( - '../../../src/tui/runtime/dialogs/choice-picker-view' - ); - const setup = await testRender( - () => ( - <ChoicePickerView - options={{ - title: 'Rows', - rows: [ - { id: 'alpha', label: 'Alpha' }, - { id: 'beta', label: 'Beta' }, - ], - }} - width={40} - onSelect={() => undefined} - onCancel={() => undefined} - /> - ), - { width: 40, height: 7 }, - ); - - try { - await setup.renderOnce(); - setup.mockInput.pressArrow('down'); - await setup.waitForFrame((frame) => frame.includes('❯ Beta')); - setup.mockInput.pressArrow('up'); - await setup.waitForFrame((frame) => frame.includes('❯ Alpha')); - - await setup.mockInput.typeText('z'); - await setup.waitForFrame((frame) => frame.includes('No matches')); - setup.mockInput.pressBackspace(); - await setup.waitForFrame((frame) => frame.includes('❯ Alpha')); - expect(setup.captureCharFrame()).toContain('❯ Alpha'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); -}); diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts deleted file mode 100644 index 13e15881..00000000 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ /dev/null @@ -1,618 +0,0 @@ -import { readFileSync } from 'node:fs'; - -import { describe, expect, it } from 'vitest'; - -import { - DEFAULT_STATUS_LINE_CONFIG, - type StatusLineConfig, -} from '#/tui/config'; -import { - createFooterState, - foldFooterEvents, - formatStatusRow, - selectFooterViewModel as selectFooterViewModelBase, - selectStatusBarExtras, - type FooterEvent, - type FooterStatus, - type FooterStatusRowViewModel, - type FooterUpdate, -} from '#/tui/runtime/footer/footer-model'; - -const CLOCK_MS = 90_000; - -function selectFooterViewModel( - state: Parameters<typeof selectFooterViewModelBase>[0], - clockMs: number, - statusLine: StatusLineConfig = DEFAULT_STATUS_LINE_CONFIG, -) { - return selectFooterViewModelBase(state, clockMs, statusLine); -} - -function workflowStatus(): Partial<FooterStatus> { - return { - model: 'DeepSeek V4 Flash', - thinkingLevel: 'max', - cwd: '/Users/example/work/pythinker-code', - homeDir: '/Users/example', - dynamicWorkflowMode: true, - contextUsage: 0.05, - git: { - branch: 'main', - dirty: false, - ahead: 15, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - pullRequest: null, - }, - tokenSpeed: 75.7, - tokenSpeedEstimated: false, - elapsedMs: 252_000, - } as Partial<FooterStatus>; -} - -function configurableState() { - return foldFooterEvents( - createFooterState({ - ...workflowStatus(), - sessionSpendUsd: 1.25, - permissionMode: 'auto', - planMode: true, - }), - [ - { - type: 'goal.updated', - goal: { - status: 'active', - turnsUsed: 2, - turnBudget: 5, - wallClockMs: 3_000, - observedAtMs: CLOCK_MS, - }, - }, - { - type: 'background-counts.updated', - counts: { bashTasks: 2, agentTasks: 3 }, - }, - ], - ); -} - -function statusConfig( - overrides: Partial<StatusLineConfig> = {}, -): StatusLineConfig { - return { ...DEFAULT_STATUS_LINE_CONFIG, ...overrides }; -} - -function hideAllStatusItems(): StatusLineConfig { - return { - showModel: false, - showEffort: false, - showTokenSpeed: false, - showContextBar: false, - showGit: false, - showModes: false, - showElapsed: false, - showGoal: false, - showBackgroundTasks: false, - }; -} - -function mainStatusRow( - statusLine: StatusLineConfig = DEFAULT_STATUS_LINE_CONFIG, -): FooterStatusRowViewModel { - const row = selectFooterViewModel(configurableState(), CLOCK_MS, statusLine).rows.find( - (candidate) => candidate.kind === 'status' && candidate.emphasis !== 'danger', - ); - if (row?.kind !== 'status') throw new Error('Expected a status row'); - return row; -} - -describe('footer model', () => { - it('builds one ordered composer and status hierarchy without persistent chrome noise', () => { - const state = foldFooterEvents(createFooterState(), [ - { type: 'status.updated', changes: workflowStatus() }, - ] satisfies readonly FooterEvent[]); - - const viewModel = selectFooterViewModel(state, CLOCK_MS); - - expect(viewModel.rows).toEqual([ - { - kind: 'composer', - slot: { - kind: 'composer-slot', - marker: '❯', - placeholder: 'Composer', - textLength: 0, - }, - }, - { - kind: 'status', - items: [ - 'DeepSeek V4 Flash · max · 75.7 t/s', - '▱▱▱▱▱▱▱▱ 5%', - 'main ↑15', - 'workflow', - 'elapsed 04:12', - ], - modelName: 'DeepSeek V4 Flash', - }, - ]); - expect(viewModel.rows).toHaveLength(2); - expect(JSON.stringify(viewModel.rows)).not.toContain('/Users/example'); - expect(JSON.stringify(viewModel.rows)).not.toContain('shift+tab'); - }); - - it('uses validation before activity as the only optional third footer row', () => { - const state = foldFooterEvents(createFooterState(), [ - { type: 'status.updated', changes: workflowStatus() }, - { - type: 'activity.updated', - activity: { - phase: 'thinking', - label: 'Thinking through the change', - spinnerActive: true, - spinnerFrame: '⠹', - }, - }, - { - type: 'validation.updated', - validation: { level: 'warning', message: 'Review the selected model' }, - }, - ] satisfies readonly FooterEvent[]); - - const rows = selectFooterViewModel(state, CLOCK_MS).rows; - - expect(rows).toHaveLength(3); - expect(rows[0]?.kind).toBe('validation'); - expect(rows.slice(1).map((row) => row.kind)).toEqual(['composer', 'status']); - }); - - it('keeps visible non-workflow activity while workflow mode is enabled', () => { - const state = foldFooterEvents(createFooterState(), [ - { type: 'status.updated', changes: workflowStatus() }, - { - type: 'activity.updated', - activity: { - phase: 'tool', - label: 'Refreshing git status', - spinnerActive: true, - spinnerFrame: '⠹', - }, - }, - ] satisfies readonly FooterEvent[]); - - const rows = selectFooterViewModel(state, CLOCK_MS).rows; - - expect(rows).toHaveLength(3); - expect(rows[0]).toMatchObject({ - kind: 'activity', - primary: '⠹ Refreshing git status', - }); - }); - - it('omits elapsed time when the runtime has no active start timestamp', () => { - const state = foldFooterEvents(createFooterState(), [ - { - type: 'status.updated', - changes: { - ...workflowStatus(), - elapsedMs: null, - } as Partial<FooterStatus>, - }, - ] satisfies readonly FooterEvent[]); - - const rows = selectFooterViewModel(state, CLOCK_MS).rows; - const status = rows.at(-1); - - expect(status).toMatchObject({ kind: 'status' }); - expect(JSON.stringify(status)).not.toContain('elapsed'); - }); - - it('shows positive session spend without model rates', () => { - const state = createFooterState({ - model: 'Priced Model', - sessionSpendUsd: 0.125, - }); - - const status = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); - - expect(status).toMatchObject({ - kind: 'status', - items: [ - 'Priced Model', - '$0.13', - '▱▱▱▱▱▱▱▱ 0%', - ], - }); - }); - - it('shows session spend when the active model has no catalog rates', () => { - const state = createFooterState({ - model: 'Unpriced Model', - sessionSpendUsd: 12.34, - }); - - const status = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); - - expect(status).toMatchObject({ - kind: 'status', - items: ['Unpriced Model', '$12.34', '▱▱▱▱▱▱▱▱ 0%'], - }); - }); - - it.each([undefined, 0])( - 'omits session spend when it is %s', - (sessionSpendUsd) => { - const status = selectFooterViewModel( - createFooterState({ model: 'Priced Model', sessionSpendUsd }), - CLOCK_MS, - ).rows.at(-1); - - expect(status).toMatchObject({ - kind: 'status', - items: ['Priced Model', '▱▱▱▱▱▱▱▱ 0%'], - }); - }, - ); - - it('uses stable precision without rounding the stored spend', () => { - const sessionSpendUsd = 0.009999999999999_998; - const state = createFooterState({ - model: 'Priced Model', - sessionSpendUsd, - }); - - const status = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); - - expect(state.status.sessionSpendUsd).toBe(sessionSpendUsd); - expect(status).toMatchObject({ - kind: 'status', - items: expect.arrayContaining(['$0.01']), - }); - }); - - it('does not render a small positive spend as zero', () => { - const status = selectFooterViewModel( - createFooterState({ model: 'Priced Model', sessionSpendUsd: 0.004 }), - CLOCK_MS, - ).rows.at(-1); - - expect(status).toMatchObject({ - kind: 'status', - items: expect.arrayContaining(['$0.004']), - }); - }); - - it('keeps all-true status configuration byte-for-byte compatible', () => { - expect(mainStatusRow()).toEqual({ - kind: 'status', - items: [ - 'DeepSeek V4 Flash · max · 75.7 t/s', - '$1.25', - '▱▱▱▱▱▱▱▱ 5%', - 'main ↑15', - 'workflow auto plan', - 'elapsed 04:12', - '[goal ● active · 3s · 2/5 turns]', - '[2 tasks running]', - '[3 agents running]', - ], - modelName: 'DeepSeek V4 Flash', - }); - }); - - it('projects status-bar extras in priority order without the model and modes items', () => { - const state = foldFooterEvents( - createFooterState({ - model: 'DeepSeek V4 Flash', - contextUsage: 0.05, - dynamicWorkflowMode: true, - git: workflowStatus().git, - tokenSpeed: 75.7, - tokenSpeedEstimated: true, - }), - [ - { - type: 'update.updated', - update: { version: '0.11.0', state: 'available', percent: null }, - }, - ], - ); - expect(selectStatusBarExtras(state, CLOCK_MS, DEFAULT_STATUS_LINE_CONFIG)).toEqual( - ['▱▱▱▱▱▱▱▱ 5%', 'main ↑15', '↑ v0.11.0'], - ); - }); - - it('hides model metadata and spend together when the model item is disabled', () => { - const row = mainStatusRow(statusConfig({ showModel: false })); - - expect(row.modelName).toBeNull(); - expect(row.items).not.toEqual( - expect.arrayContaining([ - expect.stringContaining('DeepSeek V4 Flash'), - '$1.25', - ]), - ); - }); - - it('hides effort and token speed independently while retaining the model', () => { - expect( - mainStatusRow(statusConfig({ showEffort: false })).items[0], - ).toBe('DeepSeek V4 Flash · 75.7 t/s'); - expect( - mainStatusRow(statusConfig({ showTokenSpeed: false })).items[0], - ).toBe('DeepSeek V4 Flash · max'); - expect( - mainStatusRow( - statusConfig({ showEffort: false, showTokenSpeed: false }), - ).items[0], - ).toBe('DeepSeek V4 Flash'); - }); - - it('shows requested Fast mode beside the model and hides it with mode badges', () => { - const state = createFooterState({ - model: 'GPT-5.6 Sol', - fastMode: true, - }); - - const visible = selectFooterViewModel( - state, - CLOCK_MS, - DEFAULT_STATUS_LINE_CONFIG, - ).rows.at(-1); - const hidden = selectFooterViewModel( - state, - CLOCK_MS, - statusConfig({ showModes: false }), - ).rows.at(-1); - - expect(visible).toMatchObject({ - kind: 'status', - items: expect.arrayContaining(['GPT-5.6 Sol · ↯ fast']), - }); - expect(hidden).toMatchObject({ - kind: 'status', - items: expect.arrayContaining(['GPT-5.6 Sol']), - }); - }); - - it.each([ - ['showContextBar', '▱▱▱▱▱▱▱▱ 5%'], - ['showGit', 'main ↑15'], - ['showModes', 'workflow auto plan'], - ['showElapsed', 'elapsed 04:12'], - ['showGoal', '[goal ● active · 3s · 2/5 turns]'], - ] as const)('hides %s independently', (key, hiddenItem) => { - const row = mainStatusRow(statusConfig({ [key]: false })); - - expect(row.items).not.toContain(hiddenItem); - expect(row.items).toContain('DeepSeek V4 Flash · max · 75.7 t/s'); - }); - - it('uses one toggle for both background badge kinds', () => { - const row = mainStatusRow( - statusConfig({ showBackgroundTasks: false }), - ); - - expect(row.items).not.toEqual( - expect.arrayContaining(['[2 tasks running]', '[3 agents running]']), - ); - }); - - it('uses showModes for the dedicated YOLO row', () => { - const state = createFooterState({ - model: 'DeepSeek V4 Flash', - permissionMode: 'yolo', - }); - - expect(selectFooterViewModel(state, CLOCK_MS).rows).toHaveLength(3); - expect( - selectFooterViewModel( - state, - CLOCK_MS, - statusConfig({ showModes: false }), - ).rows, - ).toHaveLength(2); - }); - - it('retains an empty status row when every status item is hidden', () => { - const viewModel = selectFooterViewModel( - configurableState(), - CLOCK_MS, - hideAllStatusItems(), - ); - const row = viewModel.rows.at(-1); - - expect(viewModel.rows.map((candidate) => candidate.kind)).toEqual([ - 'composer', - 'status', - ]); - expect(row).toEqual({ kind: 'status', items: [], modelName: null }); - expect(row?.kind === 'status' ? formatStatusRow(row.items) : null).toBe(''); - }); - - it('joins mixed status items without orphaned or doubled separators', () => { - const statusLine = { - ...hideAllStatusItems(), - showGit: true, - showElapsed: true, - }; - const text = formatStatusRow(mainStatusRow(statusLine).items); - - expect(text).toBe(' main ↑15 elapsed 04:12'); - expect(text).not.toMatch(/(^|·)\s*·|·\s*$/u); - }); - - it('keeps transient hints independent from an all-hidden status line', () => { - const state = foldFooterEvents(configurableState(), [ - { - type: 'transient-hint.updated', - hint: 'Press Ctrl-C again to exit', - }, - ]); - const viewModel = selectFooterViewModel( - state, - CLOCK_MS, - hideAllStatusItems(), - ); - - expect(viewModel.rows).toEqual([ - { - kind: 'validation', - level: 'info', - message: 'Press Ctrl-C again to exit', - }, - { - kind: 'composer', - slot: { - kind: 'composer-slot', - marker: '❯', - placeholder: 'Composer', - textLength: 0, - }, - }, - { kind: 'status', items: [], modelName: null }, - ]); - }); - - it('keeps the footer model independent of rendering and ambient runtime modules', () => { - const source = readFileSync( - new URL('../../../src/tui/runtime/footer/footer-model.ts', import.meta.url), - 'utf8', - ); - const importSources = [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map( - (match) => match[1] ?? '', - ); - - expect(importSources).not.toEqual( - expect.arrayContaining([ - expect.stringMatching(/theme|render|terminal|(?:^|\/)io(?:\/|$)|clock/i), - ]), - ); - expect(source).not.toMatch( - /FooterModelCostRates|modelCostRates|formatModelRates|isValidRate|formatRate/, - ); - }); - - describe('update status row', () => { - function statusRowWithUpdate( - update: FooterUpdate, - statusLine: StatusLineConfig = hideAllStatusItems(), - ): FooterStatusRowViewModel { - const state = foldFooterEvents(createFooterState(), [ - { type: 'update.updated', update }, - ] satisfies readonly FooterEvent[]); - const row = selectFooterViewModel(state, CLOCK_MS, statusLine).rows.find( - (candidate) => candidate.kind === 'status', - ); - if (row?.kind !== 'status') throw new Error('Expected a status row'); - return row; - } - - it.each([ - [ - 'available', - { version: '0.11.0', state: 'available', percent: null }, - '↑ v0.11.0', - ], - [ - 'required', - { version: '0.11.0', state: 'required', percent: null }, - '↑ v0.11.0 required', - ], - [ - 'downloading with percent', - { version: '0.11.0', state: 'downloading', percent: 42 }, - '↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', - ], - [ - 'downloading without percent', - { version: '0.11.0', state: 'downloading', percent: null }, - '↓ v0.11.0', - ], - [ - 'waiting', - { version: '0.11.0', state: 'waiting', percent: null }, - '↓ v0.11.0 waiting', - ], - [ - 'ready', - { version: '0.11.0', state: 'ready', percent: null }, - '↑ v0.11.0 restart to apply', - ], - [ - 'failed', - { version: '0.11.0', state: 'failed', percent: null }, - '↑ v0.11.0 failed', - ], - ] as const)('renders %s first in the status row', (_name, update, expected) => { - expect(statusRowWithUpdate(update).items).toEqual([expected]); - }); - - it.each([ - [0, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], - [100, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], - [-5, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], - [150, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], - ] as const)('clamps percent %s into the eight-cell bar', (percent, expected) => { - const items = statusRowWithUpdate({ - version: '0.11.0', - state: 'downloading', - percent, - }).items; - - expect(items).toEqual([expected]); - }); - - it('adds no item for an empty update and leaves the status row unchanged', () => { - const state = foldFooterEvents(createFooterState(), [ - { type: 'update.updated', update: { version: null, state: null, percent: null } }, - ] satisfies readonly FooterEvent[]); - const base = selectFooterViewModel(createFooterState(), CLOCK_MS).rows.at(-1); - const updated = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); - - expect(updated).toEqual(base); - }); - - it('adds no item when the version is null', () => { - const row = statusRowWithUpdate({ - version: null, - state: 'available', - percent: null, - }); - - expect(row.items).toEqual([]); - }); - - it('keeps the update under the composer and out of the activity row', () => { - const state = foldFooterEvents(createFooterState(), [ - { - type: 'activity.updated', - activity: { - phase: 'thinking', - label: 'Thinking through the change', - spinnerActive: true, - spinnerFrame: '⠹', - }, - }, - { - type: 'update.updated', - update: { version: '0.11.0', state: 'downloading', percent: 42 }, - }, - ] satisfies readonly FooterEvent[]); - const rows = selectFooterViewModel(state, CLOCK_MS).rows; - - expect(rows.map((row) => row.kind)).toEqual(['activity', 'composer', 'status']); - expect(rows[0]).toMatchObject({ - kind: 'activity', - primary: '⠹ Thinking through the change', - indicators: [], - }); - expect(rows[2]).toMatchObject({ - kind: 'status', - items: ['↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', '▱▱▱▱▱▱▱▱ 0%'], - }); - }); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/footer/text-layout.test.ts b/apps/pythinker-code/test/tui/runtime/footer/text-layout.test.ts deleted file mode 100644 index f5452ccf..00000000 --- a/apps/pythinker-code/test/tui/runtime/footer/text-layout.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { - truncateToWidth as piTruncateToWidth, - visibleWidth as piVisibleWidth, -} from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; - -import { - truncateToWidth, - visibleWidth, -} from '#/tui/runtime/footer/text-layout'; - -const ELLIPSIS = '…'; - -const DISPLAY_CASES = [ - { name: 'ascii', value: 'hello world', width: 11 }, - { name: 'emoji-sequence', value: '😀😃😄😆', width: 8 }, - { name: 'emoji-mixed', value: 'hello😀😃test', width: 13 }, - { name: 'emoji-simple', value: '😀', width: 2 }, - { name: 'emoji-zwj-family', value: '👨‍👩‍👧‍👦', width: 2 }, - { name: 'emoji-skin-tone', value: '👍🏽', width: 2 }, - { name: 'flag-us', value: '🇺🇸', width: 2 }, - { name: 'combining-accent', value: 'e\u0301', width: 1 }, - { - name: 'ansi-colored', - value: '\u001B[31mred\u001B[39m', - width: 3, - }, - { name: 'ansi-wide', value: '\u001B[32m😀😃\u001B[39m', width: 4 }, - { name: 'box-drawing', value: '┌──┐', width: 4 }, - { name: 'latin-accent', value: 'café', width: 4 }, - { name: 'tab', value: 'a\tb', width: 5 }, - { name: 'tab-only', value: '\t', width: 3 }, - { name: 'tab-after-two', value: 'ab\tc', width: 6 }, - { name: 'tab-after-three', value: 'abc\td', width: 7 }, - { name: 'tab-after-four', value: 'abcd\te', width: 8 }, -] as const; - -const OSC_BEL = '\u001B]0;title\u0007'; -const OSC_ST = '\u001B]8;;https://example.test\u001B\\'; -const OSC_CLOSE = '\u001B]8;;\u001B\\'; - -describe('OpenTUI text layout parity', () => { - // pi-tui is the parity oracle, even where its behavior is arguably not - // abstractly correct: changing shipping layout during the cutover is worse. - describe.each(DISPLAY_CASES)('$name', ({ value }) => { - it('matches pi-tui display width', () => { - expect(visibleWidth(value)).toBe(piVisibleWidth(value)); - }); - - it('matches pi-tui truncated display width', () => { - const oracleWidth = piVisibleWidth(value); - const widths = new Set([ - 0, - 1, - Math.max(1, oracleWidth - 1), - oracleWidth, - oracleWidth + 1, - ]); - - for (const width of widths) { - expect(piVisibleWidth(truncateToWidth(value, width))).toBe( - piVisibleWidth(piTruncateToWidth(value, width, ELLIPSIS)), - ); - } - }); - }); - - it.each([ - { - name: 'ANSI-wrapped text where a code-unit slice would cut an escape', - value: '\u001B[31mred\u001B[39m', - width: 2, - }, - { - name: 'ANSI-wrapped text cut before a wide grapheme', - value: '\u001B[32m😀😃😄\u001B[39m', - width: 4, - }, - { - name: 'pure CSI and OSC escapes', - value: `\u001B[31m\u001B[39m${OSC_BEL}${OSC_ST}${OSC_CLOSE}`, - width: 1, - }, - { name: 'empty string at width zero', value: '', width: 0 }, - { name: 'empty string at width one', value: '', width: 1 }, - ])( - 'matches pi-tui truncation byte-for-byte for $name', - ({ value, width }: Readonly<{ value: string; width: number }>) => { - expect(truncateToWidth(value, width)).toBe( - piTruncateToWidth(value, width, ELLIPSIS), - ); - }, - ); -}); - -describe('display-width table', () => { - it.each(DISPLAY_CASES)( - 'measures $name as $width terminal cells', - ({ value, width }) => { - expect(visibleWidth(value)).toBe(width); - }, - ); - - it.each([ - { name: 'OSC terminated by BEL', value: `${OSC_BEL}link`, width: 4 }, - { - name: 'OSC terminated by string terminator', - value: `${OSC_ST}link${OSC_CLOSE}`, - width: 4, - }, - { - name: 'pure escapes', - value: `\u001B[31m\u001B[39m${OSC_BEL}${OSC_ST}${OSC_CLOSE}`, - width: 0, - }, - ])( - 'measures $name as $width terminal cells', - ({ value, width }: Readonly<{ value: string; width: number }>) => { - expect(visibleWidth(value)).toBe(width); - }, - ); -}); - -describe('visible-width memo', () => { - it('returns the same result for cached and uncached calls', () => { - const value = 'memo-👨‍👩‍👧‍👦-\u001B[31mred\u001B[39m'; - - expect(visibleWidth(value)).toBe(11); - expect(visibleWidth(value)).toBe(11); - }); - - it('does not change results when the cache cap is exceeded', () => { - const retainedValue = 'cache-cap-😀😃'; - expect(visibleWidth(retainedValue)).toBe(14); - - for (let index = 0; index <= 1_000; index += 1) { - const value = `cache-entry-${String(index)}-👍🏽`; - expect(visibleWidth(value)).toBe( - `cache-entry-${String(index)}-`.length + 2, - ); - } - - expect(visibleWidth(retainedValue)).toBe(14); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts deleted file mode 100644 index bd33f483..00000000 --- a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { UpdateCache, UpdateInstallState } from '#/cli/update/types'; -import { footerUpdateFromState } from '#/tui/runtime/footer/update-status'; - -const CURRENT = '0.10.0'; -const NEWER = '0.11.0'; - -const EMPTY = { version: null, state: null, percent: null } as const; - -function installState( - overrides: Partial<UpdateInstallState> = {}, -): UpdateInstallState { - return { - active: null, - pending: null, - lastFailure: null, - lastSuccess: null, - ...overrides, - }; -} - -function cache(latest: string | null = NEWER): UpdateCache { - return { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest, - manifest: null, - }; -} - -describe('footerUpdateFromState', () => { - it('shows downloading when an active install is newer and downloading', () => { - const state = installState({ - active: { - version: NEWER, - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'downloading', - percent: 42, - transferred: 5_320_000, - total: 12_600_000, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ - version: NEWER, - state: 'downloading', - percent: 42, - }); - }); - - it('shows waiting when an active install is newer and waiting', () => { - const state = installState({ - active: { - version: NEWER, - source: 'homebrew', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'waiting', - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'homebrew', null, state)).toEqual({ - version: NEWER, - state: 'waiting', - percent: null, - }); - }); - - it('ignores an active install without progress and falls through to nothing', () => { - const state = installState({ - active: { - version: NEWER, - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); - }); - - it('ignores an active install older than the running version', () => { - const state = installState({ - active: { - version: CURRENT, - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'downloading', - percent: 42, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); - }); - - it('shows ready after a newer version was installed', () => { - const state = installState({ - lastSuccess: { - version: NEWER, - installedAt: '2026-04-23T08:02:00.000Z', - notifiedAt: null, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ - version: NEWER, - state: 'ready', - percent: null, - }); - }); - - it('shows nothing when the last success is the running version', () => { - const state = installState({ - lastSuccess: { - version: CURRENT, - installedAt: '2026-04-23T08:02:00.000Z', - notifiedAt: null, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); - }); - - it('shows failed after a newer install failed', () => { - const state = installState({ - lastFailure: { - version: NEWER, - failedAt: '2026-04-23T08:02:00.000Z', - attempts: 2, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ - version: NEWER, - state: 'failed', - percent: null, - }); - }); - - it('prefers the active install over a recorded success', () => { - const state = installState({ - active: { - version: NEWER, - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'downloading', - percent: 10, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - lastSuccess: { - version: NEWER, - installedAt: '2026-04-23T08:02:00.000Z', - notifiedAt: null, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ - version: NEWER, - state: 'downloading', - percent: 10, - }); - }); - - it('prefers a recorded success over a recorded failure', () => { - const state = installState({ - lastFailure: { - version: NEWER, - failedAt: '2026-04-23T08:02:00.000Z', - attempts: 1, - }, - lastSuccess: { - version: NEWER, - installedAt: '2026-04-23T08:03:00.000Z', - notifiedAt: null, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ - version: NEWER, - state: 'ready', - percent: null, - }); - }); - - it('prefers a recorded failure over an available target', () => { - const state = installState({ - lastFailure: { - version: NEWER, - failedAt: '2026-04-23T08:02:00.000Z', - attempts: 1, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', cache(), state)).toEqual({ - version: NEWER, - state: 'failed', - percent: null, - }); - }); - - it('shows available when the cache targets a newer installable version', () => { - expect(footerUpdateFromState(CURRENT, 'native', cache(), installState())).toEqual({ - version: NEWER, - state: 'available', - percent: null, - }); - }); - - it('shows required when the cached manifest declares a minRequiredVersion above current', () => { - const requiredCache: UpdateCache = { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest: NEWER, - manifest: { - version: NEWER, - publishedAt: '2026-04-23T08:00:00.000Z', - rollout: [], - minRequiredVersion: '0.10.1', - }, - }; - - expect(footerUpdateFromState(CURRENT, 'native', requiredCache, installState())).toEqual({ - version: NEWER, - state: 'required', - percent: null, - }); - }); - - it('keeps available when the declared minRequiredVersion is at or below current', () => { - const baseManifest = { - version: NEWER, - publishedAt: '2026-04-23T08:00:00.000Z', - rollout: [], - }; - const atCurrent: UpdateCache = { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest: NEWER, - manifest: { ...baseManifest, minRequiredVersion: CURRENT }, - }; - const belowCurrent: UpdateCache = { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest: NEWER, - manifest: { ...baseManifest, minRequiredVersion: '0.9.0' }, - }; - - expect(footerUpdateFromState(CURRENT, 'native', atCurrent, installState())).toEqual({ - version: NEWER, - state: 'available', - percent: null, - }); - expect(footerUpdateFromState(CURRENT, 'native', belowCurrent, installState())).toEqual({ - version: NEWER, - state: 'available', - percent: null, - }); - }); - - it('still shows downloading when a required update is already in flight', () => { - const requiredCache: UpdateCache = { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest: NEWER, - manifest: { - version: NEWER, - publishedAt: '2026-04-23T08:00:00.000Z', - rollout: [], - minRequiredVersion: '0.10.1', - }, - }; - const state = installState({ - active: { - version: NEWER, - source: 'native', - startedAt: '2026-04-23T08:00:00.000Z', - progress: { - state: 'downloading', - percent: 42, - transferred: 5_320_000, - total: 12_600_000, - updatedAt: '2026-04-23T08:01:00.000Z', - }, - }, - }); - - expect(footerUpdateFromState(CURRENT, 'native', requiredCache, state)).toEqual({ - version: NEWER, - state: 'downloading', - percent: 42, - }); - }); - - it('shows nothing when the cache target is not installable from this source', () => { - const unavailableCache: UpdateCache = { - source: 'cdn', - checkedAt: '2026-04-23T08:00:00.000Z', - latest: NEWER, - manifest: { - version: NEWER, - publishedAt: '2026-04-23T08:00:00.000Z', - rollout: [], - platforms: {}, - }, - }; - - expect( - footerUpdateFromState(CURRENT, 'native', unavailableCache, installState()), - ).toEqual(EMPTY); - }); - - it('shows nothing when the cache is null', () => { - expect(footerUpdateFromState(CURRENT, 'native', null, installState())).toEqual(EMPTY); - }); - - it('shows nothing when the cache has no newer latest', () => { - expect(footerUpdateFromState(CURRENT, 'native', cache(CURRENT), installState())).toEqual( - EMPTY, - ); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/open-tui-composer-port.test.ts b/apps/pythinker-code/test/tui/runtime/open-tui-composer-port.test.ts deleted file mode 100644 index 6631e9f9..00000000 --- a/apps/pythinker-code/test/tui/runtime/open-tui-composer-port.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - KeybindingResolver, - parseKeybindingBlocks, -} from '#/tui/keybindings'; -import { - OpenTuiComposerPort, - openTuiKeyId, - type ComposerIntent, - type ComposerKey, -} from '#/tui/runtime/footer/open-tui-composer-port'; -import { renderComposerRow } from '../../../src/tui/runtime/footer/composer'; - -function kinds(intents: readonly ComposerIntent[]): readonly string[] { - return intents.map((intent) => intent.kind); -} - -function press( - port: OpenTuiComposerPort, - key: string, - modifiers: Omit<ComposerKey, 'key'> = {}, -): readonly ComposerIntent[] { - return port.handleKey({ key, ...modifiers }); -} - -describe('OpenTuiComposerPort', () => { - it('normalizes OpenTUI keys and matches pi-tui configured actions', () => { - expect([ - openTuiKeyId({ key: 'a' }), - openTuiKeyId({ key: 's', ctrl: true }), - openTuiKeyId({ key: 'p', alt: true }), - openTuiKeyId({ key: 'tab', shift: true }), - openTuiKeyId({ key: 'w', super: true }), - openTuiKeyId({ - key: 'return', - ctrl: true, - alt: true, - shift: true, - super: true, - }), - ]).toEqual([ - 'a', - 'ctrl+s', - 'alt+p', - 'shift+tab', - 'super+w', - 'ctrl+alt+shift+super+enter', - ]); - - const bindings = parseKeybindingBlocks([ - { - context: 'Chat', - bindings: { - x: 'chat:cycleMode', - 'ctrl+k': 'chat:cycleMode', - 'alt+m': 'chat:cycleMode', - 'shift+tab': 'chat:cycleMode', - 'super+w': 'chat:cycleMode', - enter: 'chat:cycleMode', - }, - }, - ]); - const cases: readonly [string, string, ComposerKey][] = [ - ['x', 'x', { key: 'x' }], - ['ctrl+k', '\u000B', { key: 'k', ctrl: true }], - ['alt+m', '\u001Bm', { key: 'm', alt: true }], - ['shift+tab', '\u001B[Z', { key: 'tab', shift: true }], - ['super+w', '\u001B[119;9u', { key: 'w', super: true }], - ['enter', '\r', { key: 'return' }], - ]; - - for (const [keyId, rawInput, openTuiKey] of cases) { - const piResolver = new KeybindingResolver(bindings); - const piActions: string[] = []; - expect( - piResolver.dispatch(rawInput, ['Chat'], { - 'chat:cycleMode': () => { - piActions.push(keyId); - }, - }), - ).toBe(true); - - const port = new OpenTuiComposerPort({ bindings }); - expect(port.handleKey(openTuiKey)).toEqual([{ kind: 'cycle-thinking-effort' }]); - expect(piActions).toEqual([keyId]); - } - - const port = new OpenTuiComposerPort(); - expect(kinds(press(port, 'x'))).toEqual(['changed', 'autocomplete']); - expect(port.getText()).toBe('x'); - }); - - it('submits canonical OpenTUI return and ignores unbound Super keys', () => { - const port = new OpenTuiComposerPort({ text: 'hello' }); - - expect(press(port, 'return')).toEqual([ - { kind: 'submit', text: 'hello' }, - { kind: 'changed', text: '' }, - { kind: 'autocomplete', prefix: null }, - ]); - expect(port.getText()).toBe(''); - expect(press(port, 'q', { super: true })).toEqual([]); - expect(port.getText()).toBe(''); - }); - - it('consumes an explicit null binding without editing text', () => { - const port = new OpenTuiComposerPort({ - bindings: parseKeybindingBlocks([ - { context: 'Chat', bindings: { q: null } }, - ]), - }); - - expect(press(port, 'q')).toEqual([]); - expect(port.getText()).toBe(''); - }); - - it('falls back to editing for an unsupported Chat action chord', () => { - const port = new OpenTuiComposerPort({ - bindings: parseKeybindingBlocks([ - { context: 'Chat', bindings: { 'x y': 'chat:modelPicker' } }, - ]), - }); - - expect(kinds(press(port, 'x'))).toEqual(['changed', 'autocomplete']); - expect(port.getText()).toBe('x'); - expect(kinds(press(port, 'y'))).toEqual(['changed', 'autocomplete']); - expect(port.getText()).toBe('xy'); - }); - - it('submits non-empty text, clears it, and ignores empty submits', () => { - const port = new OpenTuiComposerPort({ text: 'hello' }); - - expect(press(port, 'enter')).toEqual([ - { kind: 'submit', text: 'hello' }, - { kind: 'changed', text: '' }, - { kind: 'autocomplete', prefix: null }, - ]); - expect(port.getText()).toBe(''); - expect(press(port, 'enter')).toEqual([]); - }); - - it('inserts newlines for Shift+Enter and backslash-Enter', () => { - const shifted = new OpenTuiComposerPort({ text: 'one' }); - const escaped = new OpenTuiComposerPort({ text: 'one\\' }); - - expect(kinds(press(shifted, 'enter', { shift: true }))).toEqual([ - 'changed', - 'autocomplete', - ]); - expect(shifted.getText()).toBe('one\n'); - expect(kinds(press(escaped, 'enter'))).toEqual([ - 'changed', - 'autocomplete', - ]); - expect(escaped.getText()).toBe('one\n'); - }); - - it('uses double-tap exit intents and only Ctrl+C clears text', () => { - const port = new OpenTuiComposerPort({ text: 'draft' }); - - expect(press(port, 'c', { ctrl: true })).toEqual([ - { kind: 'changed', text: '' }, - { kind: 'autocomplete', prefix: null }, - { kind: 'exit-intent', source: 'ctrl-c' }, - ]); - expect(port.pendingExit).toBe('ctrl-c'); - expect(press(port, 'c', { ctrl: true })).toEqual([ - { kind: 'exit-intent', source: 'ctrl-c' }, - ]); - - port.setText('preserved'); - expect(press(port, 'd', { ctrl: true })).toEqual([ - { kind: 'exit-intent', source: 'ctrl-d' }, - ]); - expect(port.getText()).toBe('preserved'); - expect(press(port, 'd', { ctrl: true })).toEqual([ - { kind: 'exit-intent', source: 'ctrl-d' }, - ]); - }); - - it('clears pending exit on other keys and emits command intents', () => { - const cases: readonly [ - ComposerKey, - ComposerIntent, - ][] = [ - [{ key: 'escape' }, { kind: 'cancel' }], - [{ key: 's', ctrl: true }, { kind: 'steer' }], - [{ key: 'tab', shift: true }, { kind: 'cycle-thinking-effort' }], - [{ key: 'o', ctrl: true }, { kind: 'toggle-expansion' }], - [ - { key: 'g', ctrl: true }, - { kind: 'open-external-editor', text: 'draft' }, - ], - ]; - - for (const [key, expected] of cases) { - const port = new OpenTuiComposerPort({ text: 'draft' }); - press(port, 'd', { ctrl: true }); - expect(port.handleKey(key)).toEqual([expected]); - expect(port.pendingExit).toBeNull(); - } - }); - - it('navigates history up and down through the state engine', () => { - const port = new OpenTuiComposerPort(); - port.addToHistory('first'); - port.addToHistory('second'); - - expect(press(port, 'up')[0]).toEqual({ - kind: 'changed', - text: 'second', - }); - expect(press(port, 'up')[0]).toEqual({ - kind: 'changed', - text: 'first', - }); - expect(press(port, 'down')[0]).toEqual({ - kind: 'changed', - text: 'second', - }); - expect(press(port, 'down')[0]).toEqual({ - kind: 'changed', - text: '', - }); - }); - - it('moves the cursor and deletes graphemes or words', () => { - const port = new OpenTuiComposerPort({ text: 'one two' }); - - expect(press(port, 'left')).toEqual([]); - expect(port.getViewState().cursorCol).toBe(6); - expect(press(port, 'left', { ctrl: true })).toEqual([]); - expect(port.getViewState().cursorCol).toBe(4); - expect(kinds(press(port, 'delete', { alt: true }))).toEqual([ - 'changed', - 'autocomplete', - ]); - expect(port.getText()).toBe('one '); - press(port, 'end'); - expect(kinds(press(port, 'backspace', { ctrl: true }))).toEqual([ - 'changed', - 'autocomplete', - ]); - expect(port.getText()).toBe('one'); - - port.setText('ab'); - press(port, 'home'); - expect(kinds(press(port, 'delete'))).toEqual([ - 'changed', - 'autocomplete', - ]); - press(port, 'end'); - expect(kinds(press(port, 'backspace'))).toEqual([ - 'changed', - 'autocomplete', - ]); - expect(port.getText()).toBe(''); - }); - - it('supports text boundaries and Alt+Right movement', () => { - const port = new OpenTuiComposerPort({ text: 'one two\nthree' }); - - press(port, 'home', { ctrl: true }); - expect(port.getViewState()).toMatchObject({ cursorLine: 0, cursorCol: 0 }); - press(port, 'right'); - expect(port.getViewState().cursorCol).toBe(1); - press(port, 'left'); - expect(port.getViewState().cursorCol).toBe(0); - press(port, 'right', { alt: true }); - expect(port.getViewState().cursorCol).toBe(3); - press(port, 'right', { ctrl: true }); - expect(port.getViewState().cursorCol).toBe(4); - press(port, 'left', { alt: true }); - expect(port.getViewState().cursorCol).toBe(3); - press(port, 'left', { ctrl: true }); - expect(port.getViewState().cursorCol).toBe(0); - press(port, 'end', { ctrl: true }); - expect(port.getViewState()).toMatchObject({ cursorLine: 1, cursorCol: 5 }); - press(port, 'home'); - expect(port.getViewState().cursorCol).toBe(0); - }); - - it('inserts printable keys and emits autocomplete prefixes', () => { - const port = new OpenTuiComposerPort(); - - press(port, '/'); - const intents = press(port, 'h'); - - expect(intents).toEqual([ - { kind: 'changed', text: '/h' }, - { - kind: 'autocomplete', - prefix: { kind: 'slash', query: 'h', start: 0 }, - }, - ]); - }); - - it('tracks focus and exposes a renderer-neutral view', () => { - const port = new OpenTuiComposerPort({ - marker: '>', - placeholder: 'Prompt', - }); - - expect(port.isFocused()).toBe(false); - port.focus(); - expect(port.isFocused()).toBe(true); - port.blur(); - expect(port.isFocused()).toBe(false); - expect(port.getViewState()).toMatchObject({ - marker: '>', - text: '', - placeholder: 'Prompt', - cursorLine: 0, - cursorCol: 0, - isEmpty: true, - }); - }); -}); - -describe('renderComposerRow', () => { - const view = { - marker: '>', - text: 'long composer text', - placeholder: 'Prompt', - cursorLine: 0, - cursorCol: 18, - isEmpty: false, - }; - - it('renders exactly one row', () => { - expect(renderComposerRow(view, 80)).toBe('> long composer text'); - expect(renderComposerRow(view, 80)).not.toMatch(/[\r\n]/u); - }); - - it('truncates to the supplied width', () => { - const row = renderComposerRow(view, 8); - - expect(row).toBe('> long …'); - expect(Array.from(row)).toHaveLength(8); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/open-tui-lifecycle.test.ts b/apps/pythinker-code/test/tui/runtime/open-tui-lifecycle.test.ts deleted file mode 100644 index e9497209..00000000 --- a/apps/pythinker-code/test/tui/runtime/open-tui-lifecycle.test.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { EventEmitter } from 'node:events'; - -import type { CliRendererConfig, ExternalOutputMode, ScreenMode } from '@opentui/core'; -import { describe, expect, it, vi } from 'vitest'; - -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - OpenTuiLifecycle, - type OpenTuiLifecycleRenderer, - type OpenTuiRetainedSurface, -} from '#/tui/runtime/open-tui-lifecycle'; -import { OpenTuiPresentation } from '../../../src/tui/runtime/open-tui-presentation'; -import { - createFooterState, - reduceFooterState, - selectFooterViewModel, -} from '../../../src/tui/runtime/footer/footer-model'; - -type OutputWrite = NodeJS.WriteStream['write']; - -function outputStream(events: string[], name: string): NodeJS.WriteStream { - const stream = { - write: ((chunk: string | Uint8Array): boolean => { - events.push(`${name}:${String(chunk)}`); - return true; - }) as OutputWrite, - }; - return stream as unknown as NodeJS.WriteStream; -} - -class FakeRenderer extends EventEmitter implements OpenTuiLifecycleRenderer { - readonly width = 120; - private outputMode: ExternalOutputMode = 'capture-stdout'; - private mode: ScreenMode = 'split-footer'; - footerHeight = 4; - - constructor(private readonly events: string[]) { - super(); - } - - get externalOutputMode(): ExternalOutputMode { - return this.outputMode; - } - - set externalOutputMode(mode: ExternalOutputMode) { - this.events.push(`renderer.output:${mode}`); - this.outputMode = mode; - } - - get screenMode(): ScreenMode { - return this.mode; - } - - set screenMode(mode: ScreenMode) { - this.events.push(`renderer.screen:${mode}`); - this.mode = mode; - } - - requestRender(): void { - this.events.push('renderer.render'); - } - - async idle(): Promise<void> { - this.events.push('renderer.idle'); - } - - destroy(): void { - this.events.push('renderer.destroy'); - } -} - -function surface(events: string[], name: string): OpenTuiRetainedSurface { - return { - invalidate: () => { - events.push(`${name}.invalidate`); - }, - close: () => { - events.push(`${name}.close`); - }, - }; -} - -function makeLifecycle( - events: string[] = [], - overrides: Partial<ConstructorParameters<typeof OpenTuiLifecycle>[0]> = {}, -): { - lifecycle: OpenTuiLifecycle; - renderer: FakeRenderer; - stdout: NodeJS.WriteStream; - stderr: NodeJS.WriteStream; -} { - const renderer = new FakeRenderer(events); - const stdout = outputStream(events, 'stdout'); - const stderr = outputStream(events, 'stderr'); - const lifecycle = new OpenTuiLifecycle({ - stdin: {} as NodeJS.ReadStream, - stdout, - stderr, - rendererFactory: async () => renderer, - footerFactory: () => surface(events, 'footer'), - ...overrides, - }); - return { lifecycle, renderer, stdout, stderr }; -} - -describe('OpenTuiLifecycle', () => { - it('creates the renderer with the exact split-footer runtime options', async () => { - const configs: CliRendererConfig[] = []; - const events: string[] = []; - const renderer = new FakeRenderer(events); - const stdout = outputStream(events, 'stdout'); - const stdin = {} as NodeJS.ReadStream; - const lifecycle = new OpenTuiLifecycle({ - stdin, - stdout, - stderr: outputStream(events, 'stderr'), - rendererFactory: async (config) => { - configs.push(config); - return renderer; - }, - footerFactory: () => surface(events, 'footer'), - }); - - await lifecycle.start(() => {}); - - expect(configs).toEqual([ - expect.objectContaining({ - stdin, - stdout, - screenMode: 'split-footer', - footerHeight: 2, - externalOutputMode: 'capture-stdout', - targetFps: 30, - maxFps: 60, - useMouse: false, - enableMouseMovement: false, - exitOnCtrlC: false, - exitSignals: [], - consoleMode: 'disabled', - openConsoleOnError: false, - clearOnShutdown: false, - }), - ]); - - lifecycle.stop(); - }); - - it('rolls back every acquired resource when partial start fails', async () => { - const events: string[] = []; - const renderer = new FakeRenderer(events); - const stdout = outputStream(events, 'stdout'); - const stderr = outputStream(events, 'stderr'); - const lifecycle = new OpenTuiLifecycle({ - stdin: {} as NodeJS.ReadStream, - stdout, - stderr, - rendererFactory: async () => renderer, - footerFactory: () => { - stdout.write('before failure'); - stderr.write('failure detail'); - throw new Error('footer failed'); - }, - }); - - await expect(lifecycle.start(() => {})).rejects.toThrow('footer failed'); - - expect(events).toEqual([ - 'stdout:before failure', - 'stdout:failure detail', - 'renderer.output:passthrough', - 'renderer.screen:main-screen', - 'renderer.destroy', - ]); - }); - - it('makes a second stop a no-op', async () => { - const events: string[] = []; - const { lifecycle } = makeLifecycle(events); - await lifecycle.start(() => {}); - - lifecycle.stop(); - lifecycle.stop(); - - expect(events.filter((event) => event === 'renderer.destroy')).toHaveLength(1); - }); - - it('commits captured stdout and stderr in arrival order', async () => { - const events: string[] = []; - const { lifecycle, stdout, stderr } = makeLifecycle(events); - await lifecycle.start(() => {}); - events.length = 0; - - stdout.write('one'); - stderr.write('two'); - stdout.write('three'); - expect(events).toEqual([]); - - lifecycle.commitCapturedOutput(); - - expect(events).toEqual(['stdout:one', 'stdout:two', 'stdout:three']); - lifecycle.stop(); - }); - - it('uses the required six-step shutdown order', async () => { - const events: string[] = []; - const { lifecycle, stdout } = makeLifecycle(events); - await lifecycle.start(() => {}); - lifecycle.setActiveSurface(surface(events, 'active')); - events.length = 0; - stdout.write('committed'); - - lifecycle.stop(); - - expect(events).toEqual([ - 'active.close', - 'stdout:committed', - 'footer.close', - 'renderer.output:passthrough', - 'renderer.screen:main-screen', - 'renderer.destroy', - ]); - }); -}); - -describe('OpenTuiPresentation', () => { - it('updates the mutable renderer footer height from the shared footer model', async () => { - const events: string[] = []; - const renderer = new FakeRenderer(events); - const presentation = new OpenTuiPresentation({ - stdin: {} as NodeJS.ReadStream, - stdout: outputStream(events, 'stdout'), - stderr: outputStream(events, 'stderr'), - rendererFactory: async () => renderer, - footerFactory: () => surface(events, 'footer'), - }); - const compact = selectFooterViewModel( - createFooterState(), - 0, - DEFAULT_STATUS_LINE_CONFIG, - ); - const active = selectFooterViewModel( - reduceFooterState(createFooterState(), { - type: 'activity.updated', - activity: { - phase: 'thinking', - label: 'Thinking', - spinnerActive: true, - spinnerFrame: '⠋', - }, - }), - 0, - DEFAULT_STATUS_LINE_CONFIG, - ); - - presentation.start(() => {}); - await presentation.ready(); - events.length = 0; - - presentation.updateFooter(compact); - expect(renderer.footerHeight).toBe(2); - expect(events).toEqual(['footer.invalidate']); - - events.length = 0; - presentation.updateFooter(active); - expect(renderer.footerHeight).toBe(3); - expect(events).toEqual(['footer.invalidate']); - presentation.stop(); - }); - - it('invalidates only the footer and active retained surface on resize', async () => { - const events: string[] = []; - const onResize = vi.fn(); - const renderer = new FakeRenderer(events); - const presentation = new OpenTuiPresentation({ - stdin: {} as NodeJS.ReadStream, - stdout: outputStream(events, 'stdout'), - stderr: outputStream(events, 'stderr'), - rendererFactory: async () => renderer, - footerFactory: () => surface(events, 'footer'), - }); - - presentation.start(() => { - onResize(); - }); - await presentation.ready(); - presentation.setActiveSurface(surface(events, 'active')); - events.length = 0; - - renderer.emit('resize'); - - expect(events).toEqual(['footer.invalidate', 'active.invalidate']); - expect(onResize).toHaveBeenCalledOnce(); - presentation.stop(); - }); - - it('keeps terminal writes ordered and composer state minimal', async () => { - const events: string[] = []; - const renderer = new FakeRenderer(events); - const presentation = new OpenTuiPresentation({ - stdin: {} as NodeJS.ReadStream, - stdout: outputStream(events, 'stdout'), - stderr: outputStream(events, 'stderr'), - rendererFactory: async () => renderer, - footerFactory: () => surface(events, 'footer'), - }); - - presentation.start(() => {}); - await presentation.ready(); - events.length = 0; - presentation.setTerminalTitle('Session'); - presentation.setTerminalProgress(true); - presentation.writeTerminalControl('\u001B[?25h'); - presentation.setComposerText('draft'); - presentation.focusComposer(); - presentation.addComposerHistory('previous'); - presentation.notifyIdle(); - - expect(presentation.getComposerText()).toBe('draft'); - expect(presentation.composerFocused).toBe(true); - expect(presentation.composerHistory).toEqual(['previous']); - expect(events).toEqual([ - 'stdout:\u001B]0;Session\u0007', - 'stdout:\u001B]9;4;3\u0007', - 'stdout:\u001B[?25h', - 'renderer.render', - ]); - - presentation.stop(); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/opentui-jsx-smoke.test.tsx b/apps/pythinker-code/test/tui/runtime/opentui-jsx-smoke.test.tsx deleted file mode 100644 index 29badc6f..00000000 --- a/apps/pythinker-code/test/tui/runtime/opentui-jsx-smoke.test.tsx +++ /dev/null @@ -1,269 +0,0 @@ -/* - * Proves the OpenTUI + Solid path actually executes, not merely typechecks. - * - * Everything else in `runtime/` is unit-tested through pure exports, so no test - * had ever compiled the JSX or built a real renderer. Run with - * `pnpm test:opentui`; without `--experimental-ffi` the native binding is - * unavailable and this skips rather than failing for an unrelated reason. - */ - -import type { BaseRenderable } from '@opentui/core'; -import { createTestRenderer } from '@opentui/core/testing'; -import { describe, expect, it } from 'vitest'; - -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { currentTheme } from '#/tui/theme'; -import { runOpenTuiProbe } from '../../../src/tui/runtime/open-tui-probe'; -import { OpenTuiPresentation } from '../../../src/tui/runtime/open-tui-presentation'; -import { PythinkerTUI } from '../../../src/tui/pythinker-tui'; -import { - createFooterState, - foldFooterEvents, - selectFooterViewModel, - type FooterStatus, -} from '../../../src/tui/runtime/footer/footer-model'; - -const ffiEnabled = - process.execArgv.some((arg) => arg.includes('experimental-ffi')) || - (process.env['NODE_OPTIONS'] ?? '').includes('experimental-ffi'); - -function descendants(root: BaseRenderable): readonly BaseRenderable[] { - return root.getChildren().flatMap((child) => [child, ...descendants(child)]); -} - -function inertOutput(): NodeJS.WriteStream { - return { - write: (() => true) as NodeJS.WriteStream['write'], - } as unknown as NodeJS.WriteStream; -} - -function makeStartupInput() { - return { - cliOptions: { - session: undefined, - continue: false, - rewindFiles: undefined, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - tuiConfig: { - theme: 'dark' as const, - layout: 'inline' as const, - copyFullResponse: false, - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' as const }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - }, - version: '0.0.0-test', - workDir: '/tmp/proj-a', - }; -} - -describe('OpenTUI JSX', () => { - it.skipIf(!ffiEnabled)('renders solid JSX through a real renderer', async () => { - await expect(runOpenTuiProbe()).resolves.toBeUndefined(); - }, 30_000); - - it.skipIf(!ffiEnabled)('renders danger status rows with the theme error red', async () => { - const { RGBA, TextRenderable } = await import('@opentui/core'); - const { testRender } = await import('@opentui/solid'); - const { StatusRow } = await import('../../../src/tui/runtime/footer/status-row'); - const setup = await testRender( - () => ( - <StatusRow - model={{ - kind: 'status', - items: ['yolo'], - emphasis: 'danger', - modelName: null, - }} - renderedText=' yolo' - /> - ), - { width: 20, height: 1 }, - ); - - try { - await setup.renderOnce(); - const yolo = descendants(setup.renderer.root).find( - (node) => node instanceof TextRenderable && node.plainText.trim() === 'yolo', - ); - - expect(yolo).toBeDefined(); - expect( - yolo instanceof TextRenderable && - yolo.fg.equals(RGBA.fromHex(currentTheme.palette.error)), - ).toBe(true); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it.skipIf(!ffiEnabled)('renders the persistent status row with the faint theme color', async () => { - const { RGBA, TextRenderable } = await import('@opentui/core'); - const { testRender } = await import('@opentui/solid'); - const { StatusRow } = await import('../../../src/tui/runtime/footer/status-row'); - const setup = await testRender( - () => ( - <StatusRow - model={{ - kind: 'status', - items: ['DeepSeek V4 Flash'], - modelName: 'DeepSeek V4 Flash', - }} - renderedText=' DeepSeek V4 Flash' - /> - ), - { width: 40, height: 1 }, - ); - - try { - await setup.renderOnce(); - const status = descendants(setup.renderer.root).find( - (node) => - node instanceof TextRenderable && - node.plainText.trim() === 'DeepSeek V4 Flash', - ); - - expect(status).toBeDefined(); - expect( - status instanceof TextRenderable && - status.fg.equals(RGBA.fromHex(currentTheme.palette.textDim)), - ).toBe(true); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it.skipIf(!ffiEnabled)('renders the default composer port in a real footer surface', async () => { - const state = foldFooterEvents(createFooterState(), [ - { - type: 'status.updated', - changes: { - model: 'DeepSeek V4 Flash', - thinkingLevel: 'max', - cwd: '/Users/example/work/pythinker-code', - homeDir: '/Users/example', - dynamicWorkflowMode: true, - contextUsage: 0.05, - git: { - branch: 'main', - dirty: false, - ahead: 15, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - pullRequest: null, - }, - tokenSpeed: 75.7, - tokenSpeedEstimated: false, - elapsedMs: 252_000, - } as Partial<FooterStatus>, - }, - ]); - const viewModel = selectFooterViewModel( - state, - 300_000, - DEFAULT_STATUS_LINE_CONFIG, - ); - const setup = await createTestRenderer({ - width: 120, - height: 6, - screenMode: 'split-footer', - footerHeight: 2, - exitOnCtrlC: false, - exitSignals: [], - useMouse: false, - useKittyKeyboard: null, - }); - const presentation = new OpenTuiPresentation({ - stdin: {} as NodeJS.ReadStream, - stdout: inertOutput(), - stderr: inertOutput(), - rendererFactory: async () => setup.renderer, - }); - - try { - presentation.start(() => {}); - await presentation.ready(); - presentation.updateFooter(viewModel); - await setup.flush(); - const emptyFrame = setup.captureCharFrame(); - - expect(setup.renderer.footerHeight).toBe(2); - expect(emptyFrame).toContain('❯ Type a message'); - expect(emptyFrame).toContain( - 'DeepSeek V4 Flash · max · 75.7 t/s ▱▱▱▱▱▱▱▱ 5% · main ↑15 · workflow · elapsed 04:12', - ); - expect(emptyFrame).not.toContain('/Users/example/work/pythinker-code'); - expect(emptyFrame).not.toContain('shift+tab: plan mode'); - - presentation.setComposerText('restore this draft'); - await setup.flush(); - const draftFrame = setup.captureCharFrame(); - - expect(draftFrame).toContain('❯ restore this draft'); - expect(draftFrame).not.toContain('❯ Type a message'); - } finally { - presentation.stop(); - if (!setup.renderer.isDestroyed) setup.renderer.destroy(); - } - }, 30_000); - - it.skipIf(!ffiEnabled)('receives live non-workflow activity through the presentation seam', async () => { - const setup = await createTestRenderer({ - width: 120, - height: 6, - screenMode: 'split-footer', - footerHeight: 2, - exitOnCtrlC: false, - exitSignals: [], - useMouse: false, - useKittyKeyboard: null, - }); - const presentation = new OpenTuiPresentation({ - stdin: {} as NodeJS.ReadStream, - stdout: inertOutput(), - stderr: inertOutput(), - rendererFactory: async () => setup.renderer, - }); - const tui = new PythinkerTUI( - {} as never, - makeStartupInput(), - presentation, - ); - - try { - presentation.start(() => {}); - await presentation.ready(); - tui.patchLivePane({ mode: 'waiting' }); - await setup.flush(); - - const activeFrame = setup.captureCharFrame(); - expect(setup.renderer.footerHeight).toBe(3); - expect(activeFrame).toContain('⠋ Waiting…'); - expect(activeFrame).toContain('❯ Type a message'); - expect(activeFrame).toContain('▱▱▱▱▱▱▱▱ 0%'); - - tui.resetLivePane(); - await setup.flush(); - - const idleFrame = setup.captureCharFrame(); - expect(setup.renderer.footerHeight).toBe(2); - expect(idleFrame).not.toContain('Waiting…'); - expect(idleFrame).toContain('❯ Type a message'); - expect(idleFrame).toContain('▱▱▱▱▱▱▱▱ 0%'); - } finally { - tui.resetLivePane(); - tui.state.footer.dispose(); - presentation.stop(); - if (!setup.renderer.isDestroyed) setup.renderer.destroy(); - } - }, 30_000); -}); diff --git a/apps/pythinker-code/test/tui/runtime/opentui-reactivity.test.tsx b/apps/pythinker-code/test/tui/runtime/opentui-reactivity.test.tsx deleted file mode 100644 index fd2b23fe..00000000 --- a/apps/pythinker-code/test/tui/runtime/opentui-reactivity.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import type { TextRenderable } from '@opentui/core'; -import { For, Index, createSignal, type Setter } from 'solid-js'; -import { createSignal as createClientSignal } from 'solid-js/dist/solid.js'; -import { describe, expect, it } from 'vitest'; - -const ffiEnabled = - process.execArgv.some((arg) => arg.includes('experimental-ffi')) || - (process.env['NODE_OPTIONS'] ?? '').includes('experimental-ffi'); - -describe('Solid runtime identity', () => { - it('resolves the bare import to the OpenTUI client runtime', () => { - expect(createSignal).toBe(createClientSignal); - }); -}); - -describe.skipIf(!ffiEnabled)('OpenTUI Solid reactivity', () => { - it('updates a signal-driven text child created outside the render root', async () => { - const { testRender } = await import('@opentui/solid'); - const [value, setValue] = createSignal('BEFORE'); - - const setup = await testRender(() => <text>{value()}</text>, { width: 40, height: 4 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('BEFORE'); - - setValue('AFTER'); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('AFTER'); - expect(updatedFrame).not.toContain('BEFORE'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('updates a signal-driven text child created inside the render root', async () => { - const { testRender } = await import('@opentui/solid'); - let setValue!: Setter<string>; - - function OwnerScopedText() { - const [value, updateValue] = createSignal('BEFORE'); - setValue = updateValue; - return <text>{value()}</text>; - } - - const setup = await testRender(OwnerScopedText, { width: 40, height: 4 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('BEFORE'); - - setValue('AFTER'); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('AFTER'); - expect(updatedFrame).not.toContain('BEFORE'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('updates via the content prop with the ordinary Solid import', async () => { - const { testRender } = await import('@opentui/solid'); - let setValue!: Setter<string>; - - function ReactiveContent() { - const [value, updateValue] = createSignal('BEFORE'); - setValue = updateValue; - return <text content={value()} />; - } - - const setup = await testRender(ReactiveContent, { width: 40, height: 4 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('BEFORE'); - - setValue('AFTER'); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('AFTER'); - expect(updatedFrame).not.toContain('BEFORE'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('keeps a TextNode first child while reactive text updates', async () => { - const { testRender } = await import('@opentui/solid'); - let setValue!: Setter<string>; - let textRenderable!: TextRenderable; - - function ReactiveChild() { - const [value, updateValue] = createSignal('BEFORE'); - setValue = updateValue; - return ( - <text - ref={(node) => { - textRenderable = node; - }} - > - {value()} - </text> - ); - } - - const setup = await testRender(ReactiveChild, { width: 40, height: 4 }); - try { - await setup.renderOnce(); - expect(textRenderable.getTextChildren()[0]?.constructor.name).toBe('TextNode'); - - setValue('AFTER'); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('AFTER'); - expect(updatedFrame).not.toContain('BEFORE'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('updates a signal-driven text child after explicit invalidation', async () => { - const { testRender } = await import('@opentui/solid'); - let setValue!: Setter<string>; - - function ReactiveChild() { - const [value, updateValue] = createSignal('BEFORE'); - setValue = updateValue; - return <text>{value()}</text>; - } - - const setup = await testRender(ReactiveChild, { width: 40, height: 4 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('BEFORE'); - - setValue('AFTER'); - setup.renderer.requestRender(); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('AFTER'); - expect(updatedFrame).not.toContain('BEFORE'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('updates a signal-driven list rendered with For', async () => { - const { testRender } = await import('@opentui/solid'); - let setItems!: Setter<string[]>; - - function ReactiveForList() { - const [items, updateItems] = createSignal(['ALPHA', 'BETA']); - setItems = updateItems; - return ( - <box flexDirection="column"> - <For each={items()}>{(item) => <text content={item} />}</For> - </box> - ); - } - - const setup = await testRender(ReactiveForList, { width: 40, height: 6 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('ALPHA'); - - setItems(['GAMMA', 'DELTA']); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('GAMMA'); - expect(updatedFrame).toContain('DELTA'); - expect(updatedFrame).not.toContain('ALPHA'); - expect(updatedFrame).not.toContain('BETA'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); - - it('updates a signal-driven list rendered with Index', async () => { - const { testRender } = await import('@opentui/solid'); - let setItems!: Setter<string[]>; - - function ReactiveIndexList() { - const [items, updateItems] = createSignal(['ALPHA', 'BETA']); - setItems = updateItems; - return ( - <box flexDirection="column"> - <Index each={items()}>{(item) => <text content={item()} />}</Index> - </box> - ); - } - - const setup = await testRender(ReactiveIndexList, { width: 40, height: 6 }); - try { - await setup.renderOnce(); - expect(setup.captureCharFrame()).toContain('ALPHA'); - - setItems(['GAMMA', 'DELTA']); - await setup.renderOnce(); - const updatedFrame = setup.captureCharFrame(); - expect(updatedFrame).toContain('GAMMA'); - expect(updatedFrame).toContain('DELTA'); - expect(updatedFrame).not.toContain('ALPHA'); - expect(updatedFrame).not.toContain('BETA'); - } finally { - setup.renderer.destroy(); - } - }, 30_000); -}); diff --git a/apps/pythinker-code/test/tui/runtime/retained-surface.test.ts b/apps/pythinker-code/test/tui/runtime/retained-surface.test.ts deleted file mode 100644 index b5744821..00000000 --- a/apps/pythinker-code/test/tui/runtime/retained-surface.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Verifies stable chunk extraction and retained-tail behavior for streamed text. - */ - -import { describe, expect, it } from 'vitest'; - -import { RetainedSurface } from '../../../src/tui/runtime/scrollback/retained-surface'; - -describe('RetainedSurface plain mode', () => { - it('commits complete lines incrementally and preserves empty lines', () => { - const surface = new RetainedSurface('plain'); - - expect(surface.accept('a\nb')).toEqual(['a']); - expect(surface.committedText() + surface.retained()).toBe('a\nb'); - expect(surface.retained()).toBe('b'); - expect(surface.accept('a\nb\n\nc')).toEqual(['b', '']); - expect(surface.committedText() + surface.retained()).toBe('a\nb\n\nc'); - expect(surface.retained()).toBe('c'); - expect(surface.accept('a\nb\n\ncd')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('a\nb\n\ncd'); - }); - - it('ignores equal, shrinking, and divergent text without changing state', () => { - const surface = new RetainedSurface('plain'); - - expect(surface.accept('line\ntail')).toEqual(['line']); - expect(surface.accept('line\ntail')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('line\ntail'); - expect(surface.accept('line\n')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('line\ntail'); - expect(surface.accept('line\nfail')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('line\ntail'); - }); - - it('flushes a retained tail exactly once', () => { - const surface = new RetainedSurface('plain'); - - expect(surface.accept('tail')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('tail'); - expect(surface.flush()).toEqual(['tail']); - expect(surface.committedText() + surface.retained()).toBe('tail'); - expect(surface.retained()).toBe(''); - expect(surface.flush()).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('tail'); - }); - - it('returns an empty flush after consuming a complete stream', () => { - const surface = new RetainedSurface('plain'); - - expect(surface.accept('line\n')).toEqual(['line']); - expect(surface.retained()).toBe(''); - expect(surface.flush()).toEqual([]); - expect(surface.committedText()).toBe('line\n'); - }); -}); - -describe('RetainedSurface markdown mode', () => { - it('commits complete blocks incrementally and retains incomplete additions', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('# Title\n\nBody')).toEqual(['# Title']); - expect(surface.committedText() + surface.retained()).toBe('# Title\n\nBody'); - expect(surface.retained()).toBe('Body'); - expect(surface.accept('# Title\n\nBody grows')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('# Title\n\nBody grows'); - expect(surface.accept('# Title\n\nBody grows\n\nTail')).toEqual(['Body grows']); - expect(surface.committedText() + surface.retained()).toBe( - '# Title\n\nBody grows\n\nTail', - ); - expect(surface.retained()).toBe('Tail'); - }); - - it('ignores equal, shrinking, and divergent text without changing state', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('Block\n\nTail')).toEqual(['Block']); - expect(surface.accept('Block\n\nTail')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('Block\n\nTail'); - expect(surface.accept('Block\n\n')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('Block\n\nTail'); - expect(surface.accept('Block\n\nOther')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('Block\n\nTail'); - }); - - it('does not split at blank lines inside a fenced code block', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('```\ncode\n\nmore')).toEqual([]); - expect(surface.committedText() + surface.retained()).toBe('```\ncode\n\nmore'); - expect(surface.accept('```\ncode\n\nmore\n```\n\ntail')).toEqual([ - '```\ncode\n\nmore\n```', - ]); - expect(surface.committedText() + surface.retained()).toBe( - '```\ncode\n\nmore\n```\n\ntail', - ); - expect(surface.retained()).toBe('tail'); - }); - - it('tracks tilde fences by marker and opening length', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('~~~~\ncode\n\nstill open\n````\n\nmore')).toEqual([]); - expect(surface.retained()).toBe('~~~~\ncode\n\nstill open\n````\n\nmore'); - expect( - surface.accept('~~~~\ncode\n\nstill open\n````\n\nmore\n~~~~\n\ntail'), - ).toEqual(['~~~~\ncode\n\nstill open\n````\n\nmore\n~~~~']); - expect(surface.retained()).toBe('tail'); - }); - - it('consumes a run of newlines as one block separator', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('First\n\n\n\nSecond')).toEqual(['First']); - expect(surface.committedText()).toBe('First\n\n\n\n'); - expect(surface.retained()).toBe('Second'); - expect(surface.committedText() + surface.retained()).toBe('First\n\n\n\nSecond'); - }); - - it('keeps an open fence retained until it is flushed', () => { - const surface = new RetainedSurface('markdown'); - - expect(surface.accept('Before\n\n````\ncode\n\nstill open')).toEqual(['Before']); - expect(surface.committedText() + surface.retained()).toBe( - 'Before\n\n````\ncode\n\nstill open', - ); - expect(surface.retained()).toBe('````\ncode\n\nstill open'); - expect(surface.flush()).toEqual(['````\ncode\n\nstill open']); - expect(surface.committedText() + surface.retained()).toBe( - 'Before\n\n````\ncode\n\nstill open', - ); - expect(surface.retained()).toBe(''); - expect(surface.flush()).toEqual([]); - }); - - it('terminates for newline-only and backtick-only input', () => { - const newlines = new RetainedSurface('markdown'); - const backticks = new RetainedSurface('markdown'); - - expect(newlines.accept('\n\n\n')).toEqual(['']); - expect(newlines.committedText()).toBe('\n\n\n'); - expect(newlines.retained()).toBe(''); - expect(newlines.flush()).toEqual([]); - - expect(backticks.accept('````')).toEqual([]); - expect(backticks.retained()).toBe('````'); - expect(backticks.flush()).toEqual(['````']); - expect(backticks.flush()).toEqual([]); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/scrollback-round-trip.test.tsx b/apps/pythinker-code/test/tui/runtime/scrollback-round-trip.test.tsx deleted file mode 100644 index 5341a15f..00000000 --- a/apps/pythinker-code/test/tui/runtime/scrollback-round-trip.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * End-to-end proof for the deferred cutover wiring. - * - * Every runtime module so far is unit-tested through pure exports, which cannot - * show that committed history survives later renders. This drives the real - * OpenTUI renderer in split-footer mode and asserts the property the migration - * rests on: text already in scrollback is never rewritten by a footer update. - * - * Requires `--experimental-ffi`; run via `pnpm test:opentui`. - */ - -import { Writable } from 'node:stream'; - -import { createTestRenderer } from '@opentui/core/testing'; -import { describe, expect, it } from 'vitest'; - -import { TranscriptPresenter } from '../../../src/tui/runtime/transcript-presenter'; -import { StaticWriter } from '../../../src/tui/runtime/scrollback/static-writer'; - -const ffiEnabled = - process.execArgv.some((arg) => arg.includes('experimental-ffi')) || - (process.env['NODE_OPTIONS'] ?? '').includes('experimental-ffi'); - -describe.skipIf(!ffiEnabled)('transcript commits reach scrollback', () => { - it('writes each committed entry once and never rewrites it', async () => { - // The renderer intercepts writes on the stream it owns, not on - // process.stdout, so the writer's sink has to target that same stream. - const stdout = Object.assign( - new Writable({ - write(_chunk, _encoding, callback): void { - callback(); - }, - }), - { columns: 40, rows: 10, isTTY: true }, - ) as unknown as NodeJS.WriteStream; - - const { renderer, flush, externalOutput } = await createTestRenderer({ - stdout, - width: 40, - height: 10, - screenMode: 'split-footer', - footerHeight: 2, - externalOutputMode: 'capture-stdout', - exitOnCtrlC: false, - exitSignals: [], - useMouse: false, - useKittyKeyboard: null, - }); - - try { - const presenter = new TranscriptPresenter<string>(); - const writer = new StaticWriter<string>({ - sink: (text) => { stdout.write(text); }, - render: (body) => body, - }); - - writer.writeAll(presenter.append('u1', 'first user message')); - writer.writeAll(presenter.begin('a1', 'assistant start')); - writer.writeAll(presenter.update('a1', 'hello', (delta) => delta)); - writer.writeAll(presenter.complete('a1', 'assistant done')); - await flush(); - - const committed = externalOutput.takeText(); - expect(committed).toContain('first user message'); - expect(committed).toContain('assistant done'); - - // A stale update and a duplicate completion are already suppressed by the - // presenter, so these assert the presenter's guard, not the writer's. - const before = committed; - writer.writeAll(presenter.update('a1', 'hel', (delta) => delta)); - writer.writeAll(presenter.complete('a1', 'assistant done again')); - await flush(); - expect(externalOutput.takeText()).toBe(''); - - // Re-delivering a commit the writer has already seen must also emit - // nothing. This is the writer's own guard, which the presenter cannot - // cover: replay hands back commits that were written in a past session. - const replayed = presenter.append('u2', 'second user message'); - expect(writer.writeAll(replayed)).toBe(1); - await flush(); - expect(externalOutput.takeText()).toContain('second user message'); - - expect(writer.writeAll(replayed)).toBe(0); - await flush(); - expect(externalOutput.takeText()).toBe(''); - - // A later render must not reissue or alter committed scrollback. - renderer.requestRender(); - await flush(); - expect(externalOutput.takeText()).toBe(''); - expect(before).toContain('first user message'); - } finally { - renderer.destroy(); - } - }, 30_000); -}); diff --git a/apps/pythinker-code/test/tui/runtime/solid-client-runtime.d.ts b/apps/pythinker-code/test/tui/runtime/solid-client-runtime.d.ts deleted file mode 100644 index 5a8f6c67..00000000 --- a/apps/pythinker-code/test/tui/runtime/solid-client-runtime.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'solid-js/dist/solid.js' { - export * from 'solid-js'; -} diff --git a/apps/pythinker-code/test/tui/runtime/split-footer-view.test.tsx b/apps/pythinker-code/test/tui/runtime/split-footer-view.test.tsx deleted file mode 100644 index 9092944d..00000000 --- a/apps/pythinker-code/test/tui/runtime/split-footer-view.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - DEFAULT_STATUS_LINE_CONFIG, - type StatusLineConfig, -} from '#/tui/config'; -import { - createFooterState, - foldFooterEvents, - selectFooterViewModel, - type FooterEvent, - type FooterStatus, -} from '#/tui/runtime/footer/footer-model'; -import { renderFooterRows } from '../../../src/tui/runtime/footer/split-footer-view'; - -const CLOCK_MS = 300_000; - -function workflowStatus(): Partial<FooterStatus> { - return { - model: 'DeepSeek V4 Flash', - thinkingLevel: 'max', - cwd: '/Users/example/work/pythinker-code', - homeDir: '/Users/example', - dynamicWorkflowMode: true, - contextUsage: 0.05, - git: { - branch: 'main', - dirty: false, - ahead: 15, - behind: 0, - diffAdded: 0, - diffDeleted: 0, - pullRequest: null, - }, - tokenSpeed: 75.7, - tokenSpeedEstimated: false, - elapsedMs: 252_000, - } as Partial<FooterStatus>; -} - -function footerViewModel( - events: readonly FooterEvent[] = [], - statusLine: StatusLineConfig = DEFAULT_STATUS_LINE_CONFIG, -) { - const state = foldFooterEvents( - createFooterState(), - [ - { type: 'status.updated', changes: workflowStatus() }, - ...events, - ] satisfies readonly FooterEvent[], - ); - return selectFooterViewModel(state, CLOCK_MS, statusLine); -} - -function rows( - events: readonly FooterEvent[] = [], - statusLine: StatusLineConfig = DEFAULT_STATUS_LINE_CONFIG, -) { - return renderFooterRows(footerViewModel(events, statusLine), 120); -} - -function statusConfig( - overrides: Partial<StatusLineConfig>, -): StatusLineConfig { - return { ...DEFAULT_STATUS_LINE_CONFIG, ...overrides }; -} - -describe('split footer row layout', () => { - it('renders the default composer and status rows in the required hierarchy', () => { - expect(rows()).toEqual([ - '❯ [Composer]', - ' DeepSeek V4 Flash · max · 75.7 t/s ▱▱▱▱▱▱▱▱ 5% · main ↑15 · workflow · elapsed 04:12', - ]); - }); - - it('renders YOLO at the start of a red second status row beneath the model', () => { - const events: readonly FooterEvent[] = [ - { - type: 'status.updated', - changes: { permissionMode: 'yolo' }, - }, - ]; - const viewModel = footerViewModel(events); - const rendered = renderFooterRows(viewModel, 120); - - expect(viewModel.rows.at(-1)).toMatchObject({ emphasis: 'danger' }); - expect(rendered).toHaveLength(3); - expect(rendered[1]).toContain('DeepSeek V4 Flash'); - expect(rendered[1]).not.toContain('yolo'); - expect(rendered[2]).toBe(' yolo'); - }); - - it('renders a representative mixed configuration in shared item order', () => { - const rendered = rows( - [], - statusConfig({ - showModel: false, - showContextBar: false, - showModes: false, - }), - ); - - expect(rendered).toEqual([ - '❯ [Composer]', - ' main ↑15 elapsed 04:12', - ]); - }); - - it('retains the composer and an empty status row when all items are hidden', () => { - const rendered = rows([], { - showModel: false, - showEffort: false, - showTokenSpeed: false, - showContextBar: false, - showGit: false, - showModes: false, - showElapsed: false, - showGoal: false, - showBackgroundTasks: false, - }); - - expect(rendered).toEqual(['❯ [Composer]', '']); - }); - - it('adds one activity row without restoring the fixed four-row footer', () => { - const rendered = rows([ - { - type: 'status.updated', - changes: { dynamicWorkflowMode: false }, - }, - { - type: 'activity.updated', - activity: { - phase: 'thinking', - label: 'Thinking through the change', - spinnerActive: true, - spinnerFrame: '⠹', - }, - }, - ]); - - expect(rendered).toHaveLength(3); - expect(rendered[0]).toContain('Thinking through the change'); - expect(rendered.slice(1)).toEqual([ - '❯ [Composer]', - ' DeepSeek V4 Flash · max · 75.7 t/s ▱▱▱▱▱▱▱▱ 5% · main ↑15 · elapsed 04:12', - ]); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/streaming-scrollback-wiring.test.ts b/apps/pythinker-code/test/tui/runtime/streaming-scrollback-wiring.test.ts deleted file mode 100644 index bcbb5dba..00000000 --- a/apps/pythinker-code/test/tui/runtime/streaming-scrollback-wiring.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Unit coverage for the bridge's chunking and dedupe rules. - * - * That the streaming controller actually reaches it is proven separately, in - * pythinker-tui-message-flow.test.ts, through the real session event path. - */ - -import { describe, expect, it } from 'vitest'; - -import { ScrollbackBridge } from '../../../src/tui/runtime/scrollback/scrollback-bridge'; - -describe('ScrollbackBridge', () => { - it('commits only whole markdown blocks while text is still growing', () => { - const written: string[] = []; - const bridge = new ScrollbackBridge({ sink: (text) => written.push(text) }); - - bridge.begin('a1', 'turn-1'); - bridge.update('a1', '# Title'); - expect(written).toEqual([]); - - bridge.update('a1', '# Title\n\nBody so far'); - expect(written).toEqual(['# Title\n']); - - // The incomplete tail must stay out of scrollback until completion. - bridge.update('a1', '# Title\n\nBody so far and more'); - expect(written).toEqual(['# Title\n']); - - bridge.complete('a1'); - expect(written).toEqual(['# Title\n', 'Body so far and more\n']); - }); - - it('ignores stale updates and unknown entries', () => { - const written: string[] = []; - const bridge = new ScrollbackBridge({ sink: (text) => written.push(text) }); - - bridge.begin('a1'); - bridge.update('a1', 'hello\n\n'); - bridge.update('a1', 'hel'); - bridge.update('unknown', 'anything\n\n'); - bridge.complete('unknown'); - - expect(written).toEqual(['hello\n']); - }); - - it('holds a fenced code block together across updates', () => { - const written: string[] = []; - const bridge = new ScrollbackBridge({ sink: (text) => written.push(text) }); - - bridge.begin('a1'); - bridge.update('a1', '```ts\nconst a = 1;\n'); - bridge.update('a1', '```ts\nconst a = 1;\n\nconst b = 2;\n'); - expect(written).toEqual([]); - - bridge.update('a1', '```ts\nconst a = 1;\n\nconst b = 2;\n```\n\ntail'); - expect(written).toEqual(['```ts\nconst a = 1;\n\nconst b = 2;\n```\n']); - }); - - it('writes a static entry once', () => { - const written: string[] = []; - const bridge = new ScrollbackBridge({ sink: (text) => written.push(text) }); - - bridge.append('u1', 'a user message'); - bridge.append('u1', 'a user message'); - expect(written).toEqual(['a user message\n']); - }); - - it('starts clean after reset', () => { - const written: string[] = []; - const bridge = new ScrollbackBridge({ sink: (text) => written.push(text) }); - - bridge.append('u1', 'first'); - bridge.reset(); - bridge.append('u1', 'first'); - - expect(written).toEqual(['first\n', 'first\n']); - }); -}); diff --git a/apps/pythinker-code/test/tui/runtime/transcript-presenter.test.ts b/apps/pythinker-code/test/tui/runtime/transcript-presenter.test.ts deleted file mode 100644 index cf439f67..00000000 --- a/apps/pythinker-code/test/tui/runtime/transcript-presenter.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Verifies deterministic transcript lifecycle commits and rejection behavior. - */ - -import { describe, expect, it } from 'vitest'; -import { TranscriptPresenter } from '../../../src/tui/runtime/transcript-presenter'; - -describe('TranscriptPresenter', () => { - it('appends an unknown entry once as final', () => { - const presenter = new TranscriptPresenter<string>(); - - expect(presenter.append('entry', 'body')).toEqual([ - { - key: 'entry:final', - entryId: 'entry', - phase: 'final', - body: 'body', - }, - ]); - expect(presenter.append('entry', 'duplicate')).toEqual([]); - }); - - it('begins an unknown entry once', () => { - const presenter = new TranscriptPresenter<string>(); - - expect(presenter.begin('entry', 'body')).toEqual([ - { - key: 'entry:start', - entryId: 'entry', - phase: 'start', - body: 'body', - }, - ]); - expect(presenter.begin('entry', 'duplicate')).toEqual([]); - }); - - it('emits suffix deltas with incrementing progress keys', () => { - const presenter = new TranscriptPresenter<string>(); - presenter.begin('entry', 'start'); - - expect(presenter.update('entry', 'hello', (delta) => delta)).toEqual([ - { - key: 'entry:progress:0', - entryId: 'entry', - phase: 'progress', - body: 'hello', - }, - ]); - expect(presenter.update('entry', 'hello world', (delta) => delta)).toEqual([ - { - key: 'entry:progress:1', - entryId: 'entry', - phase: 'progress', - body: ' world', - }, - ]); - }); - - it('rejects shrinking, equal, and divergent updates without advancing the counter', () => { - const presenter = new TranscriptPresenter<string>(); - let calls = 0; - const makeBody = (delta: string): string => { - calls += 1; - return delta; - }; - - presenter.begin('entry', 'start'); - presenter.update('entry', 'hello', makeBody); - - expect(presenter.update('entry', 'hell', makeBody)).toEqual([]); - expect(presenter.update('entry', 'hello', makeBody)).toEqual([]); - expect(presenter.update('entry', 'hullo!', makeBody)).toEqual([]); - expect(calls).toBe(1); - expect(presenter.update('entry', 'hello!', makeBody)).toEqual([ - { - key: 'entry:progress:1', - entryId: 'entry', - phase: 'progress', - body: '!', - }, - ]); - expect(calls).toBe(2); - }); - - it('completes a live entry once and rejects unknown or finalized entries', () => { - const presenter = new TranscriptPresenter<string>(); - - expect(presenter.complete('unknown', 'final')).toEqual([]); - presenter.begin('entry', 'start'); - expect(presenter.complete('entry', 'final')).toEqual([ - { - key: 'entry:final', - entryId: 'entry', - phase: 'final', - body: 'final', - }, - ]); - expect(presenter.complete('entry', 'duplicate')).toEqual([]); - expect(presenter.update('entry', 'late', (delta) => delta)).toEqual([]); - }); - - it('resets state and progress keys for a reused entry id', () => { - const presenter = new TranscriptPresenter<string>(); - - presenter.begin('entry', 'start'); - presenter.update('entry', 'text', (delta) => delta); - presenter.reset(); - - expect(presenter.begin('entry', 'new start')).toEqual([ - { - key: 'entry:start', - entryId: 'entry', - phase: 'start', - body: 'new start', - }, - ]); - expect(presenter.update('entry', 'new', (delta) => delta)).toEqual([ - { - key: 'entry:progress:0', - entryId: 'entry', - phase: 'progress', - body: 'new', - }, - ]); - }); - - it('reports idle state based only on live entries', () => { - const presenter = new TranscriptPresenter<string>(); - - expect(presenter.idle()).toBe(true); - presenter.append('finalized', 'body'); - expect(presenter.idle()).toBe(true); - presenter.begin('live', 'body'); - expect(presenter.idle()).toBe(false); - presenter.complete('live', 'body'); - expect(presenter.idle()).toBe(true); - presenter.begin('another', 'body'); - presenter.reset(); - expect(presenter.idle()).toBe(true); - }); - - it('carries turn ids through live commits and omits absent turn ids', () => { - const presenter = new TranscriptPresenter<string>(); - - expect(presenter.begin('with-turn', 'start', 'turn-1')).toEqual([ - { - key: 'with-turn:start', - entryId: 'with-turn', - turnId: 'turn-1', - phase: 'start', - body: 'start', - }, - ]); - expect(presenter.update('with-turn', 'text', (delta) => delta)).toEqual([ - { - key: 'with-turn:progress:0', - entryId: 'with-turn', - turnId: 'turn-1', - phase: 'progress', - body: 'text', - }, - ]); - expect(presenter.complete('with-turn', 'final')).toEqual([ - { - key: 'with-turn:final', - entryId: 'with-turn', - turnId: 'turn-1', - phase: 'final', - body: 'final', - }, - ]); - - const withoutTurn = presenter.append('without-turn', 'body'); - expect(withoutTurn).toEqual([ - { - key: 'without-turn:final', - entryId: 'without-turn', - phase: 'final', - body: 'body', - }, - ]); - expect(withoutTurn[0]).not.toHaveProperty('turnId'); - }); -}); diff --git a/apps/pythinker-code/test/tui/signal-handlers.test.ts b/apps/pythinker-code/test/tui/signal-handlers.test.ts index c1ccf6b6..16e3215b 100644 --- a/apps/pythinker-code/test/tui/signal-handlers.test.ts +++ b/apps/pythinker-code/test/tui/signal-handlers.test.ts @@ -1,16 +1,6 @@ -import { EventEmitter } from 'node:events'; - -import type { ExternalOutputMode, ScreenMode } from '@opentui/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { PythinkerTUI, type PythinkerTUIStartupInput, type TUIState } from '#/tui/pythinker-tui'; -import type { - OpenTuiLifecycleRenderer, - OpenTuiLifecycleOptions, -} from '#/tui/runtime/open-tui-lifecycle'; -import { OpenTuiPresentation } from '../../src/tui/runtime/open-tui-presentation'; -import { LEGACY_TEST_PATHS, PARITY_CASES } from './parity/feature-matrix'; interface SignalDriver { state: TUIState; @@ -25,7 +15,6 @@ function makeStartupInput(): PythinkerTUIStartupInput { cliOptions: { session: undefined, continue: false, - rewindFiles: undefined, yolo: false, auto: false, plan: false, @@ -33,15 +22,16 @@ function makeStartupInput(): PythinkerTUIStartupInput { outputFormat: undefined, prompt: undefined, skillsDirs: [], + agent: undefined, + agentFiles: [], }, tuiConfig: { theme: 'dark', - layout: 'inline', - copyFullResponse: false, + disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, + statusLine: { items: null, command: null }, }, version: '0.0.0-test', workDir: '/tmp/proj-signals', @@ -72,82 +62,6 @@ function makeDriver(): { driver: SignalDriver; tui: PythinkerTUI } { return { driver, tui }; } -interface TerminalModes { - cursorVisible: boolean; - rawInput: boolean; - bracketedPaste: boolean; - focusReporting: boolean; - kittyKeyboard: boolean; -} - -const RESTORED_TERMINAL_MODES: TerminalModes = { - cursorVisible: true, - rawInput: false, - bracketedPaste: false, - focusReporting: false, - kittyKeyboard: false, -}; - -class RestoringRenderer extends EventEmitter implements OpenTuiLifecycleRenderer { - readonly width = 120; - externalOutputMode: ExternalOutputMode = 'capture-stdout'; - screenMode: ScreenMode = 'split-footer'; - footerHeight = 2; - - constructor(private readonly modes: TerminalModes) { - super(); - } - - requestRender(): void {} - - async idle(): Promise<void> {} - - destroy(): void { - Object.assign(this.modes, RESTORED_TERMINAL_MODES); - } -} - -function inertOutput(): NodeJS.WriteStream { - return { - write: (() => true) as NodeJS.WriteStream['write'], - } as unknown as NodeJS.WriteStream; -} - -function makeOpenTuiPresentation( - options: { failFooter?: boolean } = {}, -): { presentation: OpenTuiPresentation; modes: TerminalModes } { - const modes: TerminalModes = { ...RESTORED_TERMINAL_MODES }; - const renderer = new RestoringRenderer(modes); - const lifecycleOptions: OpenTuiLifecycleOptions = { - stdin: {} as NodeJS.ReadStream, - stdout: inertOutput(), - stderr: inertOutput(), - rendererFactory: async () => { - Object.assign(modes, { - cursorVisible: false, - rawInput: true, - bracketedPaste: true, - focusReporting: true, - kittyKeyboard: true, - }); - return renderer; - }, - footerFactory: () => { - if (options.failFooter === true) { - throw new Error('footer initialization failed'); - } - return { - invalidate: () => {}, - close: () => {}, - }; - }, - }; - return { - presentation: new OpenTuiPresentation(lifecycleOptions), - modes, - }; -} - // Capture handlers via process.prependListener spy so we can invoke them // directly without going through `process.emit`. Routing through emit also // fires unrelated listeners that vitest installs on its worker process, and @@ -430,7 +344,7 @@ describe('PythinkerTUI signal handlers', () => { const beforeStdout = process.stdout.listenerCount('error'); const beforeStderr = process.stderr.listenerCount('error'); - await expect(tui.start()).rejects.toThrow(/init boom/u); + await expect(tui.start()).rejects.toThrow(/init boom/); expect(process.listenerCount('SIGTERM')).toBe(beforeSigterm); expect(process.listenerCount('SIGHUP')).toBe(beforeSighup); @@ -438,79 +352,3 @@ describe('PythinkerTUI signal handlers', () => { expect(process.stderr.listenerCount('error')).toBe(beforeStderr); }); }); - -describe('terminal restoration feature parity baseline', () => { - it('links signal and distribution restoration behavior to active parity scenarios', () => { - const linked = PARITY_CASES.filter( - ({ legacyTest }) => legacyTest === LEGACY_TEST_PATHS.signals, - ); - expect(linked.length).toBeGreaterThan(0); - expect( - linked.every(({ status, scenarioId }) => status === 'active' && scenarioId.length > 0), - ).toBe(true); - }); -}); - -describe('OpenTuiPresentation terminal restoration', () => { - it('restores all terminal modes after a successful run', async () => { - const { presentation, modes } = makeOpenTuiPresentation(); - presentation.start(() => {}); - await presentation.ready(); - - presentation.stop(); - - expect(modes).toEqual(RESTORED_TERMINAL_MODES); - }); - - it('restores all terminal modes when the application handles Ctrl-C', async () => { - const { presentation, modes } = makeOpenTuiPresentation(); - presentation.start(() => {}); - await presentation.ready(); - - const handleCtrlC = (): void => { - presentation.stop(); - }; - handleCtrlC(); - - expect(modes).toEqual(RESTORED_TERMINAL_MODES); - }); - - it('restores all terminal modes through the SIGTERM stop path', async () => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); - expect(platformDescriptor).toBeDefined(); - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); - const exitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((() => undefined) as unknown as typeof process.exit); - const { presentation, modes } = makeOpenTuiPresentation(); - presentation.start(() => {}); - await presentation.ready(); - const tui = new PythinkerTUI(makeHarness() as never, makeStartupInput(), presentation); - const driver = tui as unknown as SignalDriver; - const stopSpy = vi.spyOn(tui, 'stop').mockImplementation(async () => { - presentation.stop(); - }); - const captured = captureHandlers(driver); - - captured.signalHandlers.get('SIGTERM')?.(); - await Promise.resolve(); - await Promise.resolve(); - - expect(stopSpy).toHaveBeenCalledWith(143); - expect(modes).toEqual(RESTORED_TERMINAL_MODES); - stopSpy.mockRestore(); - captured.restore(); - driver.unregisterSignalHandlers(); - exitSpy.mockRestore(); - Object.defineProperty(process, 'platform', platformDescriptor as PropertyDescriptor); - }); - - it('restores all terminal modes when initialization fails after renderer setup', async () => { - const { presentation, modes } = makeOpenTuiPresentation({ failFooter: true }); - presentation.start(() => {}); - - await expect(presentation.ready()).rejects.toThrow('footer initialization failed'); - - expect(modes).toEqual(RESTORED_TERMINAL_MODES); - }); -}); diff --git a/apps/pythinker-code/test/tui/task-output-viewer.test.ts b/apps/pythinker-code/test/tui/task-output-viewer.test.ts index 2f53619d..733bfd00 100644 --- a/apps/pythinker-code/test/tui/task-output-viewer.test.ts +++ b/apps/pythinker-code/test/tui/task-output-viewer.test.ts @@ -1,11 +1,11 @@ -import type { Terminal } from '@earendil-works/pi-tui'; +import type { Terminal } from '@pymodel/pi-tui'; import type { BackgroundTaskInfo } from '@pymodel/pythinker-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; import { darkColors } from '@/tui/theme/colors'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } @@ -111,6 +111,17 @@ describe('TaskOutputViewer — rendering', () => { expect(out).toContain('delta'); expect(out).toContain('echo'); }); + + it('does not pass terminal controls from task output into the framed body', () => { + const rendered = makeViewer({ + output: 'Downloading wheel 25%\rDownloading wheel 75%\u001B[2Jdone', + }).render(120); + const raw = rendered.join('\n'); + + expect(raw).not.toContain('\r'); + expect(raw).not.toContain('\u001B[2J'); + expect(strip(raw)).toContain('Downloading wheel 25%Downloading wheel 75%done'); + }); }); describe('TaskOutputViewer — scrolling', () => { @@ -144,31 +155,22 @@ describe('TaskOutputViewer — scrolling', () => { expect(out).not.toContain('line-001'); }); - it.each([ - ['legacy', '\u0006'], - ['Kitty CSI-u', '\u001B[102;5u'], - ])('%s ctrl+f pages down', (_encoding, key) => { + it('Ctrl+D scrolls a page down', () => { const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); - - viewer.handleInput(key); - + viewer.handleInput('\u0004'); // Ctrl+D const out = strip(viewer.render(120).join('\n')); + // Same page size as PageDown: body has 8 viewable rows, page = 7 lines. expect(out).toContain('line-008'); expect(out).not.toContain('line-001'); }); - it.each([ - ['legacy', '\u0002'], - ['Kitty CSI-u', '\u001B[98;5u'], - ])('%s ctrl+b pages up', (_encoding, key) => { + it('Ctrl+U scrolls a page up', () => { const viewer = makeViewer({ output: bigOutput(50), rows: 12 }); - viewer.handleInput('\u001B[6~'); - - viewer.handleInput(key); - + viewer.handleInput('G'); // jump to bottom first + viewer.handleInput('\u0015'); // Ctrl+U const out = strip(viewer.render(120).join('\n')); - expect(out).toContain('line-001'); - expect(out).not.toContain('line-009'); + expect(out).toContain('line-036'); + expect(out).not.toContain('line-050'); }); it('G jumps to the bottom', () => { diff --git a/apps/pythinker-code/test/tui/tasks-browser.test.ts b/apps/pythinker-code/test/tui/tasks-browser.test.ts index 5f077b36..5ce417ce 100644 --- a/apps/pythinker-code/test/tui/tasks-browser.test.ts +++ b/apps/pythinker-code/test/tui/tasks-browser.test.ts @@ -1,5 +1,5 @@ -import type { Terminal } from '@earendil-works/pi-tui'; -import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@pymodel/pythinker-code-sdk'; +import type { Terminal } from '@pymodel/pi-tui'; +import type { BackgroundTaskInfo, BackgroundTaskStatus, Event } from '@pymodel/pythinker-code-sdk'; import { describe, expect, it, vi } from 'vitest'; import { @@ -7,10 +7,13 @@ import { type TasksBrowserProps, type TasksFilter, } from '@/tui/components/dialogs/tasks-browser'; +import { AgentActivityViewer } from '@/tui/components/dialogs/agent-activity-viewer'; +import { TaskOutputViewer } from '@/tui/components/dialogs/task-output-viewer'; +import { SubagentActivityStore } from '@/tui/controllers/subagent-activity-store'; +import { TasksBrowserController } from '@/tui/controllers/tasks-browser'; import { darkColors } from '@/tui/theme/colors'; -import { defaultKeybindings, parseKeybindingBlocks } from '#/tui/keybindings'; -const ANSI_SGR = /\u001B\[[0-9;]*m/g; +const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); } @@ -103,6 +106,28 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(big.length).toBe(40); }); + it('clamps the detail frame to the body at the minimum terminal height', () => { + const props = makeProps({ + tasks: [ + task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + status: 'running', + agentId: 'agent-1', + subagentType: 'explore', + model: 'pythinker-code/k3-256k', + thinkingEffort: 'low', + }), + ], + selectedTaskId: 'agent-aaaaaaaa', + }); + // 10 rows = the smallest terminal that still renders the full layout; the + // render must emit exactly that many lines (no overflow truncation). + const lines = new TasksBrowserApp(props, fakeTerminal(10, 120)).render(120); + expect(lines.length).toBe(10); + expect(strip(lines.join('\n'))).toContain('Preview Output'); + }); + it('shows the header row with TASK BROWSER title and counts', () => { const props: Partial<TasksBrowserProps> = { tasks: [ @@ -177,6 +202,33 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).toContain('call_question'); }); + it('shows the bound model and effort for agent tasks in the Detail pane', () => { + const out = strip( + makeApp({ + tasks: [ + task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + description: 'explore project', + agentId: 'agent-1', + subagentType: 'explore', + model: 'pythinker-code/k3-256k', + thinkingEffort: 'low', + }), + ], + selectedTaskId: 'agent-aaaaaaaa', + }) + .render(120) + .join('\n'), + ); + expect(out).toContain('Agent type:'); + expect(out).toContain('explore'); + expect(out).toContain('Model:'); + expect(out).toContain('pythinker-code/k3-256k'); + expect(out).toContain('Effort:'); + expect(out).toContain('low'); + }); + it('renders tail output in the Preview Output pane', () => { const out = strip( makeApp({ @@ -191,6 +243,19 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).toContain('listening on :3000'); }); + it('does not pass terminal controls from tail output into the framed preview', () => { + const rendered = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa' })], + selectedTaskId: 'bash-aaaaaaaa', + tailOutput: 'Downloading wheel 25%\rDownloading wheel 75%\u001B[2Jdone', + }).render(120); + const raw = rendered.join('\n'); + + expect(raw).not.toContain('\r'); + expect(raw).not.toContain('\u001B[2J'); + expect(strip(raw)).toContain('Downloading wheel 25%Downloading wheel 75%done'); + }); + it('shows a loading state when tail is loading', () => { const out = strip( makeApp({ @@ -219,6 +284,41 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).not.toContain('bash-bbbbbbbb'); }); + it('filters out foreground tasks (detached === false)', () => { + const tasks = [ + task({ taskId: 'bash-foreground', detached: false, status: 'running' }), + task({ taskId: 'bash-background', detached: true, status: 'running' }), + ]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).not.toContain('bash-foreground'); + expect(out).toContain('bash-background'); + }); + + it('keeps background tasks with detached === true even when terminal', () => { + const tasks = [task({ taskId: 'bash-done', detached: true, status: 'completed' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-done'); + }); + + it('keeps ghost tasks whose detached field is undefined', () => { + // task() leaves `detached` undefined by default, mimicking reconcile ghosts. + const tasks = [task({ taskId: 'bash-ghost', status: 'lost' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-ghost'); + }); + + it('applies active filter after excluding foreground tasks', () => { + const tasks = [ + task({ taskId: 'bash-fg-running', detached: false, status: 'running' }), + task({ taskId: 'bash-bg-running', detached: true, status: 'running' }), + task({ taskId: 'bash-bg-done', detached: true, status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).not.toContain('bash-fg-running'); + expect(out).toContain('bash-bg-running'); + expect(out).not.toContain('bash-bg-done'); + }); + it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', @@ -243,49 +343,6 @@ describe('TasksBrowserApp — full-screen rendering', () => { }); describe('TasksBrowserApp — input handling', () => { - it('deduplicates configured accept and cancel keys that match local aliases', () => { - const app = makeApp(); - app.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([ - { - context: 'Select', - bindings: { enter: null, o: 'select:accept', escape: null, q: 'select:cancel' }, - }, - ]), - ]); - - const footer = strip(app.render(120).at(-1) ?? ''); - expect(footer).toContain('O output'); - expect(footer).toContain('Q cancel'); - expect(footer).not.toMatch(/o\/O/i); - expect(footer).not.toMatch(/Q\/q/i); - }); - - it('uses remapped Select navigation, honors an unbound Down key, and keeps confirmation input local', () => { - const onSelect = vi.fn(); - const tasks = [ - task({ taskId: 'bash-aaaaaaaa', startedAt: 1 }), - task({ taskId: 'bash-bbbbbbbb', startedAt: 2 }), - ]; - const app = makeApp({ tasks, selectedTaskId: 'bash-aaaaaaaa', onSelect }); - app.setKeybindings([ - ...defaultKeybindings(), - ...parseKeybindingBlocks([{ context: 'Select', bindings: { 'alt+j': 'select:next', down: null } }]), - ]); - - app.handleInput('\u001B[B'); - expect(onSelect).not.toHaveBeenCalled(); - app.handleInput('alt+j'); - expect(onSelect).toHaveBeenLastCalledWith('bash-bbbbbbbb'); - - app.handleInput('s'); - onSelect.mockClear(); - app.handleInput('alt+j'); - expect(onSelect).not.toHaveBeenCalled(); - expect(strip(app.render(120).join('\n'))).not.toContain('Stop bash-bbbbbbbb?'); - }); - it('Esc invokes onCancel', () => { const onCancel = vi.fn(); const app = makeApp({ onCancel }); @@ -498,46 +555,124 @@ describe('TasksBrowserApp — setProps', () => { }).not.toThrow(); } }); +}); - it('reconciles a filtered-out selection with the visible task', () => { - const running = task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: 1 }); - const completed = task({ taskId: 'bash-bbbbbbbb', status: 'completed', startedAt: 2 }); - const onSelect = vi.fn(); - const app = makeApp({ - tasks: [running, completed], - selectedTaskId: completed.taskId, - }); - - app.setProps(makeProps({ - tasks: [running, completed], - filter: 'active', - selectedTaskId: completed.taskId, - tailOutput: 'stale completed output', - onSelect, - })); - - expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith(running.taskId); - const output = strip(app.render(120).join('\n')); - expect(output).toContain(running.taskId); - expect(output).not.toContain(completed.taskId); - }); - - it('clears selection when the active filter has no visible tasks', () => { - const completed = task({ taskId: 'bash-bbbbbbbb', status: 'completed' }); - const onSelect = vi.fn(); - const app = makeApp({ tasks: [completed], selectedTaskId: completed.taskId }); - - app.setProps(makeProps({ - tasks: [completed], - filter: 'active', - selectedTaskId: completed.taskId, - tailOutput: 'stale completed output', - onSelect, - })); - - expect(onSelect).toHaveBeenCalledOnce(); - expect(onSelect).toHaveBeenCalledWith(undefined); - expect(strip(app.render(120).join('\n'))).not.toContain('stale completed output'); +describe('TasksBrowserController — opening an agent task', () => { + function makeControllerHost(tasks: BackgroundTaskInfo[], store: SubagentActivityStore) { + const ui = { + children: [] as unknown[], + clear() { + this.children = []; + }, + addChild(child: unknown) { + this.children.push(child); + }, + setFocus: () => {}, + requestRender: () => {}, + }; + const state = { + tasksBrowser: undefined as unknown, + terminal: fakeTerminal(30), + ui, + editor: {}, + }; + const host = { + state, + backgroundTasks: new Map(tasks.map((t) => [t.taskId, t])), + sessionEventHandler: { subAgentEventHandler: { activityStore: store } }, + session: { + listBackgroundTasks: async () => tasks, + getBackgroundTaskOutput: async () => 'captured output', + }, + showError: vi.fn(), + setTasksBrowser(value: unknown) { + state.tasksBrowser = value; + }, + }; + return { host, state }; + } + + function agentTaskInfo(store: SubagentActivityStore | null): BackgroundTaskInfo { + const info = task({ + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent-1', + status: 'running', + } as Partial<BackgroundTaskInfo>); + if (store !== null) { + store.ensureRecord({ agentId: 'agent-1', agentName: 'explore', parentToolCallId: 'tc-1' }); + } + return info; + } + + async function openSelectedViewer(controller: TasksBrowserController, taskId: string) { + await ( + controller as unknown as { handleOpenOutput(taskId: string): Promise<void> } + ).handleOpenOutput(taskId); + } + + it('opens the activity viewer when a record exists for the agent', async () => { + const store = new SubagentActivityStore(); + const { host, state } = makeControllerHost([agentTaskInfo(store)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + await openSelectedViewer(controller, 'agent-task-1'); + + const viewer = (state.tasksBrowser as { viewer: { component: unknown } }).viewer; + expect(viewer.component).toBeInstanceOf(AgentActivityViewer); + controller.close(); + }); + + it('falls back to the output viewer when no record exists', async () => { + const store = new SubagentActivityStore(); + const { host, state } = makeControllerHost([agentTaskInfo(null)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + await openSelectedViewer(controller, 'agent-task-1'); + + const viewer = (state.tasksBrowser as { viewer: { component: unknown } }).viewer; + expect(viewer.component).toBeInstanceOf(TaskOutputViewer); + controller.close(); + }); + + it('feeds the preview pane from the activity store for agent tasks', async () => { + const store = new SubagentActivityStore(); + store.ensureRecord({ agentId: 'agent-1', agentName: 'explore', parentToolCallId: 'tc-1' }); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'turn.step.started', + turnId: 1, + step: 0, + } as Event); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'tool.call.started', + turnId: 1, + toolCallId: 't1', + name: 'Grep', + args: { pattern: 'foo' }, + } as Event); + store.applyEvent({ + sessionId: 's1', + agentId: 'agent-1', + type: 'tool.result', + turnId: 1, + toolCallId: 't1', + output: 'src/a.ts:1:foo\nsrc/b.ts:2:foo', + isError: false, + } as Event); + + const { host, state } = makeControllerHost([agentTaskInfo(null)], store); + const controller = new TasksBrowserController(host as never); + await controller.show(); + + const browser = state.tasksBrowser as { tailOutput?: string }; + expect(browser.tailOutput).toContain('── step 0 ──'); + expect(browser.tailOutput).toContain('✓ Used Grep (foo) · 2 matches'); + controller.close(); }); }); diff --git a/apps/pythinker-code/test/tui/terminal-theme.test.ts b/apps/pythinker-code/test/tui/terminal-theme.test.ts index f06313dd..3ab3f0b4 100644 --- a/apps/pythinker-code/test/tui/terminal-theme.test.ts +++ b/apps/pythinker-code/test/tui/terminal-theme.test.ts @@ -173,3 +173,18 @@ describe('ColorPalette warning token', () => { expect(getBuiltInPalette('light')).toBe(lightColors); }); }); + +describe('ColorPalette tool card tokens', () => { + it('defines distinct state tints in both themes', () => { + expect([ + darkColors.toolPendingBg, + darkColors.toolSuccessBg, + darkColors.toolErrorBg, + ]).toEqual(['#1D2129', '#14171B', '#291D1D']); + expect([ + lightColors.toolPendingBg, + lightColors.toolSuccessBg, + lightColors.toolErrorBg, + ]).toEqual(['#E8EEF7', '#F1F3F5', '#F9E9E9']); + }); +}); diff --git a/apps/pythinker-code/test/tui/theme/palette.test.ts b/apps/pythinker-code/test/tui/theme/palette.test.ts deleted file mode 100644 index ed637842..00000000 --- a/apps/pythinker-code/test/tui/theme/palette.test.ts +++ /dev/null @@ -1,576 +0,0 @@ -import { readFileSync } from 'node:fs'; - -import chalk from 'chalk'; -import { afterEach, describe, expect, it } from 'vitest'; - -import { - colorize, - currentTheme, - darkColors, - lightColors, - type ColorSpec, -} from '#/tui/theme'; - -const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/u; -const SCHEMA_HEX_PATTERN = '^#[0-9a-fA-F]{6}$'; - -const textTokens = [ - 'accent', - 'text', - 'textStrong', - 'textDim', - 'textMuted', - 'roleUser', - 'workflowTitle', - 'success', - 'warning', - 'error', - 'diffAdded', - 'diffRemoved', - 'diffAddedStrong', - 'diffRemovedStrong', - 'diffMeta', - 'diffAddedDimmed', - 'diffRemovedDimmed', -] as const; - -const effortTokens = [ - 'effortLow', - 'effortMedium', - 'effortHigh', - 'effortXHigh', - 'effortMax', -] as const; - -const chromeTokens = [ - ...effortTokens, - 'primary', - 'border', - 'borderFocus', - 'diffGutter', - 'agentRed', - 'agentOrange', - 'agentYellow', - 'agentGreen', - 'agentCyan', - 'agentBlue', - 'agentPurple', - 'agentPink', - 'rainbowRed', - 'rainbowOrange', - 'rainbowYellow', - 'rainbowGreen', - 'rainbowBlue', - 'rainbowIndigo', - 'rainbowViolet', - 'modeAutoAccept', - 'modePlan', - 'modePermission', - 'modeFast', - 'primaryShimmer', - 'accentShimmer', - 'warningShimmer', - 'borderShimmer', - 'textDimShimmer', - 'progressFill', - 'progressHead', -] as const; - -const exemptTokens = [ - 'background', - 'inverseText', - 'selectionBg', - 'surfaceHighlight', - 'toolPendingBg', - 'toolSuccessBg', - 'toolErrorBg', - 'progressEmpty', -] as const; - -const shimmerPairs = [ - ['primaryShimmer', 'primary'], - ['accentShimmer', 'accent'], - ['warningShimmer', 'warning'], - ['borderShimmer', 'border'], - ['textDimShimmer', 'textDim'], -] as const; - -const agentTokens = [ - 'agentRed', - 'agentOrange', - 'agentYellow', - 'agentGreen', - 'agentCyan', - 'agentBlue', - 'agentPurple', - 'agentPink', -] as const; - -const rainbowTokens = [ - 'rainbowRed', - 'rainbowOrange', - 'rainbowYellow', - 'rainbowGreen', - 'rainbowBlue', - 'rainbowIndigo', - 'rainbowViolet', -] as const; - -const expectedVisualDefaults = { - dark: { - primary: '#BBC6FF', - primaryShimmer: '#F4F5FF', - effortLow: '#8A8A8A', - effortMedium: '#6FA8DC', - effortHigh: '#D33682', - effortXHigh: '#C0392B', - effortMax: '#F2C744', - workflowTitle: '#EE9983', - progressFill: '#25764A', - progressHead: '#4EC87E', - progressEmpty: '#D9DEE8', - }, - light: { - effortLow: '#8A8A8A', - effortMedium: '#2E6FB8', - effortHigh: '#A81D6E', - effortXHigh: '#8B1A1A', - effortMax: '#B8860B', - workflowTitle: '#9C261C', - progressFill: '#3B9A65', - progressHead: '#0E7A38', - progressEmpty: '#6B7280', - }, -} as const; - -const existingDarkColors = { - primary: '#BBC6FF', - accent: '#7B8CE8', - text: '#E0E0E0', - textStrong: '#F5F5F5', - textDim: '#888888', - textMuted: '#6B6B6B', - border: '#5A5A5A', - borderFocus: '#E8A838', - success: '#4EC87E', - warning: '#E8A838', - error: '#E85454', - diffAdded: '#4EC87E', - diffRemoved: '#E85454', - diffAddedStrong: '#7AD99B', - diffRemovedStrong: '#F08585', - diffGutter: '#6B6B6B', - diffMeta: '#888888', - roleUser: '#FFCB6B', -} as const; - -const existingLightColors = { - primary: '#4A5BC4', - accent: '#5566CC', - text: '#1A1A1A', - textStrong: '#1A1A1A', - textDim: '#454545', - textMuted: '#5F5F5F', - border: '#737373', - borderFocus: '#92660A', - success: '#0E7A38', - warning: '#92660A', - error: '#B91C1C', - diffAdded: '#0E7A38', - diffRemoved: '#B91C1C', - diffAddedStrong: '#0E7A38', - diffRemovedStrong: '#B91C1C', - diffGutter: '#737373', - diffMeta: '#5F5F5F', - roleUser: '#9A4A00', -} as const; - -interface ThemeSchema { - properties: { - colors: { - properties: Record< - string, - { - type: string; - pattern: string; - description: string; - } - >; - }; - }; -} - -const schema = JSON.parse( - readFileSync(new URL('../../../src/tui/theme/theme-schema.json', import.meta.url), 'utf8'), -) as ThemeSchema; - -const originalPalette = currentTheme.palette; - -afterEach(() => { - currentTheme.setPalette(originalPalette); -}); - -function markdownColorTokens(url: URL): string[] { - const source = readFileSync(url, 'utf8'); - return [...source.matchAll(/^\| `([A-Za-z][A-Za-z0-9]*)` \|/gmu)] - .map((match) => match[1]) - .filter((token): token is string => token !== undefined) - .toSorted(); -} - -function documentedBuiltInColors(url: URL): Record<string, { dark: string; light: string }> { - const source = readFileSync(url, 'utf8'); - return Object.fromEntries( - [...source.matchAll( - /^\| `([A-Za-z][A-Za-z0-9]*)` \| `(#[0-9A-Fa-f]{6})` \| `(#[0-9A-Fa-f]{6})` \|/gmu, - )].flatMap((match) => { - const token = match[1]; - const dark = match[2]; - const light = match[3]; - return token === undefined || dark === undefined || light === undefined - ? [] - : [[token, { dark, light }] as const]; - }), - ); -} - -function paletteDescriptions(): Record<string, string> { - const source = readFileSync( - new URL('../../../src/tui/theme/colors.ts', import.meta.url), - 'utf8', - ); - const palette = source.match(/export interface ColorPalette \{([\s\S]*?)\n\}/u)?.[1] ?? ''; - return Object.fromEntries( - [...palette.matchAll(/\/\*\*([\s\S]*?)\*\/\s*([A-Za-z][A-Za-z0-9]*): string;/gu)].map( - ([, comment, token]) => [ - token, - comment - ?.split('\n') - .map((line) => line.replace(/^\s*\*\s?/u, '').trim()) - .join(' ') - .trim(), - ], - ), - ); -} - -function documentedDescriptions(url: URL): Record<string, string> { - const source = readFileSync(url, 'utf8'); - return Object.fromEntries( - [...source.matchAll( - /^\| `([A-Za-z][A-Za-z0-9]*)` \| (?:`#[0-9A-Fa-f]{6}` \| `#[0-9A-Fa-f]{6}` \| )?(.+) \|$/gmu, - )].map(([, token, description]) => [token, description]), - ); -} - -function channel(hex: string, start: number): number { - return Number.parseInt(hex.slice(start, start + 2), 16); -} - -function relativeLuminance(hex: string): number { - const linearize = (value: number): number => { - const srgb = value / 255; - return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; - }; - - return ( - 0.2126 * linearize(channel(hex, 1)) + - 0.7152 * linearize(channel(hex, 3)) + - 0.0722 * linearize(channel(hex, 5)) - ); -} - -function contrastRatio(first: string, second: string): number { - const firstLuminance = relativeLuminance(first); - const secondLuminance = relativeLuminance(second); - const lighter = Math.max(firstLuminance, secondLuminance); - const darker = Math.min(firstLuminance, secondLuminance); - return (lighter + 0.05) / (darker + 0.05); -} - -function meanChannelDelta(first: string, second: string): number { - return ( - (Math.abs(channel(first, 1) - channel(second, 1)) + - Math.abs(channel(first, 3) - channel(second, 3)) + - Math.abs(channel(first, 5) - channel(second, 5))) / - 3 - ); -} - -function hexToHsl(hex: string): { hue: number; saturation: number; lightness: number } { - const red = channel(hex, 1) / 255; - const green = channel(hex, 3) / 255; - const blue = channel(hex, 5) / 255; - const maximum = Math.max(red, green, blue); - const minimum = Math.min(red, green, blue); - const delta = maximum - minimum; - const lightness = (maximum + minimum) / 2; - - let hue = 0; - if (delta !== 0) { - if (maximum === red) hue = 60 * (((green - blue) / delta) % 6); - else if (maximum === green) hue = 60 * ((blue - red) / delta + 2); - else hue = 60 * ((red - green) / delta + 4); - } - if (hue < 0) hue += 360; - - const saturation = - delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1)); - return { hue, saturation: saturation * 100, lightness: lightness * 100 }; -} - -function expectPairwiseHueDistance( - palette: typeof darkColors, - tokens: readonly (keyof typeof darkColors)[], - minimumDistance: number, -): void { - for (let firstIndex = 0; firstIndex < tokens.length; firstIndex += 1) { - const firstToken = tokens[firstIndex]; - if (firstToken === undefined) throw new Error('missing first color token'); - - for (let secondIndex = firstIndex + 1; secondIndex < tokens.length; secondIndex += 1) { - const secondToken = tokens[secondIndex]; - if (secondToken === undefined) throw new Error('missing second color token'); - - const firstHue = hexToHsl(palette[firstToken]).hue; - const secondHue = hexToHsl(palette[secondToken]).hue; - const directDistance = Math.abs(firstHue - secondHue); - const circularDistance = Math.min(directDistance, 360 - directDistance); - - expect( - circularDistance, - `${String(firstToken)} and ${String(secondToken)} are only ${circularDistance.toFixed(2)}° apart`, - ).toBeGreaterThanOrEqual(minimumDistance); - } - } -} - -describe('theme palettes', () => { - it('keeps palette and schema token sets complete and synchronized', () => { - const darkTokens = Object.keys(darkColors).toSorted(); - const lightTokens = Object.keys(lightColors).toSorted(); - const schemaProperties = schema.properties.colors.properties; - - expect(darkTokens).toHaveLength(60); - expect(lightTokens).toEqual(darkTokens); - expect(Object.keys(schemaProperties).toSorted()).toEqual(darkTokens); - expect( - markdownColorTokens( - new URL('../../../../../docs/customization/themes.md', import.meta.url), - ), - ).toEqual(darkTokens); - expect( - documentedBuiltInColors( - new URL('../../../../../docs/customization/themes.md', import.meta.url), - ), - ).toEqual( - Object.fromEntries( - darkTokens.map((token) => [ - token, - { - dark: darkColors[token as keyof typeof darkColors], - light: lightColors[token as keyof typeof lightColors], - }, - ]), - ), - ); - const documentationUrls = [ - new URL('../../../../../docs/customization/themes.md', import.meta.url), - new URL( - '../../../../../packages/agent-core/src/skill/builtin/custom-theme.md', - import.meta.url, - ), - ]; - expect(markdownColorTokens(documentationUrls[1] as URL)).toEqual(darkTokens); - for (const url of documentationUrls) { - expect(documentedDescriptions(url)).toEqual(paletteDescriptions()); - } - - for (const token of darkTokens) { - const property = schemaProperties[token]; - expect(property, `${token} is missing from the theme schema`).toBeDefined(); - expect(property).toMatchObject({ - type: 'string', - pattern: SCHEMA_HEX_PATTERN, - description: expect.any(String), - }); - expect(property?.description).not.toContain('\n'); - } - }); - - it('keeps active-tab semantics and custom-theme contrast guidance synchronized', () => { - const descriptions = paletteDescriptions(); - const activeTabDescriptions = { - inverseText: - 'Foreground for active `/model` provider and `AskUserQuestion` tabs; pair with `selectionBg` at 4.5:1 contrast or higher.', - selectionBg: - 'Background for active `/model` provider and `AskUserQuestion` tabs; pair with `inverseText` at 4.5:1 contrast or higher.', - } as const; - - expect(descriptions).toMatchObject(activeTabDescriptions); - for (const [token, description] of Object.entries(activeTabDescriptions)) { - expect(schema.properties.colors.properties[token]?.description).toBe(description); - } - - const activeTabGuidance = - 'Active `/model` provider and `AskUserQuestion` tabs use `selectionBg` for the background and `inverseText` for the foreground. Keep this pair at 4.5:1 contrast or higher.'; - const runtimeGuidance = - 'The runtime validates six-digit hex syntax for each color, but it does not enforce or repair color contrast.'; - const referenceUrls = [ - new URL('../../../src/tui/theme/colors.ts', import.meta.url), - new URL('../../../src/tui/theme/theme-schema.json', import.meta.url), - new URL('../../../../../docs/customization/themes.md', import.meta.url), - new URL( - '../../../../../packages/agent-core/src/skill/builtin/custom-theme.md', - import.meta.url, - ), - new URL('../../../../../.agents/skills/write-tui/DESIGN.md', import.meta.url), - ]; - - for (const url of referenceUrls) { - const source = readFileSync(url, 'utf8') - .replaceAll(/^\s*\/\/\s?/gmu, '') - .replaceAll(/\s+/gu, ' '); - expect(source, url.pathname).toContain(activeTabGuidance); - expect(source, url.pathname).toContain(runtimeGuidance); - } - - const colorsSchema = schema.properties.colors as ThemeSchema['properties']['colors'] & { - description: string; - }; - expect(colorsSchema.description).toContain( - 'Omitted tokens fall back to the selected base palette.', - ); - }); - - it('uses the professional visual defaults', () => { - expect(darkColors).toMatchObject(expectedVisualDefaults.dark); - expect(lightColors).toMatchObject(expectedVisualDefaults.light); - }); - - it('uses progressHead with no progressShimmer fallback token', () => { - expect(darkColors).toHaveProperty('progressHead'); - expect(lightColors).toHaveProperty('progressHead'); - expect(darkColors).not.toHaveProperty('progressShimmer'); - expect(lightColors).not.toHaveProperty('progressShimmer'); - expect(schema.properties.colors.properties).toHaveProperty('progressHead'); - expect(schema.properties.colors.properties).not.toHaveProperty('progressShimmer'); - }); - - it('uses six-digit hex values in both palettes', () => { - for (const palette of [darkColors, lightColors]) { - for (const [token, value] of Object.entries(palette)) { - expect(value, token).toMatch(HEX_PATTERN); - } - } - }); - - it('meets light-palette WCAG contrast floors against white', () => { - for (const token of textTokens) { - expect(contrastRatio(lightColors[token], '#FFFFFF'), token).toBeGreaterThanOrEqual(4.5); - } - for (const token of chromeTokens) { - expect(contrastRatio(lightColors[token], '#FFFFFF'), token).toBeGreaterThanOrEqual(3); - } - }); - - it('meets the dark-palette contrast floor against black', () => { - const exempt = new Set<string>(exemptTokens); - - for (const [token, value] of Object.entries(darkColors)) { - if (exempt.has(token)) continue; - expect(contrastRatio(value, '#000000'), token).toBeGreaterThanOrEqual(3); - } - }); - - it('keeps inverse text readable on every filled surface', () => { - for (const palette of [darkColors, lightColors]) { - for (const fill of ['progressFill', 'selectionBg', 'surfaceHighlight'] as const) { - expect(contrastRatio(palette.inverseText, palette[fill]), fill).toBeGreaterThanOrEqual(4.5); - } - } - }); - - it('keeps every shimmer visibly distinct from its base token', () => { - for (const palette of [darkColors, lightColors]) { - for (const [shimmer, base] of shimmerPairs) { - expect(meanChannelDelta(palette[shimmer], palette[base]), shimmer).toBeGreaterThanOrEqual( - 16, - ); - } - } - }); - - it('keeps agent identity hues pairwise distinct', () => { - expect.hasAssertions(); - expectPairwiseHueDistance(darkColors, agentTokens, 25); - expectPairwiseHueDistance(lightColors, agentTokens, 25); - }); - - it('keeps rainbow hues pairwise distinct', () => { - expect.hasAssertions(); - expectPairwiseHueDistance(darkColors, rainbowTokens, 20); - expectPairwiseHueDistance(lightColors, rainbowTokens, 20); - }); - - it('keeps all pre-existing palette values unchanged', () => { - expect(darkColors).toMatchObject(existingDarkColors); - expect(lightColors).toMatchObject(existingLightColors); - }); -}); - -describe('colorize', () => { - it('returns identity for an undefined color', () => { - expect(colorize(undefined)('plain text')).toBe('plain text'); - }); - - it('applies raw hex foreground and background colors directly', () => { - const previousLevel = chalk.level; - chalk.level = 3; - - try { - expect(colorize('#ff0000')('text')).toBe(chalk.hex('#ff0000')('text')); - expect(colorize('#ff0000', 'background')('text')).toBe( - chalk.bgHex('#ff0000')('text'), - ); - } finally { - chalk.level = previousLevel; - } - }); - - it('resolves a palette token when the curried function is called', () => { - const previousLevel = chalk.level; - chalk.level = 3; - currentTheme.setPalette(darkColors); - const applyPrimary = colorize('primary'); - - try { - const darkOutput = applyPrimary('text'); - currentTheme.setPalette(lightColors); - const lightOutput = applyPrimary('text'); - - expect(darkOutput).toBe(chalk.hex(darkColors.primary)('text')); - expect(lightOutput).toBe(chalk.hex(lightColors.primary)('text')); - expect(lightOutput).not.toBe(darkOutput); - } finally { - chalk.level = previousLevel; - } - }); - - it('returns identity for an unknown palette token', () => { - expect(colorize('removedToken' as ColorSpec)('plain text')).toBe('plain text'); - }); - - it('ignores an unknown token retained by a custom palette', () => { - const previousLevel = chalk.level; - chalk.level = 3; - const customPalette = { ...darkColors, removedToken: '#FF0000' }; - currentTheme.setPalette(customPalette); - - try { - expect(colorize('removedToken' as ColorSpec)('plain text')).toBe('plain text'); - } finally { - chalk.level = previousLevel; - } - }); -}); diff --git a/apps/pythinker-code/test/tui/tool-intent-label.test.ts b/apps/pythinker-code/test/tui/tool-intent-label.test.ts deleted file mode 100644 index e4af7c39..00000000 --- a/apps/pythinker-code/test/tui/tool-intent-label.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Event } from '@pymodel/pythinker-code-sdk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; -import { - formatThinkingSpinnerLabel, - setLiveIntent, -} from '#/tui/constant/rendering'; -import { PythinkerTUI, type PythinkerTUIStartupInput } from '#/tui/pythinker-tui'; - -const SANITIZER_FIXTURES = [ - ['\u001B[31mred\u001B[0m', 'red'], - ['\u001B]0;title\u0007visible', 'visible'], - ['\u001B]0;title\u001B\\visible', 'visible'], - ['check\n\u0007test', 'check test'], -] as const; - -function makeStartupInput(): PythinkerTUIStartupInput { - return { - cliOptions: { - session: undefined, - continue: false, - rewindFiles: undefined, - yolo: false, - auto: false, - plan: false, - model: undefined, - outputFormat: undefined, - prompt: undefined, - skillsDirs: [], - }, - tuiConfig: { - theme: 'dark', - layout: 'inline', - copyFullResponse: false, - editorCommand: null, - notifications: { enabled: true, condition: 'unfocused' }, - upgrade: { autoInstall: true }, - statusLine: DEFAULT_STATUS_LINE_CONFIG, - }, - version: '0.0.0-test', - workDir: '/tmp/tool-intent-test', - }; -} - -afterEach(() => { - setLiveIntent(undefined); -}); - -describe('tool intent thinking label', () => { - it('uses the live intent and restores the rotating label when cleared', () => { - setLiveIntent('check failing test'); - expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); - - setLiveIntent(undefined); - expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); - }); - - it.each(SANITIZER_FIXTURES)('sanitizes intent %j', (raw, expected) => { - setLiveIntent(raw); - expect(formatThinkingSpinnerLabel(0)).toBe(`${expected}…`); - }); - - it('sets intent from a tool delta and clears it on the result', () => { - const driver = new PythinkerTUI({} as never, makeStartupInput()); - const dispatch = (event: Event): void => - driver.sessionEventHandler.handleEvent(event, vi.fn()); - - dispatch({ - type: 'tool.call.delta', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - toolCallId: 'call-1', - name: 'echo', - argumentsPart: '{"i":"check failing test","text":"hello"}', - }); - expect(formatThinkingSpinnerLabel(0)).toBe('check failing test…'); - - dispatch({ - type: 'tool.result', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - toolCallId: 'call-1', - output: 'hello', - }); - expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); - }); - - it('clears a stale intent when the next tool call has no intent', () => { - const driver = new PythinkerTUI({} as never, makeStartupInput()); - const dispatch = (event: Event): void => - driver.sessionEventHandler.handleEvent(event, vi.fn()); - - dispatch({ - type: 'tool.call.started', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - toolCallId: 'call-1', - name: 'echo', - args: {}, - intent: 'check failing test', - }); - dispatch({ - type: 'tool.call.started', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - toolCallId: 'call-2', - name: 'StructuredOutput', - args: {}, - }); - - expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); - }); - - it('clears the live intent when a step retries', () => { - const driver = new PythinkerTUI({} as never, makeStartupInput()); - const dispatch = (event: Event): void => - driver.sessionEventHandler.handleEvent(event, vi.fn()); - - dispatch({ - type: 'tool.call.started', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - toolCallId: 'call-1', - name: 'echo', - args: {}, - intent: 'check failing test', - }); - dispatch({ - type: 'turn.step.retrying', - agentId: 'main', - sessionId: 'session-1', - turnId: 1, - step: 1, - failedAttempt: 1, - nextAttempt: 2, - maxAttempts: 3, - delayMs: 100, - errorName: 'Error', - errorMessage: 'retry', - }); - - expect(formatThinkingSpinnerLabel(0)).toBe('thinking…'); - }); -}); diff --git a/apps/pythinker-code/test/tui/tui-frame.bench.ts b/apps/pythinker-code/test/tui/tui-frame.bench.ts new file mode 100644 index 00000000..9e4db900 --- /dev/null +++ b/apps/pythinker-code/test/tui/tui-frame.bench.ts @@ -0,0 +1,101 @@ +/** + * Benchmark for the TUI steady-state frame (Phase: doRender fast path). + * + * Measures the cost of one frame over a very long transcript when only a + * single line changed — the shape every spinner tick and streaming flush + * produces. Component render caches return the same string references for + * unchanged content, so doRender's processed-line reuse turns the frame into + * O(total lines) pointer comparisons plus O(changed lines) real work. A + * regression here re-introduces the per-frame full-transcript processing that + * pegged CPU in long sessions. + * + * Run: + * pnpm --filter @pymodel/pythinker-code exec vitest bench test/tui/tui-frame.bench.ts + */ + +import type { Component, Terminal } from '@pymodel/pi-tui'; +import { TuiMainScreen } from '@pymodel/pi-tui'; +import { bench, describe } from 'vitest'; + +const WIDTH = 120; +const HEIGHT = 40; +const TRANSCRIPT_LINES = 30_000; + +/** Terminal stub that discards output — we benchmark frame computation, not xterm parsing. */ +class StubTerminal implements Terminal { + /** + * Counts writes so the frame's output is an observable side effect; an + * empty write would let the JIT eliminate the whole frame as dead code. + */ + writes = 0; + start(): void {} + stop(): void {} + async drainInput(): Promise<void> {} + write(): void { + this.writes++; + } + get columns(): number { + return WIDTH; + } + get rows(): number { + return HEIGHT; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(): void {} + setProgress(): void {} +} + +/** Returns the same array reference every frame, mirroring the app's cached message components. */ +class StaticTranscript implements Component { + constructor(private readonly lines: string[]) {} + render(): string[] { + return this.lines; + } + invalidate(): void {} +} + +class SpinnerComponent implements Component { + frame = 0; + render(): string[] { + return [`⠋ working (frame ${this.frame})`]; + } + invalidate(): void {} +} + +describe('TUI steady-state frame', () => { + const terminal = new StubTerminal(); + const tui = new TuiMainScreen(terminal); + const spinner = new SpinnerComponent(); + tui.addChild( + new StaticTranscript( + Array.from( + { length: TRANSCRIPT_LINES }, + (_, i) => `transcript line ${i} — the quick brown fox jumps over the lazy dog`, + ), + ), + ); + tui.addChild(spinner); + tui.start(); + + // doRender is private; the bench drives it directly so the measurement is + // one frame's computation without the 16ms render throttle in between. + const renderFrame = (): void => { + spinner.frame++; + (tui as unknown as { doRender(): void }).doRender(); + }; + renderFrame(); + + // No teardown/stop here: bench-option hooks fire per measured iteration, + // and stopping the TUI would turn every subsequent frame into a no-op. + bench(`${TRANSCRIPT_LINES}-line transcript, one spinner line change per frame`, () => { + renderFrame(); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/cache-hint.test.ts b/apps/pythinker-code/test/tui/utils/cache-hint.test.ts new file mode 100644 index 00000000..894eea36 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/cache-hint.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import type { CacheHintConfig } from '#/utils/cache-hint-config'; +import { evaluateCacheHint, formatIdleDuration } from '#/tui/utils/cache-hint'; + +const CONFIG: CacheHintConfig = { + version: 1, + config: { + k3: { min_tokens_to_hint: 100000, cache_duration: 600 }, + }, +}; + +const NOW = 1_800_000_000_000; +const IDLE_BEYOND_TTL = NOW - 601_000; // 601s > 600s cache_duration + +function input(overrides: Partial<Parameters<typeof evaluateCacheHint>[0]> = {}) { + return { + now: NOW, + lastActiveAt: IDLE_BEYOND_TTL, + totalTokens: 150000, + modelId: 'k3', + config: CONFIG, + dismissed: false, + ...overrides, + }; +} + +describe('evaluateCacheHint', () => { + it('hints when idle exceeds cache_duration and tokens clear the threshold', () => { + expect(evaluateCacheHint(input())).toEqual({ + kind: 'hint', + idleSeconds: 601, + totalTokens: 150000, + }); + }); + + it('skips when dismissed', () => { + expect(evaluateCacheHint(input({ dismissed: true })).kind).toBe('skip'); + }); + + it.each([ + ['config', { config: undefined }], + ['modelId', { modelId: undefined }], + ['lastActiveAt', { lastActiveAt: undefined }], + ['totalTokens', { totalTokens: undefined }], + ] as const)('skips when %s is missing', (_name, overrides) => { + expect(evaluateCacheHint(input(overrides)).kind).toBe('skip'); + }); + + it('skips when the model is not in the config', () => { + expect(evaluateCacheHint(input({ modelId: 'unknown-model' })).kind).toBe('skip'); + }); + + it('skips at exactly cache_duration (strictly-greater rule)', () => { + expect( + evaluateCacheHint(input({ lastActiveAt: NOW - 600_000 })).kind, + ).toBe('skip'); + }); + + it('skips below the token threshold', () => { + expect(evaluateCacheHint(input({ totalTokens: 99999 })).kind).toBe('skip'); + }); + + it('skips on clock skew (negative idle)', () => { + expect(evaluateCacheHint(input({ lastActiveAt: NOW + 60_000 })).kind).toBe('skip'); + }); +}); + +describe('formatIdleDuration', () => { + it.each([ + [45 * 60, '45m'], + [60 * 60, '1h'], + [(3 * 60 + 20) * 60, '3h 20m'], + [24 * 60 * 60, '1d'], + [(2 * 24 + 4) * 60 * 60, '2d 4h'], + [(26 * 24 + 22) * 60 * 60, '26d 22h'], + ])('formats %ss as %s', (seconds, expected) => { + expect(formatIdleDuration(seconds)).toBe(expected); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/event-payload.test.ts b/apps/pythinker-code/test/tui/utils/event-payload.test.ts index 1d7fd418..68505a49 100644 --- a/apps/pythinker-code/test/tui/utils/event-payload.test.ts +++ b/apps/pythinker-code/test/tui/utils/event-payload.test.ts @@ -6,7 +6,6 @@ import { appendStreamingArgsPreview, formatErrorMessage, formatErrorPayload, - normalizeTodoList, parseStreamingArgs, } from '#/tui/utils/event-payload'; @@ -18,12 +17,6 @@ describe('streaming tool argument payload helpers', () => { }); }); - it('parses intent from partial streaming arguments', () => { - expect(parseStreamingArgs('{"i":"scan configs","path":"/tmp/x')).toMatchObject({ - i: 'scan configs', - }); - }); - it('caps accumulated streaming preview text', () => { const current = 'a'.repeat(STREAMING_ARGS_PREVIEW_MAX_CHARS - 2); @@ -39,36 +32,6 @@ describe('streaming tool argument payload helpers', () => { }); }); -describe('todo payload normalization', () => { - it('normalizes TodoWrite fields and clears fully completed lists', () => { - expect( - normalizeTodoList([ - { - content: 'Inspect the implementation', - activeForm: 'Inspecting the implementation', - status: 'in_progress', - }, - ]), - ).toEqual([ - { - title: 'Inspect the implementation', - activeForm: 'Inspecting the implementation', - status: 'in_progress', - }, - ]); - - expect( - normalizeTodoList([ - { - content: 'Run focused tests', - activeForm: 'Running focused tests', - status: 'completed', - }, - ]), - ).toEqual([]); - }); -}); - describe('error payload formatting', () => { const filteredThinkOnlyMessage = 'The API returned a response containing only thinking content without any text or tool calls. ' + diff --git a/apps/pythinker-code/test/tui/utils/foreground-task.test.ts b/apps/pythinker-code/test/tui/utils/foreground-task.test.ts new file mode 100644 index 00000000..20898348 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/foreground-task.test.ts @@ -0,0 +1,92 @@ +import type { BackgroundTaskInfo } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { pickForegroundTask, pickForegroundTasks } from '@/tui/utils/foreground-task'; + +function task(overrides: Partial<BackgroundTaskInfo> = {}): BackgroundTaskInfo { + return { + taskId: 'bash-aaaaaaaa', + kind: 'process', + command: 'sleep 10', + description: 'Bash: sleep 10', + status: 'running', + detached: false, + pid: 1234, + exitCode: null, + startedAt: 1000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +describe('pickForegroundTask', () => { + it('returns undefined for an empty list', () => { + expect(pickForegroundTask([])).toBeUndefined(); + }); + + it('returns undefined when all tasks are detached (already background)', () => { + expect(pickForegroundTask([task({ detached: true })])).toBeUndefined(); + }); + + it('returns undefined when foreground tasks are not running', () => { + expect(pickForegroundTask([task({ status: 'completed' })])).toBeUndefined(); + expect(pickForegroundTask([task({ status: 'killed' })])).toBeUndefined(); + }); + + it('excludes question tasks', () => { + const question = task({ + kind: 'question', + questionCount: 1, + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([question])).toBeUndefined(); + }); + + it('returns the most recently started foreground running task', () => { + const older = task({ taskId: 'bash-old', startedAt: 1000 }); + const newer = task({ taskId: 'bash-new', startedAt: 2000 }); + expect(pickForegroundTask([older, newer])?.taskId).toBe('bash-new'); + }); + + it('ignores detached running tasks even if newer', () => { + const fg = task({ taskId: 'bash-fg', detached: false, startedAt: 1000 }); + const bg = task({ taskId: 'bash-bg', detached: true, startedAt: 9999 }); + expect(pickForegroundTask([bg, fg])?.taskId).toBe('bash-fg'); + }); + + it('accepts agent (subagent) foreground tasks', () => { + const agent = task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + agentId: 'child-1', + subagentType: 'coder', + } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTask([agent])?.taskId).toBe('agent-aaaaaaaa'); + }); +}); + +describe('pickForegroundTasks', () => { + it('returns all foreground running tasks, most recently started first', () => { + const a = task({ taskId: 'bash-a', startedAt: 1000 }); + const b = task({ taskId: 'agent-b', kind: 'agent', startedAt: 3000 }); + const c = task({ taskId: 'bash-c', startedAt: 2000 }); + expect(pickForegroundTasks([a, b, c]).map((t) => t.taskId)).toEqual([ + 'agent-b', + 'bash-c', + 'bash-a', + ]); + }); + + it('excludes detached, terminal, and question tasks', () => { + const fg = task({ taskId: 'bash-fg' }); + const detached = task({ taskId: 'bash-bg', detached: true }); + const done = task({ taskId: 'bash-done', status: 'completed' }); + const question = task({ taskId: 'q', kind: 'question' } as Partial<BackgroundTaskInfo>); + expect(pickForegroundTasks([fg, detached, done, question]).map((t) => t.taskId)).toEqual([ + 'bash-fg', + ]); + }); + + it('returns an empty array when nothing matches', () => { + expect(pickForegroundTasks([task({ detached: true })])).toEqual([]); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/inline-skill-tokens.test.ts b/apps/pythinker-code/test/tui/utils/inline-skill-tokens.test.ts new file mode 100644 index 00000000..d307a561 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/inline-skill-tokens.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import { + extractInlineSkillActivations, + findInlineSkillTokens, +} from '#/tui/utils/inline-skill-tokens'; + +const SKILL_COMMAND_MAP = new Map([ + ['skill:review', 'review'], + ['skill:security', 'security'], + ['commit', 'commit'], +]); + +function findAll(text: string, includeLeading = false) { + return findInlineSkillTokens(text, { + isKnownSkill: (name) => SKILL_COMMAND_MAP.has(name) || SKILL_COMMAND_MAP.has(`skill:${name}`), + includeLeading, + }); +} + +describe('findInlineSkillTokens', () => { + it('finds tokens preceded by whitespace in first-occurrence order', () => { + expect(findAll('please /skill:review and /skill:security this')).toEqual([ + { commandName: 'skill:review', start: 7, end: 20 }, + { commandName: 'skill:security', start: 25, end: 40 }, + ]); + }); + + it('skips the leading slash-command area by default', () => { + expect(findAll('/skill:review')).toEqual([]); + expect(findAll('/skill:review')).toHaveLength(0); + expect(findAll('/skill:review', true)).toEqual([ + { commandName: 'skill:review', start: 0, end: 13 }, + ]); + }); + + it('finds tokens after the leading command and its arguments', () => { + expect(findAll('/skill:review some args /skill:security')).toEqual([ + { commandName: 'skill:security', start: 24, end: 39 }, + ]); + }); + + it('treats a newline as whitespace, so multi-line prompts work', () => { + expect(findAll('first line\n/skill:review more')).toEqual([ + { commandName: 'skill:review', start: 11, end: 24 }, + ]); + }); + + it('ignores slashes inside words, paths, and URLs', () => { + expect(findAll('and/or')).toEqual([]); + expect(findAll('see /tmp/file and https://example.com/a')).toEqual([]); + expect(findAll('1/2')).toEqual([]); + }); + + it('ignores unknown command names', () => { + expect(findAll('hello /not-a-skill world')).toEqual([]); + }); +}); + +describe('extractInlineSkillActivations', () => { + it('resolves command names to skill names, deduped in first-occurrence order', () => { + expect( + extractInlineSkillActivations( + '/skill:review then /skill:review again /skill:security', + SKILL_COMMAND_MAP, + { includeLeading: true }, + ), + ).toEqual([{ skillName: 'review' }, { skillName: 'security' }]); + }); + + it('supports the skill: prefix fallback for bare names', () => { + expect(extractInlineSkillActivations('hello /review', SKILL_COMMAND_MAP)).toEqual([ + { skillName: 'review' }, + ]); + }); + + it('keeps builtin skill command names as-is', () => { + expect(extractInlineSkillActivations('please /commit this', SKILL_COMMAND_MAP)).toEqual([ + { skillName: 'commit' }, + ]); + }); + + it('returns an empty list when nothing matches', () => { + expect(extractInlineSkillActivations('no tokens here', SKILL_COMMAND_MAP)).toEqual([]); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/input-latency.test.ts b/apps/pythinker-code/test/tui/utils/input-latency.test.ts new file mode 100644 index 00000000..f28b0ea0 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/input-latency.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'vitest'; +import { LatencyStats } from '#/tui/utils/input-latency'; + +describe('LatencyStats (input→render probe)', () => { + test('empty stats render the hint line', () => { + expect(new LatencyStats().formatLines()).toEqual([' input→render: (type something) ']); + }); + + test('records counters, percentiles, max and the worst-five window', () => { + const s = new LatencyStats(); + for (const v of [5, 12, 8, 150, 400, 30, 1200, 60, 22, 95]) s.record(v, 't'); + expect(s.events).toBe(10); + expect(s.last).toBe(95); + expect(s.over100).toBe(3); // 150, 400, 1200 + expect(s.over300).toBe(2); // 400, 1200 + expect(s.over1000).toBe(1); // 1200 + expect(s.max()).toBe(1200); + expect(s.percentile(50)).toBe(30); // sorted: 5 8 12 22 [30] 60 95 150 400 1200 + expect(s.worst.map((w) => w.latency)).toEqual([1200, 400, 150, 95, 60]); + }); + + test('rolling window drops old samples', () => { + const s = new LatencyStats(); + for (let i = 0; i < 600; i++) s.record(1, 't'); + s.record(999, 't'); + expect(s.events).toBe(601); + expect(s.max()).toBe(999); // the window still holds the latest 500 + expect(s.percentile(99)).toBe(1); // 499 ones + one 999: p99 sits on the ones + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts index ff8306d1..b4c9f264 100644 --- a/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts +++ b/apps/pythinker-code/test/tui/utils/refresh-providers.test.ts @@ -1,15 +1,12 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - - +import { + PYTHINKER_CODE_PROVIDER_NAME, + resolvePythinkerCodeOAuthKey, + resolvePythinkerCodeOAuthRef, +} from '@pymodel/pythinker-code-oauth'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { refreshAllProviderModels } from '../../../src/tui/utils/refresh-providers'; -import { - createPythinkerHarness, - type PythinkerConfig, -} from '@pymodel/pythinker-code-sdk'; +import type { PythinkerConfig } from '@pymodel/pythinker-code-sdk'; type FetchMock = ( input: Parameters<typeof fetch>[0], @@ -26,7 +23,6 @@ function makeRefreshHost(initial: PythinkerConfig): { current: () => PythinkerConfig; removeProvider: ReturnType<typeof vi.fn<(providerId: string) => Promise<PythinkerConfig>>>; setConfig: ReturnType<typeof vi.fn<(patch: Partial<PythinkerConfig>) => Promise<PythinkerConfig>>>; - replaceConfig: ReturnType<typeof vi.fn<(config: PythinkerConfig) => Promise<PythinkerConfig>>>; } { let persisted = structuredClone(initial); const removeProvider = vi.fn(async (providerId: string) => { @@ -47,15 +43,10 @@ function makeRefreshHost(initial: PythinkerConfig): { persisted = { ...persisted, ...patch }; return structuredClone(persisted); }); - const replaceConfig = vi.fn(async (config: PythinkerConfig) => { - persisted = structuredClone(config); - return structuredClone(persisted); - }); return { current: () => structuredClone(persisted), removeProvider, setConfig, - replaceConfig, }; } @@ -65,44 +56,56 @@ describe('refreshAllProviderModels', () => { vi.unstubAllGlobals(); }); - - - it('refreshes the OpenAI Codex provider under scope oauth and drops a default model the refresh removed', async () => { - const host = makeRefreshHost({ + it('refreshes managed Pythinker Code against environment endpoints over persisted config', async () => { + const configuredBaseUrl = 'https://api.configured.example.test/coding/v1'; + const envBaseUrl = 'https://api.env.example.test/coding/v1'; + const envOauthHost = 'https://auth.env.example.test'; + const configuredOauthKey = resolvePythinkerCodeOAuthKey({ baseUrl: configuredBaseUrl }); + const envOauthRef = resolvePythinkerCodeOAuthRef({ + oauthHost: envOauthHost, + baseUrl: envBaseUrl, + }); + const config: PythinkerConfig = { providers: { - 'openai-codex': { - type: 'openai_responses', - baseUrl: 'https://chatgpt.com/backend-api/codex', - apiKey: 'codex-access-token', - customHeaders: { 'chatgpt-account-id': 'acct-1' }, - source: { auth: 'openai-codex-oauth', accountId: 'acct-1', refreshToken: 'codex-refresh' }, + [PYTHINKER_CODE_PROVIDER_NAME]: { + type: 'pythinker', + baseUrl: configuredBaseUrl, + apiKey: '', + oauth: { + storage: 'file', + key: configuredOauthKey, + oauthHost: 'https://auth.kimi.com', + }, }, - manual: { type: 'openai', baseUrl: 'https://manual.example.test/v1', apiKey: 'sk-manual' }, }, models: { - 'openai-codex/gone': { - provider: 'openai-codex', - model: 'gone', - maxContextSize: 128_000, - capabilities: ['tool_use'], - }, - 'manual/kept': { - provider: 'manual', - model: 'kept', - maxContextSize: 8_000, - capabilities: ['tool_use'], + 'pythinker-code/kimi-for-coding': { + provider: PYTHINKER_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], }, }, - defaultModel: 'openai-codex/gone', - defaultThinking: true, - } as unknown as PythinkerConfig); - - const fetchMock = vi.fn<FetchMock>(async (input) => { - expect(fetchInputUrl(input)).toContain('/models?client_version='); + defaultModel: 'pythinker-code/kimi-for-coding', + telemetry: true, + }; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', envBaseUrl); + vi.stubEnv('PYTHINKER_CODE_OAUTH_HOST', envOauthHost); + const resolveOAuthToken = vi.fn(async (_providerName, oauthRef) => { + expect(oauthRef).toEqual(envOauthRef); + return 'env-access-token'; + }); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${envBaseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer env-access-token'); return new Response( JSON.stringify({ - models: [ - { id: 'gpt-5-codex', context_length: 272_000, supports_reasoning: true }, + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + }, ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, @@ -110,290 +113,103 @@ describe('refreshAllProviderModels', () => { }); vi.stubGlobal('fetch', fetchMock); - const result = await refreshAllProviderModels( - { - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - }, - { scope: 'oauth' }, - ); + const result = await refreshAllProviderModels({ + getConfig: async () => config, + removeProvider: vi.fn(), + setConfig: vi.fn(), + resolveOAuthToken, + }); expect(result.failed).toEqual([]); - expect(result.changed).toEqual([ - { providerId: 'openai-codex', providerName: 'OpenAI Codex (OAuth)', added: 1, removed: 1 }, - ]); - // scope 'oauth' must not touch the hand-written provider. - expect(host.current().providers['manual']).toMatchObject({ apiKey: 'sk-manual' }); - expect(host.current().models?.['manual/kept']).toBeDefined(); - // The old default alias is gone. The refresh re-points the selection at the - // model it just fetched rather than clearing it, so the session still has a - // model to run on. - expect(host.current().models?.['openai-codex/gone']).toBeUndefined(); - expect(host.current().defaultModel).toBe('openai-codex/gpt-5-codex'); + expect(result.unchanged).toEqual([PYTHINKER_CODE_PROVIDER_NAME]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(resolveOAuthToken).toHaveBeenCalledWith(PYTHINKER_CODE_PROVIDER_NAME, envOauthRef); }); - it('refreshes catalog-backed providers once per models.dev source', async () => { - const catalogUrl = 'https://catalog.example.test/api.json'; - const source = { kind: 'modelsDev', url: catalogUrl }; - const host = makeRefreshHost({ + it('can refresh only the managed OAuth provider without fetching third-party registries', async () => { + const baseUrl = 'https://api.example.test/coding/v1'; + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const config: PythinkerConfig = { providers: { - deepseek: { - type: 'openai', - baseUrl: 'https://old.deepseek.example.test/v1', - apiKey: 'legacy-token', - apiKeyEnvVar: 'DEEPSEEK_API_KEY', - customHeaders: { 'x-account': 'account-1' }, - source, + [PYTHINKER_CODE_PROVIDER_NAME]: { + type: 'pythinker', + baseUrl, + apiKey: '', + oauth: { + storage: 'file', + key: resolvePythinkerCodeOAuthKey({ baseUrl }), + }, }, - moonshotai: { + custom: { type: 'openai', - baseUrl: 'https://api.moonshot.ai/v1', - apiKeyEnvVar: 'MOONSHOT_API_KEY', - source, + baseUrl: 'https://custom.example.test/v1', + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, }, }, models: { - 'deepseek/legacy': { - provider: 'deepseek', - model: 'legacy', - maxContextSize: 64_000, + 'pythinker-code/kimi-for-coding': { + provider: PYTHINKER_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, capabilities: ['thinking', 'tool_use'], + displayName: 'Old Pythinker', }, - 'moonshotai/kimi-k3': { - provider: 'moonshotai', - model: 'kimi-k3', - maxContextSize: 262_144, - capabilities: ['thinking', 'tool_use'], + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'Custom M1', }, }, - defaultModel: 'deepseek/legacy', - defaultThinking: true, - } as unknown as PythinkerConfig); - const fetchMock = vi.fn<FetchMock>(async (input) => { - expect(fetchInputUrl(input)).toBe(catalogUrl); + defaultModel: 'pythinker-code/kimi-for-coding', + telemetry: true, + }; + const host = makeRefreshHost(config); + const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer oauth-access-token'); return new Response( JSON.stringify({ - deepseek: { - id: 'deepseek', - name: 'DeepSeek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - models: { - v4: { - id: 'deepseek-v4-pro', - limit: { context: 1_000_000, output: 384_000 }, - reasoning: true, - reasoning_options: [{ type: 'effort', values: ['high', 'max'] }], - }, - }, - }, - moonshotai: { - id: 'moonshotai', - name: 'Moonshot AI', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.moonshot.ai/v1', - models: { - kimi: { - id: 'kimi-k3', - limit: { context: 262_144, output: 262_144 }, - reasoning: true, - reasoning_options: [ - { type: 'effort', values: ['low', 'high', 'max'] }, - ], - }, + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Pythinker', }, - }, + ], }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); }); vi.stubGlobal('fetch', fetchMock); - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - }); - - expect(result.failed).toEqual([]); - expect(result.changed).toEqual([ - { providerId: 'deepseek', providerName: 'DeepSeek', added: 1, removed: 1 }, - { providerId: 'moonshotai', providerName: 'Moonshot AI', added: 0, removed: 0 }, - ]); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.current().providers['deepseek']).toMatchObject({ - apiKeyEnvVar: 'DEEPSEEK_API_KEY', - baseUrl: 'https://api.deepseek.com', - customHeaders: { 'x-account': 'account-1' }, - source, - }); - expect(host.current().providers['deepseek']?.apiKey).toBe('legacy-token'); - expect(host.current().models?.['deepseek/deepseek-v4-pro']).toMatchObject({ - supportEfforts: ['high', 'max'], - capabilities: ['thinking', 'tool_use', 'always_thinking'], - }); - expect(host.current().models?.['moonshotai/kimi-k3']).toMatchObject({ - supportEfforts: ['low', 'high', 'max'], - }); - expect(host.current().defaultModel).toBe('deepseek/deepseek-v4-pro'); - expect(host.current().defaultThinking).toBe(true); - }); - - it('refreshes a catalog provider with a registered inline credential', async () => { - const catalogUrl = 'https://catalog.example.test/api.json'; - const host = makeRefreshHost({ - providers: { - deepseek: { - type: 'openai', - apiKey: 'legacy-token', - source: { kind: 'modelsDev', url: catalogUrl }, - }, - }, - models: { - 'deepseek/old': { - provider: 'deepseek', - model: 'old', - maxContextSize: 64_000, - }, + const result = await refreshAllProviderModels( + { + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken, }, - } as PythinkerConfig); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response( - JSON.stringify({ - deepseek: { - id: 'deepseek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - models: { next: { id: 'next', limit: { context: 128_000 } } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ), + { scope: 'oauth' }, ); - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - }); - expect(result.failed).toEqual([]); expect(result.changed).toEqual([ { - providerId: 'deepseek', - providerName: 'deepseek', - added: 1, - removed: 1, - }, - ]); - expect(host.removeProvider).toHaveBeenCalledWith('deepseek'); - expect(host.current().providers['deepseek']).toMatchObject({ - type: 'openai', - baseUrl: 'https://api.deepseek.com', - apiKey: 'legacy-token', - source: { kind: 'modelsDev', url: catalogUrl }, - }); - expect(host.current().providers['deepseek']?.apiKeyEnvVar).toBeUndefined(); - expect(host.current().models?.['deepseek/next']).toBeDefined(); - }); - - it('reports a catalog provider with no registered credential', async () => { - const catalogUrl = 'https://catalog.example.test/api.json'; - const host = makeRefreshHost({ - providers: { - deepseek: { - type: 'openai', - source: { kind: 'modelsDev', url: catalogUrl }, - }, - }, - models: {}, - } as PythinkerConfig); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response( - JSON.stringify({ - deepseek: { - id: 'deepseek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - models: { next: { id: 'next', limit: { context: 128_000 } } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ), - ); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - }); - - expect(result.failed).toEqual([ - { - provider: 'deepseek', - reason: 'Catalog provider "deepseek" has no registered API key credential.', + providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerName: 'Pythinker Code', + added: 0, + removed: 0, }, ]); - expect(host.removeProvider).not.toHaveBeenCalled(); - }); - - it('does not choose a default when an unselected catalog provider refreshes', async () => { - const catalogUrl = 'https://catalog.example.test/api.json'; - const host = makeRefreshHost({ - providers: { - deepseek: { - type: 'openai', - apiKeyEnvVar: 'DEEPSEEK_API_KEY', - source: { kind: 'modelsDev', url: catalogUrl }, - }, - }, - models: { - 'deepseek/old': { - provider: 'deepseek', - model: 'old', - maxContextSize: 64_000, - capabilities: ['tool_use'], - }, - }, - } as unknown as PythinkerConfig); - vi.stubGlobal( - 'fetch', - vi.fn(async () => - new Response( - JSON.stringify({ - deepseek: { - id: 'deepseek', - npm: '@ai-sdk/openai-compatible', - api: 'https://api.deepseek.com', - models: { next: { id: 'next', limit: { context: 128_000 } } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - ), - ); - - const result = await refreshAllProviderModels({ - getConfig: async () => host.current(), - removeProvider: host.removeProvider, - setConfig: host.setConfig, - replaceConfig: host.replaceConfig, - }); - - expect(result.changed).toHaveLength(1); - expect(host.current().defaultModel).toBeUndefined(); - expect(host.current().defaultThinking).toBeUndefined(); + expect(result.unchanged).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.current().models?.['pythinker-code/kimi-for-coding']?.displayName).toBe('Fresh Pythinker'); + expect(host.current().models?.['custom/m1']?.displayName).toBe('Custom M1'); }); it('refreshes custom-registry model capabilities even when model ids are unchanged', async () => { @@ -494,7 +310,7 @@ describe('refreshAllProviderModels', () => { getConfig: async () => host.current(), removeProvider: host.removeProvider, setConfig: host.setConfig, - replaceConfig: host.replaceConfig, + resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -513,9 +329,9 @@ describe('refreshAllProviderModels', () => { removed: 0, }, ]); - expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.replaceConfig).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith(providerId); + expect(host.removeProvider).toHaveBeenCalledWith(siblingProviderId); + expect(host.setConfig).toHaveBeenCalledTimes(1); expect(host.current().models?.[modelAlias]?.capabilities).toEqual([ 'tool_use', 'thinking', @@ -585,7 +401,7 @@ describe('refreshAllProviderModels', () => { getConfig: async () => host.current(), removeProvider: host.removeProvider, setConfig: host.setConfig, - replaceConfig: host.replaceConfig, + resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -600,8 +416,7 @@ describe('refreshAllProviderModels', () => { ]); expect(fetchMock).toHaveBeenCalledTimes(1); expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.replaceConfig).toHaveBeenCalledTimes(1); + expect(host.setConfig).toHaveBeenCalledTimes(1); expect(Object.keys(host.current().providers).toSorted()).toEqual(['a', 'b']); expect(host.current().providers['b']).toMatchObject({ type: 'openai', @@ -660,9 +475,8 @@ describe('refreshAllProviderModels', () => { displayName: 'My B', }, }, - defaultProvider: 'b', defaultModel: 'my-b', - defaultThinking: true, + thinking: { enabled: true }, telemetry: true, } as unknown as PythinkerConfig); @@ -688,7 +502,7 @@ describe('refreshAllProviderModels', () => { getConfig: async () => host.current(), removeProvider: host.removeProvider, setConfig: host.setConfig, - replaceConfig: host.replaceConfig, + resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -702,120 +516,14 @@ describe('refreshAllProviderModels', () => { }, ]); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.replaceConfig).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); expect(Object.keys(host.current().providers)).toEqual(['a']); expect(host.current().models?.['a/m1']).toBeDefined(); expect(host.current().models?.['b/m1']).toBeUndefined(); expect(host.current().models?.['my-b']).toBeUndefined(); - expect(host.current().defaultProvider).toBeUndefined(); expect(host.current().defaultModel).toBeUndefined(); - expect(host.current().defaultThinking).toBeUndefined(); - }); - - it('persists custom-registry removals and cleared defaults across harness restart', async () => { - const homeDir = await mkdtemp(join(tmpdir(), 'pythinker-custom-refresh-')); - const registryUrl = 'https://registry.example.test/v1/models/api.json'; - const apiKey = 'sk-test-token'; - const configPath = join(homeDir, 'config.toml'); - await writeFile(configPath, ` -telemetry = true -theme = "dark" -default_provider = "b" -default_model = "my-b" -default_thinking = true - -[notifications] -claim_stale_after_ms = 15000 - -[providers.a] -type = "openai" -base_url = "https://a.example.test/v1" -api_key = "${apiKey}" -source = { kind = "apiJson", url = "${registryUrl}", apiKey = "${apiKey}" } - -[providers.b] -type = "openai" -base_url = "https://b.example.test/v1" -api_key = "${apiKey}" -source = { kind = "apiJson", url = "${registryUrl}", apiKey = "${apiKey}" } - -[providers.keep] -type = "openai" -base_url = "https://keep.example.test/v1" -api_key = "sk-keep" - -[models."a/m1"] -provider = "a" -model = "m1" -max_context_size = 131072 - -[models."b/m1"] -provider = "b" -model = "m1" -max_context_size = 131072 - -[models.my-b] -provider = "b" -model = "m1" -max_context_size = 131072 - -[models."keep/model"] -provider = "keep" -model = "model" -max_context_size = 64000 -`, 'utf8'); - - vi.stubGlobal('fetch', vi.fn<FetchMock>(async () => - new Response( - JSON.stringify({ - a: { - id: 'a', - name: 'Provider A', - api: 'https://a.example.test/v1', - type: 'openai', - models: { m1: { id: 'm1' } }, - }, - }), - { status: 200, headers: { 'Content-Type': 'application/json' } }, - ), - )); - - let harness = createPythinkerHarness({ homeDir }); - try { - const result = await refreshAllProviderModels({ - getConfig: () => harness.getConfig({ reload: true }), - removeProvider: (providerId) => harness.removeProvider(providerId), - setConfig: (patch) => harness.setConfig(patch), - replaceConfig: (config) => harness.replaceConfig(config), - }); - expect(result.failed).toEqual([]); - expect(result.changed).toContainEqual({ - providerId: 'b', - providerName: 'b', - added: 0, - removed: 1, - }); - - await harness.close(); - harness = createPythinkerHarness({ homeDir }); - const persisted = await harness.getConfig({ reload: true }); - expect(persisted.providers['b']).toBeUndefined(); - expect(persisted.models?.['b/m1']).toBeUndefined(); - expect(persisted.models?.['my-b']).toBeUndefined(); - expect(persisted.defaultProvider).toBeUndefined(); - expect(persisted.defaultModel).toBeUndefined(); - expect(persisted.defaultThinking).toBeUndefined(); - expect(persisted.providers['keep']).toBeDefined(); - expect(persisted.models?.['keep/model']).toBeDefined(); - expect(persisted.telemetry).toBe(true); - expect(persisted.raw?.['theme']).toBe('dark'); - expect(persisted.raw?.['notifications']).toEqual({ claim_stale_after_ms: 15000 }); - } finally { - await harness.close(); - await rm(homeDir, { recursive: true, force: true }); - } + expect(host.current().thinking).toBeUndefined(); }); it('coalesces duplicate custom-registry source URLs without reporting config-only changes', async () => { @@ -892,7 +600,7 @@ max_context_size = 64000 getConfig: async () => host.current(), removeProvider: host.removeProvider, setConfig: host.setConfig, - replaceConfig: host.replaceConfig, + resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -906,9 +614,9 @@ max_context_size = 64000 }, ]); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(host.removeProvider).not.toHaveBeenCalled(); - expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.replaceConfig).toHaveBeenCalledTimes(1); + expect(host.removeProvider).toHaveBeenCalledWith('a'); + expect(host.removeProvider).toHaveBeenCalledWith('b'); + expect(host.setConfig).toHaveBeenCalledTimes(1); expect(host.current().providers['a']?.source).toEqual(newSource); expect(host.current().providers['b']?.source).toEqual(newSource); expect(host.current().providers['a']?.apiKey).toBe('sk-new-token'); @@ -956,7 +664,7 @@ max_context_size = 64000 [userAlias]: userAliasModel, }, defaultModel: userAlias, - defaultThinking: false, + thinking: { enabled: false }, telemetry: true, } as unknown as PythinkerConfig); @@ -991,7 +699,7 @@ max_context_size = 64000 getConfig: async () => host.current(), removeProvider: host.removeProvider, setConfig: host.setConfig, - replaceConfig: host.replaceConfig, + resolveOAuthToken: vi.fn(), }); expect(result.failed).toEqual([]); @@ -999,10 +707,547 @@ max_context_size = 64000 expect(result.unchanged).toEqual([providerId]); expect(host.removeProvider).not.toHaveBeenCalled(); expect(host.setConfig).not.toHaveBeenCalled(); - expect(host.replaceConfig).not.toHaveBeenCalled(); expect(host.current().models?.[userAlias]).toEqual(userAliasModel); expect(host.current().defaultModel).toBe(userAlias); - expect(host.current().defaultThinking).toBe(false); + expect(host.current().thinking?.enabled).toBe(false); + }); + + it('forces default thinking on when the refreshed default model cannot disable thinking', async () => { + const host = makeRefreshHost({ + providers: { + [PYTHINKER_CODE_PROVIDER_NAME]: { + type: 'pythinker', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/pythinker-code' }, + }, + }, + models: { + 'pythinker-code/pythinker-deep-coder': { + provider: PYTHINKER_CODE_PROVIDER_NAME, + model: 'pythinker-deep-coder', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + defaultModel: 'pythinker-code/pythinker-deep-coder', + thinking: { enabled: false }, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'pythinker-deep-coder', + context_length: 262144, + supports_reasoning: true, + supports_thinking_type: 'only', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(async () => 'oauth-access-token'), + }); + + expect(result.failed).toEqual([]); + expect(host.current().models?.['pythinker-code/pythinker-deep-coder']?.capabilities).toEqual([ + 'thinking', + 'always_thinking', + 'tool_use', + ]); + expect(host.current().defaultModel).toBe('pythinker-code/pythinker-deep-coder'); + expect(host.current().thinking?.enabled).toBe(true); }); + it('refreshes a hand-configured API-key provider pointing at the managed endpoint', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const userAliasModel = { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'My Coding', + }; + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-pythinker/kimi-for-coding': { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Pythinker', + }, + 'my-pythinker/pythinker-old': { + provider: 'my-pythinker', + model: 'pythinker-old', + maxContextSize: 131072, + capabilities: ['tool_use'], + }, + 'my-fav': userAliasModel, + }, + defaultModel: 'my-pythinker/kimi-for-coding', + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-distributed-key'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Pythinker', + }, + { id: 'kimi-k2', context_length: 131072 }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { providerId: 'my-pythinker', providerName: 'my-pythinker', added: 1, removed: 1 }, + ]); + // The provider record is user-owned and must survive untouched. + expect(host.current().providers['my-pythinker']).toEqual({ + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }); + // Upstream-owned fields merge; the dropped model disappears; the new one appears. + expect(host.current().models?.['my-pythinker/kimi-for-coding']?.displayName).toBe('Fresh Pythinker'); + expect(host.current().models?.['my-pythinker/kimi-for-coding']?.capabilities).toEqual([ + 'thinking', + 'tool_use', + ]); + expect(host.current().models?.['my-pythinker/kimi-k2']).toBeDefined(); + expect(host.current().models?.['my-pythinker/pythinker-old']).toBeUndefined(); + // Non-prefix user aliases and the default selection are preserved. + expect(host.current().models?.['my-fav']).toEqual(userAliasModel); + expect(host.current().defaultModel).toBe('my-pythinker/kimi-for-coding'); + }); + + it('resolves the API key from the provider env sub-table when api_key is empty', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl, + apiKey: '', + env: { PYTHINKER_API_KEY: 'sk-env-key' }, + }, + }, + models: { + 'my-pythinker/kimi-for-coding': { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['thinking', 'tool_use'], + }, + }, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-env-key'); + return new Response( + JSON.stringify({ + data: [{ id: 'kimi-for-coding', context_length: 262144, supports_reasoning: true }], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['my-pythinker']); + expect(host.setConfig).not.toHaveBeenCalled(); + }); + + it('matches the managed endpoint even with a trailing slash on the configured baseUrl', async () => { + vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl: 'https://api.managed.example.test/coding/v1/', + apiKey: 'sk-distributed-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>(async (input) => { + expect(fetchInputUrl(input)).toBe('https://api.managed.example.test/coding/v1/models'); + return new Response( + JSON.stringify({ data: [{ id: 'kimi-for-coding', context_length: 262144 }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { providerId: 'my-pythinker', providerName: 'my-pythinker', added: 1, removed: 0 }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not refresh API-key providers pointing at non-managed endpoints', async () => { + vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://api.managed.example.test/coding/v1'); + const host = makeRefreshHost({ + providers: { + gateway: { + type: 'pythinker', + baseUrl: 'https://gateway.example.test/v1', + apiKey: 'sk-gateway-key', + }, + 'pymodel-lookalike': { + type: 'pythinker', + baseUrl: 'https://api.moonshot.cn/v1', + apiKey: 'sk-platform-key', + }, + }, + models: {}, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>(); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes a hand-written managed:pythinker-code provider that uses an API key instead of OAuth', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + [PYTHINKER_CODE_PROVIDER_NAME]: { + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'pythinker-code/kimi-for-coding': { + provider: PYTHINKER_CODE_PROVIDER_NAME, + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Pythinker', + }, + }, + defaultModel: 'pythinker-code/kimi-for-coding', + telemetry: true, + } as unknown as PythinkerConfig); + + const resolveOAuthToken = vi.fn(async () => 'oauth-access-token'); + const fetchMock = vi.fn<FetchMock>(async (input, init) => { + expect(fetchInputUrl(input)).toBe(`${baseUrl}/models`); + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer sk-distributed-key'); + return new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Pythinker', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken, + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toEqual([ + { + providerId: PYTHINKER_CODE_PROVIDER_NAME, + providerName: PYTHINKER_CODE_PROVIDER_NAME, + added: 0, + removed: 0, + }, + ]); + // The OAuth branch must not run: no token resolution, and the provider + // record keeps the user's API-key shape (no oauth ref, no apiKey reset). + expect(resolveOAuthToken).not.toHaveBeenCalled(); + expect(host.current().providers[PYTHINKER_CODE_PROVIDER_NAME]).toEqual({ + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }); + expect(host.current().services).toBeUndefined(); + expect(host.current().models?.['pythinker-code/kimi-for-coding']?.displayName).toBe('Fresh Pythinker'); + expect(host.current().defaultModel).toBe('pythinker-code/kimi-for-coding'); + }); + + it('reports a failed refresh and keeps config when the managed endpoint rejects the API key', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl, + apiKey: 'sk-revoked-key', + }, + }, + models: { + 'my-pythinker/kimi-for-coding': { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response(JSON.stringify({ error: { message: 'invalid key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.changed).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]?.provider).toBe('my-pythinker'); + expect(result.failed[0]?.reason).toContain('the API key'); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-pythinker/kimi-for-coding']).toBeDefined(); + }); + + it('skips the API-key refresh when the managed endpoint returns no models', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-pythinker/kimi-for-coding': { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + }, + }, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result).toEqual({ changed: [], unchanged: [], failed: [] }); + expect(host.setConfig).not.toHaveBeenCalled(); + expect(host.current().models?.['my-pythinker/kimi-for-coding']).toBeDefined(); + }); + + it('writes defaultProvider back when refreshing the provider it points at', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const host = makeRefreshHost({ + providers: { + 'my-pythinker': { + type: 'pythinker', + baseUrl, + apiKey: 'sk-distributed-key', + }, + }, + models: { + 'my-pythinker/kimi-for-coding': { + provider: 'my-pythinker', + model: 'kimi-for-coding', + maxContextSize: 262144, + capabilities: ['tool_use'], + displayName: 'Old Pythinker', + }, + }, + defaultProvider: 'my-pythinker', + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>( + async () => + new Response( + JSON.stringify({ + data: [ + { + id: 'kimi-for-coding', + context_length: 262144, + supports_reasoning: true, + display_name: 'Fresh Pythinker', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.changed).toHaveLength(1); + // The v1 removeProvider RPC clears defaultProvider when it points at the + // refreshed provider; the setConfig patch must carry the original value + // back. + expect(host.setConfig).toHaveBeenCalledWith( + expect.objectContaining({ defaultProvider: 'my-pythinker' }), + ); + expect(host.current().defaultProvider).toBe('my-pythinker'); + }); + + it('leaves registry-sourced providers at the managed base URL to the registry branch', async () => { + const baseUrl = 'https://api.managed.example.test/coding/v1'; + vi.stubEnv('PYTHINKER_CODE_BASE_URL', baseUrl); + const registryUrl = 'https://registry.example.test/v1/models/api.json'; + const host = makeRefreshHost({ + providers: { + custom: { + type: 'pythinker', + baseUrl, + apiKey: 'sk-test-token', + source: { kind: 'apiJson', url: registryUrl, apiKey: 'sk-test-token' }, + }, + }, + models: { + 'custom/m1': { + provider: 'custom', + model: 'm1', + maxContextSize: 131072, + capabilities: ['tool_use'], + displayName: 'm1', + }, + }, + telemetry: true, + } as unknown as PythinkerConfig); + + const fetchMock = vi.fn<FetchMock>(async (input) => { + expect(fetchInputUrl(input)).toBe(registryUrl); + return new Response( + JSON.stringify({ + custom: { + id: 'custom', + name: 'Custom', + api: baseUrl, + type: 'pythinker', + models: { m1: { id: 'm1' } }, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await refreshAllProviderModels({ + getConfig: async () => host.current(), + removeProvider: host.removeProvider, + setConfig: host.setConfig, + resolveOAuthToken: vi.fn(), + }); + + expect(result.failed).toEqual([]); + expect(result.unchanged).toEqual(['custom']); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(host.setConfig).not.toHaveBeenCalled(); + }); }); diff --git a/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts b/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts new file mode 100644 index 00000000..2735f4e9 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import type { Component, Terminal } from '@pymodel/pi-tui'; +import { Text, TuiAltScreen, TuiMainScreen } from '@pymodel/pi-tui'; + +import { beginScreenTakeover, endScreenTakeover } from '#/tui/utils/screen-takeover'; + +/** Minimal Terminal stub: takeover logic never starts the terminal. */ +function stubTerminal(): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: async () => {}, + write: () => {}, + get columns() { + return 80; + }, + get rows() { + return 24; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function line(text: string): Component { + return new Text(text, 0, 0); +} + +describe('screen-takeover', () => { + it('swaps and restores root children in regular mode', () => { + const ui = new TuiMainScreen(stubTerminal()); + const transcript = line('transcript'); + const editor = line('editor'); + ui.addChild(transcript); + ui.addChild(editor); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.children).toEqual([viewer]); + + endScreenTakeover(ui, takeover); + expect(ui.children).toEqual([transcript, editor]); + }); + + it('swaps and restores the layout root in fullscreen mode', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + // The root children list is unused in fullscreen and stays empty. + expect(ui.children).toHaveLength(0); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.getLayoutRoot()).toBe(viewer); + + endScreenTakeover(ui, takeover); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); + + it('nests takeovers (viewer opened from a viewer)', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + + const browser = line('browser'); + const first = beginScreenTakeover(ui, browser); + const detail = line('detail'); + const second = beginScreenTakeover(ui, detail); + expect(ui.getLayoutRoot()).toBe(detail); + + endScreenTakeover(ui, second); + expect(ui.getLayoutRoot()).toBe(browser); + endScreenTakeover(ui, first); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/searchable-list.test.ts b/apps/pythinker-code/test/tui/utils/searchable-list.test.ts index 0fce2925..698d1a60 100644 --- a/apps/pythinker-code/test/tui/utils/searchable-list.test.ts +++ b/apps/pythinker-code/test/tui/utils/searchable-list.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest'; import { SearchableList, type SearchableListOptions } from '#/tui/utils/searchable-list'; const ESC = String.fromCodePoint(27); +const UP = `${ESC}[A`; +const DOWN = `${ESC}[B`; +const PAGE_UP = `${ESC}[5~`; +const PAGE_DOWN = `${ESC}[6~`; const BACKSPACE = String.fromCodePoint(127); const ITEMS = Array.from({ length: 10 }, (_, i) => `item${String(i).padStart(2, '0')}`); @@ -54,7 +58,7 @@ describe('SearchableList', () => { it('filters on the query, resets the cursor, and clearQuery restores the list', () => { const list = make({ initialIndex: 5, searchable: true }); - for (const ch of 'item09') list.handleSearchKey(ch); + for (const ch of 'item09') list.handleKey(ch); let v = list.view(); expect(v.query).toBe('item09'); @@ -72,25 +76,45 @@ describe('SearchableList', () => { it('trims the query on Backspace', () => { const list = make({ searchable: true }); - for (const ch of 'item0') list.handleSearchKey(ch); + for (const ch of 'item0') list.handleKey(ch); expect(list.view().query).toBe('item0'); - list.handleSearchKey(BACKSPACE); + list.handleKey(BACKSPACE); expect(list.view().query).toBe('item'); }); - it('keeps navigation and search handling separate', () => { + it('handleKey always consumes navigation but only edits the query when searchable', () => { const nav = make({ searchable: false }); - nav.moveDown(); - nav.pageDown(); - nav.pageUp(); - nav.moveUp(); - expect(nav.handleSearchKey('a')).toBe(false); // not searchable → printable ignored - expect(nav.handleSearchKey(BACKSPACE)).toBe(false); + expect(nav.handleKey(UP)).toBe(true); + expect(nav.handleKey(DOWN)).toBe(true); + expect(nav.handleKey(PAGE_UP)).toBe(true); + expect(nav.handleKey(PAGE_DOWN)).toBe(true); + expect(nav.handleKey('a')).toBe(false); // not searchable → printable ignored + expect(nav.handleKey(BACKSPACE)).toBe(false); expect(nav.view().query).toBe(''); const search = make({ searchable: true }); - expect(search.handleSearchKey('a')).toBe(true); - expect(search.handleSearchKey(BACKSPACE)).toBe(true); + expect(search.handleKey('a')).toBe(true); + expect(search.handleKey(BACKSPACE)).toBe(true); expect(search.view().query).toBe(''); }); + + it('setItems replaces the items, keeps the query, and clamps the cursor', () => { + const list = make({ searchable: true }); + for (const ch of 'zz') list.handleKey(ch); + list.setItems([...ITEMS, 'item10']); + // The active query survives an items swap and still filters. + expect(list.view().query).toBe('zz'); + expect(list.view().items).toHaveLength(0); + + expect(list.clearQuery()).toBe(true); + for (let i = 0; i < 20; i++) list.moveDown(); + expect(list.view().selectedIndex).toBe(10); + + // Shrinking the set clamps the cursor into the new range. + list.setItems(['item00']); + const v = list.view(); + expect(v.items).toEqual(['item00']); + expect(v.selectedIndex).toBe(0); + expect(list.selected()).toBe('item00'); + }); }); diff --git a/apps/pythinker-code/test/tui/utils/session-accent.test.ts b/apps/pythinker-code/test/tui/utils/session-accent.test.ts deleted file mode 100644 index 5b748bde..00000000 --- a/apps/pythinker-code/test/tui/utils/session-accent.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { accentHexForHue, sessionAccentHex } from '#/tui/utils/session-accent'; - -function channelSum(hex: string): number { - return [1, 3, 5].reduce((sum, start) => sum + Number.parseInt(hex.slice(start, start + 2), 16), 0); -} - -function relativeLuminance(hex: string): number { - const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255); - const [red, green, blue] = channels.map((channel) => - channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4, - ); - return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; -} - -describe('sessionAccentHex', () => { - it('returns a stable six-digit hex color for each key', () => { - const accent = sessionAccentHex('session-alpha', 'dark'); - - expect(accent).toBe(sessionAccentHex('session-alpha', 'dark')); - expect(accent).toMatch(/^#[0-9a-fA-F]{6}$/u); - }); - - it('gives known session keys different hues', () => { - expect(sessionAccentHex('session-alpha', 'dark')).not.toBe( - sessionAccentHex('session-beta', 'dark'), - ); - }); - - it('uses a darker light-theme variant', () => { - expect(channelSum(sessionAccentHex('session-alpha', 'light'))).toBeLessThan( - channelSum(sessionAccentHex('session-alpha', 'dark')), - ); - }); - - it('keeps every light-theme hue above the chrome contrast floor', () => { - for (let hue = 0; hue < 360; hue++) { - const contrast = 1.05 / (relativeLuminance(accentHexForHue(hue, 'light')) + 0.05); - expect(contrast, `hue ${String(hue)}`).toBeGreaterThanOrEqual(3); - } - }); - - it('keeps the dark-theme hue mapping unchanged', () => { - expect(accentHexForHue(60, 'dark')).toBe('#F8F877'); - }); -}); diff --git a/apps/pythinker-code/test/tui/utils/shell-output.test.ts b/apps/pythinker-code/test/tui/utils/shell-output.test.ts new file mode 100644 index 00000000..e7a724b4 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/shell-output.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { formatBashOutputForDisplay, sanitizeShellOutput } from '#/tui/utils/shell-output'; + +const ESC = '\u001B'; +const BEL = '\u0007'; + +function stripTheme(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +describe('sanitizeShellOutput', () => { + it('leaves plain text untouched', () => { + expect(sanitizeShellOutput('hello\nworld')).toBe('hello\nworld'); + }); + + it('strips SGR colour sequences', () => { + expect(sanitizeShellOutput(`${ESC}[31mred${ESC}[0m`)).toBe('red'); + expect(sanitizeShellOutput(`${ESC}[1;32mbold green${ESC}[0m`)).toBe('bold green'); + }); + + it('strips CSI private modes (alt screen, cursor visibility)', () => { + expect(sanitizeShellOutput(`${ESC}[?1049h${ESC}[?25l`)).toBe(''); + expect(sanitizeShellOutput(`before${ESC}[?2004hafter`)).toBe('beforeafter'); + }); + + it('strips clear-screen and cursor-movement sequences', () => { + expect(sanitizeShellOutput(`${ESC}[2J${ESC}[Hhello`)).toBe('hello'); + expect(sanitizeShellOutput(`${ESC}[10;5Hhi`)).toBe('hi'); + }); + + it('strips OSC window titles', () => { + expect(sanitizeShellOutput(`${ESC}]0;my title${BEL}text`)).toBe('text'); + }); + + it('strips OSC 8 hyperlinks but keeps the link text', () => { + const link = `${ESC}]8;;https://example.com${ESC}\\click here${ESC}]8;;${ESC}\\`; + expect(sanitizeShellOutput(link)).toBe('click here'); + }); + + it('strips carriage returns (spinner redraw)', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); + }); + + it('strips backspace, bell and NUL', () => { + expect(sanitizeShellOutput(`a\u0008b${BEL}c\u0000d`)).toBe('abcd'); + }); + + it('preserves newlines and tabs', () => { + expect(sanitizeShellOutput('a\nb\tc')).toBe('a\nb\tc'); + }); + + it('strips single-char ESC commands (reset, save/restore cursor)', () => { + expect(sanitizeShellOutput(`${ESC}c${ESC}7${ESC}8text`)).toBe('text'); + }); + + it('never throws and returns "" for non-string input', () => { + expect(sanitizeShellOutput(undefined as unknown as string)).toBe(''); + expect(sanitizeShellOutput(null as unknown as string)).toBe(''); + expect(sanitizeShellOutput(42 as unknown as string)).toBe(''); + }); + + it('handles huge input without throwing', () => { + const huge = `${ESC}[31m${'x'.repeat(2_000_000)}\r${ESC}[0m`; + expect(() => sanitizeShellOutput(huge)).not.toThrow(); + }); + + it('cleans a realistic TUI/dev-server burst down to printable text', () => { + const messy = + `${ESC}[?1049h${ESC}[?25l${ESC}[2J${ESC}[H` + + `${ESC}[1m${ESC}[32mVITE${ESC}[0m ready in 120ms\r\n` + + `${ESC}]0;dev server${BEL}` + + ` Local: http://localhost:5173/`; + const result = sanitizeShellOutput(messy); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('VITE ready in 120ms'); + expect(result).toContain('Local: http://localhost:5173/'); + }); +}); + +describe('formatBashOutputForDisplay', () => { + it('shows "(no output)" when both streams are empty', () => { + expect(stripTheme(formatBashOutputForDisplay('', ''))).toBe('(no output)'); + }); + + it('strips control sequences from stdout before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay(`${ESC}[?1049h${ESC}[31mhi${ESC}[0m\r`, '')); + expect(result).not.toContain(ESC); + expect(result).not.toContain('\r'); + expect(result).toContain('hi'); + }); + + it('strips control sequences from stderr before rendering', () => { + const result = stripTheme(formatBashOutputForDisplay('', `err${BEL}\r`, true)); + expect(result).not.toContain(ESC); + expect(result).not.toContain(BEL); + expect(result).not.toContain('\r'); + expect(result).toContain('err'); + }); + + it('never throws on malformed / non-string input', () => { + expect(() => + formatBashOutputForDisplay(undefined as unknown as string, null as unknown as string), + ).not.toThrow(); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/shimmer.test.ts b/apps/pythinker-code/test/tui/utils/shimmer.test.ts deleted file mode 100644 index cce1c38a..00000000 --- a/apps/pythinker-code/test/tui/utils/shimmer.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import chalk from 'chalk'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { currentTheme, darkColors } from '#/tui/theme'; -import { shimmerText } from '#/tui/utils/shimmer'; - -function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/gu, ''); -} - -describe('shimmerText', () => { - let previousLevel = chalk.level; - let previousPalette = currentTheme.palette; - - beforeEach(() => { - previousLevel = chalk.level; - previousPalette = currentTheme.palette; - chalk.level = 3; - currentTheme.setPalette(darkColors); - }); - - afterEach(() => { - vi.restoreAllMocks(); - chalk.level = previousLevel; - currentTheme.setPalette(previousPalette); - }); - - it('preserves the input text when ANSI is removed', () => { - vi.spyOn(Date, 'now').mockReturnValue(0); - const text = 'Thinking carefully'; - - expect( - stripAnsi( - shimmerText(text, { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - }), - ), - ).toBe(text); - }); - - it('moves the cosine band with wall-clock time', () => { - const now = vi.spyOn(Date, 'now'); - now.mockReturnValue(0); - const first = shimmerText('abcdefghijklmno', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - }); - now.mockReturnValue(100); - const second = shimmerText('abcdefghijklmno', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - }); - - expect(second).not.toBe(first); - }); - - it('alternates the peak token after one full sweep', () => { - const now = vi.spyOn(Date, 'now'); - const options = { - baseToken: 'primary' as const, - shimmerToken: 'primaryShimmer' as const, - altShimmerToken: 'warningShimmer' as const, - bandHalfWidth: 1, - }; - - now.mockReturnValue(50); - const first = shimmerText('abcde', options); - now.mockReturnValue(400); - const second = shimmerText('abcde', options); - - expect(first).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); - expect(second).toContain(chalk.hex(darkColors.warningShimmer).bold('a')); - }); - - it('keeps the primary peak token when no alternate is set', () => { - const now = vi.spyOn(Date, 'now'); - const options = { - baseToken: 'primary' as const, - shimmerToken: 'primaryShimmer' as const, - bandHalfWidth: 1, - }; - - now.mockReturnValue(50); - const first = shimmerText('abcde', options); - now.mockReturnValue(400); - const second = shimmerText('abcde', options); - - expect(first).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); - expect(second).toContain(chalk.hex(darkColors.primaryShimmer).bold('a')); - }); - - it('advances the band at twenty cells per second', () => { - vi.spyOn(Date, 'now').mockReturnValue(100); - - const output = shimmerText('abcdefghijklmno', { - baseToken: 'primary', - shimmerToken: 'primaryShimmer', - bandHalfWidth: 1, - }); - - expect(output).toContain(chalk.hex(darkColors.primaryShimmer).bold('b')); - expect(output).not.toContain(chalk.hex(darkColors.primaryShimmer).bold('c')); - }); -}); diff --git a/apps/pythinker-code/test/tui/utils/steer-input.test.ts b/apps/pythinker-code/test/tui/utils/steer-input.test.ts new file mode 100644 index 00000000..933c81a9 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/steer-input.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import type { PromptPart } from '@pymodel/pythinker-code-sdk'; + +import type { SteerInputItem } from '#/tui/types'; +import { combineSteerInput } from '#/tui/utils/steer-input'; + +describe('combineSteerInput', () => { + const refPart = { + type: 'image_url', + imageUrl: { url: 'pythinker-file://f_1?path=%2Fcache%2Ff_1.png' }, + } as const; + + it('keeps a bare daemon-ref part intact while merging the surrounding text', () => { + const result = combineSteerInput([ + { + text: 'what is this?', + parts: [{ type: 'text', text: 'what is this? ' }, refPart], + }, + ]); + expect(result).toEqual([{ type: 'text', text: 'what is this? ' }, refPart]); + }); + + it('merges plain text across items around the media parts', () => { + const result = combineSteerInput([ + { text: 'a', parts: [{ type: 'text', text: 'a ' }, refPart] }, + { text: 'b', parts: [{ type: 'text', text: 'b ' }, refPart] }, + ]); + expect(result).toEqual([ + { type: 'text', text: 'a ' }, + refPart, + { type: 'text', text: '\n\nb ' }, + refPart, + ]); + }); + + it.each([ + { + name: 'between two touching media parts', + first: { text: '', parts: [refPart] } as SteerInputItem, + head: [] as PromptPart[], + }, + { + name: 'when a media-ending item is followed by a media-first item', + first: { + text: 'a', + parts: [{ type: 'text', text: 'a ' }, refPart], + } as SteerInputItem, + head: [{ type: 'text', text: 'a ' }] as PromptPart[], + }, + ])('drops the separator $name', ({ first, head }) => { + // Inserting '\n\n' there would strand a whitespace-only text part between + // the two media parts, which `normalizePromptInput` rejects. + const refPart2 = { + type: 'image_url', + imageUrl: { url: 'pythinker-file://f_2?path=%2Fcache%2Ff_2.png' }, + } as const; + const result = combineSteerInput([first, { text: '', parts: [refPart2] }]); + expect(result).toEqual([...head, refPart, refPart2]); + }); + + it('treats a standalone <media path> tag as plain user text', () => { + // Extraction no longer authors machine tags, so a tag in the input is + // user text: it merges with adjacent text instead of staying atomic. + const tag = '<image path="/cache/f_1.png"></image>'; + const result = combineSteerInput([ + { + text: `look ${tag}`, + parts: [{ type: 'text', text: 'look ' }, { type: 'text', text: tag }, refPart], + }, + ]); + expect(result).toEqual([{ type: 'text', text: `look ${tag}` }, refPart]); + }); + + it('joins text-only items with the historical separator', () => { + expect(combineSteerInput([{ text: 'one' }, { text: 'two' }])).toBe('one\n\ntwo'); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/step-retry.test.ts b/apps/pythinker-code/test/tui/utils/step-retry.test.ts new file mode 100644 index 00000000..9111471c --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/step-retry.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { RETRY_DETAIL_MAX_CHARS } from '#/tui/constant/rendering'; +import { formatStepRetryDetail, formatStepRetryLabel } from '#/tui/utils/step-retry'; +import type { StepRetryState } from '#/tui/types'; + +function retry(partial: Partial<StepRetryState> = {}): StepRetryState { + return { + nextAttempt: 2, + maxAttempts: 10, + delayMs: 4000, + errorName: 'APIStatusError', + errorMessage: 'rate limited', + statusCode: 429, + phase: 'backoff', + ...partial, + }; +} + +describe('formatStepRetryLabel', () => { + it('shows attempts, raw error name, and backoff delay', () => { + expect(formatStepRetryLabel(retry())).toBe('Retrying (2/10) · APIStatusError · in 4s'); + }); + + it('drops the stale countdown once the attempt is running', () => { + expect(formatStepRetryLabel(retry({ phase: 'attempt' }))).toBe( + 'Retrying (2/10) · APIStatusError', + ); + }); + + it('rounds sub-second delays up to 1s', () => { + expect(formatStepRetryLabel(retry({ delayMs: 500 }))).toContain('in 1s'); + }); +}); + +describe('formatStepRetryDetail', () => { + it('prefixes the message with the status code', () => { + expect(formatStepRetryDetail(retry())).toBe('429 · rate limited'); + }); + + it('omits the status code for network/timeout failures', () => { + expect( + formatStepRetryDetail( + retry({ errorName: 'APIConnectionError', errorMessage: 'fetch failed', statusCode: undefined }), + ), + ).toBe('fetch failed'); + }); + + it('collapses multi-line error bodies into one line', () => { + expect(formatStepRetryDetail(retry({ errorMessage: 'line one\n\n line two' }))).toBe( + '429 · line one line two', + ); + }); + + it('caps huge error bodies', () => { + const detail = formatStepRetryDetail(retry({ errorMessage: 'x'.repeat(1000) })); + expect(detail.length).toBe(RETRY_DETAIL_MAX_CHARS); + expect(detail.endsWith('…')).toBe(true); + }); + + it('returns the status code alone when the message is empty', () => { + expect(formatStepRetryDetail(retry({ errorMessage: '' }))).toBe('429'); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/tab-strip.test.ts b/apps/pythinker-code/test/tui/utils/tab-strip.test.ts new file mode 100644 index 00000000..e4ae2d2a --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/tab-strip.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import chalk from 'chalk'; + +import { darkColors } from '#/tui/theme/colors'; +import { renderTabStrip } from '#/tui/utils/tab-strip'; + +const ANSI_SGR = /\u001b\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function render(labels: readonly string[], width: number, activeIndex = 0): string { + const previousChalkLevel = chalk.level; + chalk.level = 3; + try { + return strip(renderTabStrip({ labels, activeIndex, width, colors: darkColors })); + } finally { + chalk.level = previousChalkLevel; + } +} + +describe('renderTabStrip', () => { + const labels = ['Installed', 'Official', 'Third-party', 'Custom']; + // Cell widths: ` ${label} ` → 11 / 10 / 13 / 8 = 42, plus 3 separators and a + // leading space → 46 columns total. + const FULL_WIDTH = 46; + + it('shows the full strip when it exactly fits', () => { + const out = render(labels, FULL_WIDTH); + expect(out).toContain('Installed'); + expect(out).toContain('Custom'); + expect(out).not.toContain('<'); + expect(out).not.toContain('>'); + }); + + it('scrolls (shows markers) when one column narrower than full fit', () => { + const out = render(labels, FULL_WIDTH - 1, 0); + expect(out).toContain('>'); + expect(out).not.toContain('Custom'); + }); + + it('does not truncate the last tab when separators just barely fit', () => { + // Regression: the old fit check summed only cell widths and ignored the + // three inter-tab spaces, so at 43–45 columns it declared a fit while the + // joined line was wider and the trailing tab got truncated. + const out = render(labels, FULL_WIDTH); + expect(out.endsWith(' Custom ')).toBe(true); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/terminal-size.test.ts b/apps/pythinker-code/test/tui/utils/terminal-size.test.ts deleted file mode 100644 index 3392f35a..00000000 --- a/apps/pythinker-code/test/tui/utils/terminal-size.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EventEmitter } from 'node:events'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { waitForTerminalSize } from '#/tui/utils/terminal-size'; - -type TestStream = EventEmitter & Parameters<typeof waitForTerminalSize>[0]; - -function makeStream(columns: number): TestStream { - return Object.assign(new EventEmitter(), { columns }) as TestStream; -} - -describe('waitForTerminalSize', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('resolves immediately when columns are already usable', async () => { - const stream = makeStream(80); - const once = vi.spyOn(stream, 'once'); - - await waitForTerminalSize(stream); - - expect(once).not.toHaveBeenCalled(); - }); - - it('resolves on resize and removes the listener', async () => { - const stream = makeStream(0); - const pending = waitForTerminalSize(stream); - - expect(stream.listenerCount('resize')).toBe(1); - stream.emit('resize'); - await pending; - - expect(stream.listenerCount('resize')).toBe(0); - }); - - it('resolves after the timeout and removes the listener', async () => { - vi.useFakeTimers(); - const stream = makeStream(0); - const pending = waitForTerminalSize(stream, 25); - - vi.advanceTimersByTime(25); - await pending; - - expect(stream.listenerCount('resize')).toBe(0); - }); -}); diff --git a/apps/pythinker-code/test/tui/utils/thinking-config.test.ts b/apps/pythinker-code/test/tui/utils/thinking-config.test.ts new file mode 100644 index 00000000..e0a95359 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/thinking-config.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { + isThinkingOn, + thinkingEffortFromConfig, + thinkingEffortToConfig, +} from '@/tui/utils/thinking-config'; + +describe('thinkingEffortToConfig', () => { + it.each([ + ['off', { enabled: false }], + // 'on' is the boolean-model on-signal, not a declared effort. It must not + // be persisted as `thinking.effort` — boolean models have no effort concept + // and resolve back to 'on' at runtime via defaultThinkingEffortFor. + ['on', { enabled: true }], + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + ['max', { enabled: true, effort: 'max' }], + ] as const)('maps %s → %o without model efforts', (effort, expected) => { + expect(thinkingEffortToConfig(effort)).toEqual(expected); + }); + + it.each([ + // The model's highest declared level (last support_efforts entry) is + // session-only; anything below it persists as the global default. + ['low', { enabled: true, effort: 'low' }], + ['high', { enabled: true, effort: 'high' }], + ['max', { enabled: true }], + // Undeclared values persist as-is (the provider validates them). + ['ultra', { enabled: true, effort: 'ultra' }], + ] as const)('maps %s → %o for [low, high, max]', (effort, expected) => { + expect(thinkingEffortToConfig(effort, ['low', 'high', 'max'])).toEqual(expected); + }); + + it('treats a single declared level as the top tier', () => { + expect(thinkingEffortToConfig('max', ['max'])).toEqual({ enabled: true }); + }); +}); + +describe('isThinkingOn', () => { + it.each([ + ['off', false], + ['on', true], + ['low', true], + ['high', true], + ['max', true], + ] as const)('%s → %s', (effort, expected) => { + expect(isThinkingOn(effort)).toBe(expected); + }); +}); + +describe('thinkingEffortFromConfig', () => { + it.each([ + [undefined, undefined], + [{}, undefined], + // enabled with no concrete effort → let the model's own default apply. + [{ enabled: true }, undefined], + [{ enabled: false }, 'off'], + [{ enabled: true, effort: 'high' }, 'high'], + // effort is honored even when enabled is not explicitly set. + [{ effort: 'max' }, 'max'], + ] as const)('%o → %s', (config, expected) => { + expect(thinkingEffortFromConfig(config)).toBe(expected); + }); +}); diff --git a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts b/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts deleted file mode 100644 index 59c40c55..00000000 --- a/apps/pythinker-code/test/tui/utils/thinking-levels.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { effortColorToken, shortEffortLabel } from '#/tui/utils/thinking-levels'; - -describe('effortColorToken', () => { - it.each([ - ['minimal', 'effortLow'], - ['low', 'effortLow'], - ['medium', 'effortMedium'], - ['high', 'effortHigh'], - ['xhigh', 'effortXHigh'], - ['max', 'effortMax'], - ['legacy', 'primary'], - ])('maps %s to %s', (level, token) => { - expect(effortColorToken(level)).toBe(token); - }); -}); - -describe('shortEffortLabel', () => { - it('shortens medium to med and keeps other labels', () => { - expect(shortEffortLabel('medium')).toBe('med'); - expect(shortEffortLabel('high')).toBe('high'); - expect(shortEffortLabel('off')).toBe('off'); - }); -}); diff --git a/apps/pythinker-code/test/tui/utils/transcript-window.test.ts b/apps/pythinker-code/test/tui/utils/transcript-window.test.ts new file mode 100644 index 00000000..b6a7df66 --- /dev/null +++ b/apps/pythinker-code/test/tui/utils/transcript-window.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { TranscriptEntry } from '#/tui/types'; +import { groupTurns, readEnvInt, turnsToTrim } from '#/tui/utils/transcript-window'; + +let seq = 0; +function makeEntry( + turnId: string | undefined, + kind: TranscriptEntry['kind'] = 'assistant', +): TranscriptEntry { + return { id: String(++seq), kind, turnId, renderMode: 'markdown', content: '' }; +} +function tool(turnId: string): TranscriptEntry { + return makeEntry(turnId, 'tool_call'); +} +function msg(turnId: string | undefined): TranscriptEntry { + return makeEntry(turnId, 'assistant'); +} + +describe('groupTurns', () => { + it('groups consecutive entries with the same turnId', () => { + const turns = groupTurns([msg('a'), tool('a'), msg('b')]); + expect(turns.map((t) => t.turnId)).toEqual(['a', 'b']); + expect(turns[0]!.entries).toHaveLength(2); + expect(turns[1]!.entries).toHaveLength(1); + }); + + it('attaches leading undefined turnId entries to the following turn', () => { + // A user message (undefined turnId) followed by its response should be one turn. + const turns = groupTurns([msg(undefined), tool('1'), msg('1')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('1'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('attaches multiple consecutive undefined entries to the following turn', () => { + const turns = groupTurns([msg(undefined), msg(undefined), msg('a')]); + expect(turns).toHaveLength(1); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[0]!.entries).toHaveLength(3); + }); + + it('makes trailing undefined entries their own turn', () => { + const turns = groupTurns([msg('a'), msg(undefined)]); + expect(turns).toHaveLength(2); + expect(turns[0]!.turnId).toBe('a'); + expect(turns[1]!.turnId).toBeUndefined(); + expect(turns[1]!.entries).toHaveLength(1); + }); +}); + +describe('turnsToTrim', () => { + it('returns empty when turn count is within maxTurns', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 5, 1).size).toBe(0); + }); + + it('does not trim within the hysteresis band', () => { + const turns = groupTurns([msg('a'), msg('b'), msg('c')]); // 3 turns + expect(turnsToTrim(turns, 2, 1).size).toBe(0); // 3 <= 2 + 1 + }); + + it('trims oldest turns first', () => { + const entries = [msg('a'), msg('b'), msg('c'), msg('d')]; // 4 turns + const turns = groupTurns(entries); + const removed = turnsToTrim(turns, 2, 0); + expect(removed.has(entries[0]!)).toBe(true); + expect(removed.has(entries[1]!)).toBe(true); + expect(removed.has(entries[2]!)).toBe(false); + expect(removed.has(entries[3]!)).toBe(false); + }); + + it('never trims the most recent turn', () => { + // A single turn is never removed, even if it is huge. + const entries = Array.from({ length: 200 }, () => tool('solo')); + const turns = groupTurns(entries); // 1 turn + const removed = turnsToTrim(turns, 2, 0); + expect(removed.size).toBe(0); + }); +}); + +describe('readEnvInt', () => { + const KEY = 'PYTHINKER_CODE_TUI_TEST_INT'; + afterEach(() => { + delete process.env[KEY]; + }); + + it('returns fallback when unset', () => { + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('reads a valid integer', () => { + process.env[KEY] = '42'; + expect(readEnvInt(KEY, 7)).toBe(42); + }); + + it('accepts 0', () => { + process.env[KEY] = '0'; + expect(readEnvInt(KEY, 7)).toBe(0); + }); + + it('falls back on negative', () => { + process.env[KEY] = '-1'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on non-integer', () => { + process.env[KEY] = 'abc'; + expect(readEnvInt(KEY, 7)).toBe(7); + }); + + it('falls back on empty/whitespace', () => { + process.env[KEY] = ' '; + expect(readEnvInt(KEY, 7)).toBe(7); + }); +}); diff --git a/apps/pythinker-code/test/tui/working-tips.test.ts b/apps/pythinker-code/test/tui/working-tips.test.ts new file mode 100644 index 00000000..e2cf96c5 --- /dev/null +++ b/apps/pythinker-code/test/tui/working-tips.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + WORKING_TIPS, + currentWorkingTip, + pickRandomWorkingTip, +} from '#/tui/components/chrome/working-tips'; + +describe('currentWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const now = Date.now(); + const tip = currentWorkingTip(now); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('returns the same tip for the same timestamp', () => { + const now = 1_000_000; + const first = currentWorkingTip(now); + const second = currentWorkingTip(now); + expect(first).toBe(second); + }); +}); + +describe('pickRandomWorkingTip', () => { + it('returns a tip from WORKING_TIPS', () => { + const tip = pickRandomWorkingTip(); + expect(tip).toBeDefined(); + expect(WORKING_TIPS.some((t) => t.text === tip!.text)).toBe(true); + }); + + it('avoids the excluded text when possible', () => { + const first = pickRandomWorkingTip()!; + let different = false; + for (let i = 0; i < 50; i++) { + const next = pickRandomWorkingTip(first.text); + if (next !== undefined && next.text !== first.text) { + different = true; + break; + } + } + if (WORKING_TIPS.length > 1) { + expect(different).toBe(true); + } + }); + + it('falls back to the rotation when every tip would be excluded', () => { + // If all working tips share the same text, exclusion cannot be satisfied. + const onlyTip = WORKING_TIPS[0]; + if (onlyTip !== undefined && WORKING_TIPS.every((t) => t.text === onlyTip.text)) { + expect(pickRandomWorkingTip(onlyTip.text)).toBeDefined(); + } + }); +}); diff --git a/apps/pythinker-code/test/utils/catalog-fetch.test.ts b/apps/pythinker-code/test/utils/catalog-fetch.test.ts new file mode 100644 index 00000000..3db991c3 --- /dev/null +++ b/apps/pythinker-code/test/utils/catalog-fetch.test.ts @@ -0,0 +1,84 @@ +import { DEFAULT_CATALOG_URL, CatalogFetchError } from '@pymodel/pythinker-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; + +const BUILT_IN = JSON.stringify({ + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { 'claude-test': { id: 'claude-test', limit: { context: 200000 } } }, + }, +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('fetchCatalogOrBuiltIn', () => { + it('returns the network catalog when models.dev is reachable', async () => { + const network = { openai: { id: 'openai', models: {} } }; + const fetchImpl = vi.fn(async () => jsonResponse(network)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(false); + expect(result.catalog).toEqual(network); + }); + + it('falls back to the built-in snapshot when the default URL fetch fails', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 503)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(true); + expect(result.catalog).toEqual(JSON.parse(BUILT_IN)); + }); + + it('does not fall back for a custom catalog URL', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn('https://example.test/private.json', { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); + + it('does not fall back when the caller aborted the request', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = vi.fn(async () => { + throw new DOMException('Aborted', 'AbortError'); + }); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + signal: controller.signal, + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toThrow(); + }); + + it('rethrows when fetch fails and no built-in snapshot is available', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: '', + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); +}); diff --git a/apps/pythinker-code/test/utils/client-configs.test.ts b/apps/pythinker-code/test/utils/client-configs.test.ts new file mode 100644 index 00000000..f97effa8 --- /dev/null +++ b/apps/pythinker-code/test/utils/client-configs.test.ts @@ -0,0 +1,356 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + fetchClientConfig, + getClientConfig, + peekClientConfig, + resetClientConfigCache, +} from '#/utils/client-configs'; +import { z } from 'zod'; + +const configSchema = z.object({ + version: z.literal(1), + config: z.record(z.string(), z.object({ min_tokens_to_hint: z.number(), cache_duration: z.number() })), +}); + +const CONFIG = { + version: 1, + config: { k3: { min_tokens_to_hint: 200000, cache_duration: 600 } }, +}; + +const ENVELOPE = { name: 'estimated_cache_duration', config: CONFIG }; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +afterEach(() => { + resetClientConfigCache(); +}); + +describe('fetchClientConfig', () => { + it('POSTs the config name and unwraps the envelope', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledWith( + expect.stringContaining('/client_configs'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'estimated_cache_duration' }), + }), + ); + }); + + it('sends the bearer token when provided, anonymous otherwise', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + accessToken: 'tok', + }); + expect(fetchImpl).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ authorization: 'Bearer tok' }), + }), + ); + + await fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }); + expect(fetchImpl).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ authorization: expect.anything() }), + }), + ); + }); + + it('returns undefined on non-OK responses', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 503)); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when the payload fails the caller schema', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'estimated_cache_duration', config: { version: 2, config: {} } }), + ); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when the envelope name does not match', async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ name: 'some_other_config', config: CONFIG }), + ); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); + + it('returns undefined when fetch throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('offline'); + }); + + await expect( + fetchClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('getClientConfig', () => { + it('serves the in-process cache within a day', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + const first = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const second = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 60_000, + cacheFile: null, + }); + + expect(first).toEqual(CONFIG); + expect(second).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('refetches when the cache is older than a day', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 25 * 60 * 60 * 1000, + cacheFile: null, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('caches each config name independently', async () => { + const other = { name: 'other_config', config: CONFIG }; + const fetchImpl = vi.fn(async (url: unknown, init?: { body?: string }) => + jsonResponse(init?.body?.includes('other') ? other : ENVELOPE), + ); + const now = Date.now(); + + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + const second = await getClientConfig('other_config', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + + expect(second).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('resolves to undefined when the refetch fails', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: null, + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('peekClientConfig', () => { + it('returns the cached config only while fresh', async () => { + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + const now = Date.now(); + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: null, + }); + + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG); + expect( + peekClientConfig('estimated_cache_duration', configSchema, now + 25 * 60 * 60 * 1000), + ).toBeUndefined(); + }); + + it('returns undefined for a config that was never fetched', () => { + expect(peekClientConfig('estimated_cache_duration', configSchema)).toBeUndefined(); + }); +}); + +describe('getClientConfig disk cache', () => { + let dir: string; + let file: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'client-configs-')); + file = join(dir, 'estimated_cache_duration.json'); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it('serves a fresh disk entry without network and warms the in-process cache', async () => { + const now = Date.now(); + await writeFile(file, JSON.stringify({ version: 1, fetchedAt: now, config: CONFIG })); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now: now + 60_000, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).not.toHaveBeenCalled(); + // The in-process layer was warmed with the original fetch time. + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 60_000)).toEqual(CONFIG); + }); + + it('serves the disk entry after the in-process cache is dropped (restart)', async () => { + const now = Date.now(); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + + resetClientConfigCache(); + const offline = vi.fn(async (): Promise<Response> => { + throw new Error('offline'); + }); + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: offline as typeof fetch, + now: now + 60_000, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(offline).not.toHaveBeenCalled(); + }); + + it('keeps the original fetch time when warming from disk (no TTL extension)', async () => { + const now = Date.now(); + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: now - 23 * 60 * 60 * 1000, config: CONFIG }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + // 23h old on disk: still fresh. + await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + // 2h later (25h since the actual fetch): the warmed entry must be stale. + expect(peekClientConfig('estimated_cache_duration', configSchema, now + 2 * 60 * 60 * 1000)).toBeUndefined(); + }); + + it('refetches and rewrites the file when the disk entry is stale', async () => { + const now = Date.now(); + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: now - 25 * 60 * 60 * 1000, config: CONFIG }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + now, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const onDisk = JSON.parse(await readFile(file, 'utf-8')) as { fetchedAt: number }; + expect(onDisk.fetchedAt).toBe(now); + }); + + it('treats a malformed cache file as missing', async () => { + await writeFile(file, 'not json'); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('ignores a disk entry whose payload fails the caller schema', async () => { + await writeFile( + file, + JSON.stringify({ version: 1, fetchedAt: Date.now(), config: { version: 2 } }), + ); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: file, + }); + + expect(result).toEqual(CONFIG); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('still resolves when the cache file cannot be written', async () => { + // The parent path is a regular file, so mkdir for the cache file fails. + const blocker = join(dir, 'blocker'); + await writeFile(blocker, 'x'); + const fetchImpl = vi.fn(async () => jsonResponse(ENVELOPE)); + + const result = await getClientConfig('estimated_cache_duration', configSchema, { + fetchImpl: fetchImpl as typeof fetch, + cacheFile: join(blocker, 'nested', 'config.json'), + }); + + expect(result).toEqual(CONFIG); + }); +}); diff --git a/apps/pythinker-code/test/utils/clipboard/clipboard-common.test.ts b/apps/pythinker-code/test/utils/clipboard/clipboard-common.test.ts new file mode 100644 index 00000000..c6aba57a --- /dev/null +++ b/apps/pythinker-code/test/utils/clipboard/clipboard-common.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { runCommandAsync } from '#/utils/clipboard/clipboard-common'; + +describe('runCommandAsync', () => { + it('resolves with stdout for a successful command', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.stdout.write("hello")']); + expect(result.ok).toBe(true); + expect(result.stdout.toString('utf-8')).toBe('hello'); + }); + + it('resolves ok:false for a non-zero exit', async () => { + const result = await runCommandAsync(process.execPath, ['-e', 'process.exit(3)']); + expect(result.ok).toBe(false); + }); + + it('does not block when the command exceeds the timeout', async () => { + const timeoutMs = 100; + const start = Date.now(); + // The child would idle for 30s if left running; runCommandAsync must kill + // it and resolve well before that so a wedged helper cannot freeze launch. + const result = await runCommandAsync(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { + timeoutMs, + }); + const elapsed = Date.now() - start; + + expect(result.ok).toBe(false); + expect(elapsed).toBeLessThan(5000); + }); +}); diff --git a/apps/pythinker-code/test/utils/clipboard/clipboard-has-image.test.ts b/apps/pythinker-code/test/utils/clipboard/clipboard-has-image.test.ts new file mode 100644 index 00000000..94e84110 --- /dev/null +++ b/apps/pythinker-code/test/utils/clipboard/clipboard-has-image.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { clipboardHasImage } from '#/utils/clipboard/clipboard-has-image'; +import type { ClipboardModule } from '#/utils/clipboard/clipboard-native'; + +function fakeClipboard(overrides: Partial<ClipboardModule>): ClipboardModule { + return { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(async () => []), + ...overrides, + }; +} + +describe('clipboardHasImage', () => { + it('returns false on Termux', async () => { + const result = await clipboardHasImage({ env: { TERMUX_VERSION: '0.118' }, platform: 'linux' }); + expect(result).toBe(false); + }); + + it('returns true when native clipboard reports an image on macOS', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on macOS when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when native clipboard throws', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => { + throw new Error('native error'); + }), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + }); + + it('returns false on macOS when clipboard contains a file-like native format', async () => { + const clip = fakeClipboard({ + hasImage: vi.fn(() => true), + availableFormats: vi.fn(() => ['public.file-url', 'public.png']), + }); + const result = await clipboardHasImage({ platform: 'darwin', clipboard: clip }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + // The focus-driven hint must not probe the clipboard on Linux: spawning + // wl-paste / xclip on Wayland perturbs seat focus and re-triggers the + // terminal focus event, creating a focus feedback loop (issue #1090). + it('returns false on Linux without reading the clipboard', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ + platform: 'linux', + env: { WAYLAND_DISPLAY: 'wayland-1' }, + clipboard: clip, + }); + expect(result).toBe(false); + expect(clip.hasImage).not.toHaveBeenCalled(); + }); + + it('returns true on Windows when native clipboard reports an image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => true) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(true); + }); + + it('returns false on Windows when native clipboard reports no image', async () => { + const clip = fakeClipboard({ hasImage: vi.fn(() => false) }); + const result = await clipboardHasImage({ platform: 'win32', clipboard: clip }); + expect(result).toBe(false); + }); +}); diff --git a/apps/pythinker-code/test/utils/clipboard/clipboard-text.test.ts b/apps/pythinker-code/test/utils/clipboard/clipboard-text.test.ts index 72b03635..510d60ba 100644 --- a/apps/pythinker-code/test/utils/clipboard/clipboard-text.test.ts +++ b/apps/pythinker-code/test/utils/clipboard/clipboard-text.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { buildClipboardOSC52 } from '#/utils/clipboard/clipboard-osc52'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; vi.mock('node:child_process', () => ({ @@ -17,7 +18,30 @@ vi.mock('#/utils/clipboard/clipboard-native', () => ({ const clipboardMock = clipboard as unknown as { setText: ReturnType<typeof vi.fn> }; const spawnSyncMock = vi.mocked(spawnSync); +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +function restoreIsTTY(): void { + if (originalIsTTYDescriptor !== undefined) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); + } +} + +function stubStdoutTTY(isTTY: boolean): void { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + writable: true, + value: isTTY, + }); +} + +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + afterEach(() => { + restoreIsTTY(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); vi.clearAllMocks(); }); @@ -31,7 +55,7 @@ describe('copyTextToClipboard', () => { it('copies text with the native clipboard when available', async () => { clipboardMock.setText.mockResolvedValue(undefined); - await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"'); }); @@ -41,10 +65,11 @@ describe('copyTextToClipboard', () => { expect(text).toBe('cd "/tmp/proj-b"'); }); - await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); }); it('throws an Error when all platform clipboard commands fail', async () => { + stubStdoutTTY(false); clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); @@ -54,3 +79,39 @@ describe('copyTextToClipboard', () => { ); }); }); + +describe('buildClipboardOSC52', () => { + it('emits a bare OSC 52 sequence outside tmux', () => { + expect(buildClipboardOSC52('hi', false)).toBe(`\u001B]52;c;${base64('hi')}\u0007`); + }); + + it('wraps the sequence in a tmux passthrough with doubled ESC bytes', () => { + expect(buildClipboardOSC52('hi', true)).toBe( + `\u001BPtmux;\u001B\u001B]52;c;${base64('hi')}\u0007\u001B\\`, + ); + }); +}); + +describe('OSC 52 fallback in copyTextToClipboard', () => { + it('resolves via OSC 52 when native clipboards fail on a terminal', async () => { + stubStdoutTTY(true); + clipboardMock.setText = undefined as unknown as ReturnType<typeof vi.fn>; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType<typeof spawnSync>); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await expect(copyTextToClipboard('hello world')).resolves.toBe('osc52'); + + const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(written).toContain(`]52;c;${base64('hello world')}`); + }); + + it('does not write escape sequences when stdout is not a terminal', async () => { + stubStdoutTTY(false); + clipboardMock.setText = vi.fn().mockResolvedValue(undefined); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await copyTextToClipboard('hello'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/utils/git/git-status.test.ts b/apps/pythinker-code/test/utils/git/git-status.test.ts index 951816fd..962bd8aa 100644 --- a/apps/pythinker-code/test/utils/git/git-status.test.ts +++ b/apps/pythinker-code/test/utils/git/git-status.test.ts @@ -1,9 +1,10 @@ /* eslint-disable import/first -- vi.mock setup must run before the imports it stubs out. */ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ spawnSync: vi.fn(), execFile: vi.fn(), + resolveCommandPath: vi.fn(), })); vi.mock('node:child_process', () => ({ @@ -11,8 +12,16 @@ vi.mock('node:child_process', () => ({ spawnSync: mocks.spawnSync, })); +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, +})); + import { createGitStatusCache, formatGitBadge } from '#/utils/git/git-status'; +beforeEach(() => { + mocks.resolveCommandPath.mockImplementation((command: string) => `/usr/bin/${command}`); +}); + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); @@ -200,6 +209,47 @@ describe('git status cache', () => { }); }); + it('returns null without spawning when git cannot be resolved to a safe path', () => { + mocks.resolveCommandPath.mockReturnValue(undefined); + expect(createGitStatusCache('/tmp/repo').getStatus()).toBeNull(); + expect(mocks.spawnSync).not.toHaveBeenCalled(); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); + + it('spawns git and gh through their resolved absolute paths', async () => { + mocks.execFile.mockImplementation( + ( + _cmd: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + callback(new Error('no pull request'), '', ''); + }, + ); + mocks.spawnSync.mockImplementation((_cmd: string, args: string[]) => { + if (args.includes('rev-parse')) return { status: 0, stdout: 'true\n' }; + if (args.includes('branch')) return { status: 0, stdout: 'main\n' }; + if (args.includes('status')) return { status: 0, stdout: '## main...origin/main\n' }; + return { status: 1, stdout: '' }; + }); + + const cache = createGitStatusCache('/tmp/repo'); + expect(cache.getStatus()).not.toBeNull(); + await Promise.resolve(); + + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('git', '/tmp/repo'); + for (const call of mocks.spawnSync.mock.calls) { + expect(call[0]).toBe('/usr/bin/git'); + } + expect(mocks.execFile).toHaveBeenCalledWith( + '/usr/bin/gh', + expect.any(Array), + expect.anything(), + expect.any(Function), + ); + }); + it('returns null when the working directory is not a git repo and formats badges', () => { mocks.spawnSync.mockReturnValue({ status: 1, stdout: '' }); expect(createGitStatusCache('/tmp/not-a-repo').getStatus()).toBeNull(); diff --git a/apps/pythinker-code/test/utils/heap-dump.test.ts b/apps/pythinker-code/test/utils/heap-dump.test.ts deleted file mode 100644 index 967796e6..00000000 --- a/apps/pythinker-code/test/utils/heap-dump.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { readFile, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { Readable } from 'node:stream'; - -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { performHeapDump, type HeapDumpResult } from '#/utils/heap-dump'; - -const v8Mocks = vi.hoisted(() => ({ - getHeapSnapshot: vi.fn(), - getHeapSpaceStatistics: vi.fn(), - getHeapStatistics: vi.fn(), -})); - -vi.mock('node:v8', () => v8Mocks); - -const outputDirectories: string[] = []; - -afterEach(async () => { - vi.restoreAllMocks(); - await Promise.all(outputDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); -}); - -describe('performHeapDump', () => { - it('writes a private heap snapshot and memory diagnostics', async () => { - v8Mocks.getHeapSnapshot.mockReturnValue(Readable.from(['heap snapshot'])); - v8Mocks.getHeapStatistics.mockReturnValue({ - heap_size_limit: 1024, - malloced_memory: 128, - peak_malloced_memory: 256, - number_of_detached_contexts: 0, - number_of_native_contexts: 1, - }); - v8Mocks.getHeapSpaceStatistics.mockReturnValue([ - { - space_name: 'old_space', - space_size: 512, - space_used_size: 384, - space_available_size: 128, - physical_space_size: 512, - }, - ]); - const outputDirectory = join( - tmpdir(), - `pythinker-heap-dump-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - outputDirectories.push(outputDirectory); - - const result = await performHeapDump('session/unsafe', '1.2.3', outputDirectory); - - expect(result).toMatchObject({ - success: true, - heapPath: join(outputDirectory, 'session-unsafe.heapsnapshot'), - diagPath: join(outputDirectory, 'session-unsafe-diagnostics.json'), - }); - const successful = result as Extract<HeapDumpResult, { success: true }>; - expect(await readFile(successful.heapPath, 'utf8')).toBe('heap snapshot'); - expect(JSON.parse(await readFile(successful.diagPath, 'utf8'))).toMatchObject({ - sessionId: 'session/unsafe', - version: '1.2.3', - trigger: 'manual', - v8HeapStats: { - heapSizeLimit: 1024, - detachedContexts: 0, - }, - v8HeapSpaces: [{ name: 'old_space', size: 512, used: 384, available: 128 }], - }); - expect((await stat(successful.heapPath)).mode & 0o777).toBe(0o600); - expect((await stat(successful.diagPath)).mode & 0o777).toBe(0o600); - }); -}); diff --git a/apps/pythinker-code/test/utils/history/input-history.test.ts b/apps/pythinker-code/test/utils/history/input-history.test.ts index cee375f3..43269f3d 100644 --- a/apps/pythinker-code/test/utils/history/input-history.test.ts +++ b/apps/pythinker-code/test/utils/history/input-history.test.ts @@ -4,11 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, it, expect } from 'vitest'; -import { - appendInputHistory, - loadInputHistory, - selectRecentInputHistory, -} from '#/utils/history/input-history'; +import { loadInputHistory, appendInputHistory } from '#/utils/history/input-history'; let dir: string; let file: string; @@ -85,17 +81,4 @@ describe('input-history', () => { const entries = await loadInputHistory(file); expect(entries).toEqual([{ content: 'hi' }]); }); - - it('selects at most 100 unique recent entries in newest-first order', () => { - const entries = Array.from({ length: 101 }, (_, index) => ({ - content: `entry ${String(index)}`, - })); - entries.push({ content: 'entry 100' }, { content: ' newest\nprompt ' }); - - const selected = selectRecentInputHistory(entries); - - expect(selected).toHaveLength(100); - expect(selected.slice(0, 3)).toEqual(['newest\nprompt', 'entry 100', 'entry 99']); - expect(selected.at(-1)).toBe('entry 2'); - }); }); diff --git a/apps/pythinker-code/test/utils/open-url.test.ts b/apps/pythinker-code/test/utils/open-url.test.ts deleted file mode 100644 index 60d894c2..00000000 --- a/apps/pythinker-code/test/utils/open-url.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { openUrlCommandFor } from '#/utils/open-url'; - -const oauthUrl = - 'https://auth.openai.com/oauth/authorize?client_id=app_test&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=state'; - -describe('openUrlCommandFor', () => { - it('passes the complete OAuth URL to the Windows URL handler', () => { - expect(openUrlCommandFor(oauthUrl, 'win32')).toEqual({ - command: 'rundll32', - args: ['url.dll,FileProtocolHandler', oauthUrl], - }); - }); - - it('uses the native opener on macOS', () => { - expect(openUrlCommandFor(oauthUrl, 'darwin')).toEqual({ - command: 'open', - args: [oauthUrl], - }); - }); - - it('uses xdg-open on Linux', () => { - expect(openUrlCommandFor(oauthUrl, 'linux')).toEqual({ - command: 'xdg-open', - args: [oauthUrl], - }); - }); -}); diff --git a/apps/pythinker-code/test/utils/persistence.test.ts b/apps/pythinker-code/test/utils/persistence.test.ts index 611adc9f..35dcdc27 100644 --- a/apps/pythinker-code/test/utils/persistence.test.ts +++ b/apps/pythinker-code/test/utils/persistence.test.ts @@ -59,18 +59,7 @@ describe('persistence helpers', () => { await expect( readJsonFile(file, TestJsonSchema, { name: 'fallback', count: 0 }), - ).rejects.toThrowErrorMatchingInlineSnapshot(` - [ZodError: [ - { - "expected": "number", - "code": "invalid_type", - "path": [ - "count" - ], - "message": "Invalid input: expected number, received string" - } - ]] - `); + ).rejects.toThrow(); }); it('writeJsonFile refuses to write config.toml', async () => { diff --git a/apps/pythinker-code/test/utils/plugin-marketplace.test.ts b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts index 0f89a115..45c9cf14 100644 --- a/apps/pythinker-code/test/utils/plugin-marketplace.test.ts +++ b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts @@ -1,31 +1,17 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { PluginSummary } from '@pymodel/pythinker-code-sdk'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { - ANTHROPIC_PLUGIN_MARKETPLACE_URL, PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '#/constant/app'; -import { - computeMarketplaceEntryStatus, - computeUpdateStatus, - loadPluginMarketplace, - type PluginMarketplaceEntry, -} from '#/utils/plugin-marketplace'; - -const REPO_ROOT = join(import.meta.dirname, '../../../..'); -const SHA = '0123456789abcdef0123456789abcdef01234567'; -const NEXT_SHA = '89abcdef0123456789abcdef0123456789abcdef'; - -afterEach(() => { - vi.useRealTimers(); - vi.unstubAllEnvs(); -}); +import { computeUpdateStatus, loadPluginMarketplace } from '#/utils/plugin-marketplace'; + +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..'); describe('computeUpdateStatus', () => { it('reports not-installed when the plugin is absent', () => { @@ -34,19 +20,23 @@ describe('computeUpdateStatus', () => { it('reports an update when the marketplace version is newer', () => { expect(computeUpdateStatus('5.1.0', '5.0.0', true)).toEqual({ - kind: 'update', local: '5.0.0', latest: '5.1.0', + kind: 'update', + local: '5.0.0', + latest: '5.1.0', }); }); it('reports up-to-date when versions match', () => { expect(computeUpdateStatus('5.1.0', '5.1.0', true)).toEqual({ - kind: 'up-to-date', version: '5.1.0', + kind: 'up-to-date', + version: '5.1.0', }); }); it('does not offer a downgrade when the local version is ahead', () => { expect(computeUpdateStatus('3.1.1', '3.2.0', true)).toEqual({ - kind: 'up-to-date', version: '3.2.0', + kind: 'up-to-date', + version: '3.2.0', }); }); @@ -55,560 +45,547 @@ describe('computeUpdateStatus', () => { expect(computeUpdateStatus('5.1.0', 'dev', true).kind).toBe('up-to-date'); }); - it('shows only a known local version', () => { + it('shows the local version even when the marketplace omits one', () => { expect(computeUpdateStatus(undefined, '5.0.0', true)).toEqual({ - kind: 'up-to-date', version: '5.0.0', + kind: 'up-to-date', + version: '5.0.0', }); - expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ - kind: 'up-to-date', version: undefined, - }); - }); -}); - -describe('computeMarketplaceEntryStatus', () => { - it('compares immutable GitHub SHAs before semver', () => { - const installed = pluginSummary({ installedSha: SHA }); - expect(computeMarketplaceEntryStatus( - marketplaceEntry({ effectiveSha: NEXT_SHA }), - installed, - )).toEqual({ kind: 'update', local: SHA, latest: NEXT_SHA }); - expect(computeMarketplaceEntryStatus( - marketplaceEntry({ effectiveSha: SHA }), - installed, - )).toEqual({ kind: 'up-to-date', version: '1.0.0' }); }); - it('does not invent an update for an unpinned HEAD source', () => { - expect(computeMarketplaceEntryStatus(marketplaceEntry(), pluginSummary())).toEqual({ - kind: 'up-to-date', version: '1.0.0', + it('does not claim the marketplace version as installed when the local version is unknown', () => { + // No spurious `installed · v<latest>`, and no permanent suppression of updates. + expect(computeUpdateStatus('5.1.0', undefined, true)).toEqual({ + kind: 'up-to-date', + version: undefined, }); }); }); describe('loadPluginMarketplace', () => { - it('loads a local Pythinker marketplace and preserves relative sources', async () => { + it('loads a local marketplace file and resolves relative plugin sources', async () => { const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); - await writeFile(file, JSON.stringify({ - version: '1', - plugins: [{ + await writeFile( + file, + JSON.stringify({ + version: '1', + plugins: [ + { + id: 'pythinker-datasource', + tier: 'official', + displayName: 'Pythinker Datasource', + version: '1.0.0', + description: 'Datasource tools', + source: './pythinker-datasource', + keywords: ['data'], + }, + { + id: 'superpowers', + tier: 'curated', + displayName: 'Superpowers', + version: '5.1.0', + description: 'Workflow skills', + homepage: 'https://github.com/obra/superpowers', + source: './curated/superpowers', + keywords: ['skills', 'workflow'], + }, + ], + }), + 'utf8', + ); + + const marketplace = await loadPluginMarketplace({ + workDir: '/tmp/work', + source: file, + }); + + expect(marketplace.source).toBe(file); + expect(marketplace.version).toBe('1'); + expect(marketplace.plugins.slice(0, 2)).toEqual([ + { id: 'pythinker-datasource', - tier: 'official', displayName: 'Pythinker Datasource', + tier: 'official', version: '1.0.0', description: 'Datasource tools', - source: './pythinker-datasource', + source: join(dir, 'pythinker-datasource'), keywords: ['data'], - }], - }), 'utf8'); - - const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file }); - - expect(marketplace).toEqual(expect.objectContaining({ - format: 'pythinker', source: file, name: 'Pythinker', version: '1', - })); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - tier: 'official', - source: join(dir, 'pythinker-datasource'), - keywords: ['data'], - install: expect.objectContaining({ kind: 'supported' }), - })); + homepage: undefined, + }, + { + id: 'superpowers', + displayName: 'Superpowers', + tier: 'curated', + version: '5.1.0', + description: 'Workflow skills', + source: join(dir, 'curated', 'superpowers'), + keywords: ['skills', 'workflow'], + homepage: 'https://github.com/obra/superpowers', + }, + ]); }); - it('recognizes a named Pythinker marketplace by its id-based entries', async () => { + const builtInEntries = [ + { + id: 'pythinker-cu', + displayName: 'Pythinker Computer Use', + description: 'fake cu', + tier: 'official' as const, + source: 'capability:pythinker-cu', + }, + { + id: 'pythinker-webbridge', + displayName: 'Pythinker WebBridge', + description: 'fake wb', + tier: 'official' as const, + source: 'capability:pythinker-webbridge', + }, + ]; + + it('appends the caller-supplied built-in entries the catalog does not carry', async () => { const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); - await writeFile(file, JSON.stringify({ - name: 'Example Pythinker Marketplace', - owner: { name: 'Example Owner' }, - plugins: [{ id: 'demo', name: 'Demo', source: './demo' }], - }), 'utf8'); - - const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file }); + await writeFile(file, JSON.stringify({ version: '1', plugins: [] }), 'utf8'); - expect(marketplace.format).toBe('pythinker'); - expect(marketplace.name).toBe('Example Pythinker Marketplace'); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - id: 'demo', - source: join(dir, 'demo'), - install: expect.objectContaining({ kind: 'supported' }), - })); - }); - - it('includes Superpowers in the repository marketplace fixture', async () => { const marketplace = await loadPluginMarketplace({ - workDir: REPO_ROOT, - source: join(REPO_ROOT, 'plugins/marketplace.json'), + workDir: '/tmp/work', + source: file, + builtInEntries, }); - expect(marketplace.plugins).toContainEqual(expect.objectContaining({ - id: 'superpowers', - displayName: 'Superpowers', - tier: 'curated', - source: join(REPO_ROOT, 'plugins/curated/superpowers'), - })); + // The util owns no product knowledge: entries come from the caller (the + // engine's capability registry), and no version is invented. + expect(marketplace.plugins).toEqual(builtInEntries); + expect(marketplace.plugins.map((entry) => entry.version)).toEqual([undefined, undefined]); }); - it('loads the Pythinker alias through the environment override', async () => { - vi.stubEnv(PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL); - const fetchImpl = marketplaceFetch({ - plugins: [{ id: 'datasource', source: './official/datasource.zip' }], - }); + it('masks same-id catalog rows with the built-in entries', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'pythinker-webbridge', + tier: 'official', + displayName: 'Pythinker WebBridge', + version: '1.12.0', + source: './pythinker-webbridge', + }, + ], + }), + 'utf8', + ); const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', source: 'pythinker', fetchImpl, + workDir: '/tmp/work', + source: file, + builtInEntries, }); - expect(fetchImpl).toHaveBeenCalledWith( - PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, - { signal: expect.any(AbortSignal) }, - ); - expect(marketplace.sourceLabel).toBe('Pythinker'); - expect(marketplace.plugins[0]?.source).toBe( - new URL('./official/datasource.zip', PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL).toString(), - ); + // What the built-in ids mean stays decided by the client release: the + // catalog's row contributes the version, but not its source or copy. + const webbridge = marketplace.plugins.filter((entry) => entry.id === 'pythinker-webbridge'); + expect(webbridge).toHaveLength(1); + expect(webbridge[0]?.source).toBe('capability:pythinker-webbridge'); + expect(webbridge[0]?.version).toBe('1.12.0'); + expect(marketplace.plugins.some((entry) => entry.id === 'pythinker-cu')).toBe(true); }); - it('times out a remote request with one overall deadline', async () => { - vi.useFakeTimers(); - const fetchImpl = vi.fn((_input: unknown, init?: RequestInit) => { - if (init?.signal === undefined) throw new Error('missing marketplace abort signal'); - return new Promise<Response>(() => {}); + it('includes Superpowers in the repository marketplace fixture', async () => { + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + if (url.endsWith('/releases/latest')) { + return { + status: 302, + headers: new Headers({ + location: 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + }), + } as Response; + } + return { status: 404, headers: new Headers() } as Response; }) as unknown as typeof fetch; - - const loading = loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.test/marketplace.json', + const marketplace = await loadPluginMarketplace({ + workDir: REPO_ROOT, + source: join(REPO_ROOT, 'plugins/marketplace.json'), fetchImpl, - fetchTimeoutMs: 25, }); - const timedOut = expect(loading).rejects.toThrow(/timed out after 25ms/i); - await vi.advanceTimersByTimeAsync(25); - await timedOut; + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'superpowers', + displayName: 'Superpowers', + tier: 'curated', + source: 'https://github.com/obra/superpowers', + version: '6.0.3', + }), + ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'pythinker-datasource', + tier: 'official', + source: join(REPO_ROOT, 'plugins/official/pythinker-datasource'), + }), + ); }); - it('keeps the deadline active while reading the response body', async () => { - vi.useFakeTimers(); + it('loads the default CDN marketplace with injectable fetch', async () => { const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, - text: () => new Promise<string>((_resolve, reject) => { - setTimeout(() => reject(new Error('body remained pending')), 50); - }), + text: async () => + JSON.stringify({ + plugins: [ + { + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + source: './official/pythinker-datasource.zip', + }, + ], + }), })) as unknown as typeof fetch; - const loading = loadPluginMarketplace({ + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', - source: 'https://example.test/marketplace.json', + source: PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, fetchImpl, - fetchTimeoutMs: 25, }); - const timedOut = expect(loading).rejects.toThrow(/timed out after 25ms/i); - await vi.advanceTimersByTimeAsync(50); - await timedOut; + expect(fetchImpl).toHaveBeenCalledWith(PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL); + expect(marketplace.plugins[0]).toEqual( + expect.objectContaining({ + id: 'pythinker-datasource', + displayName: 'Pythinker Datasource', + source: new URL( + './official/pythinker-datasource.zip', + PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, + ).toString(), + }), + ); }); - it('loads the Anthropic alias as a GitHub marketplace', async () => { - const fetchImpl = marketplaceFetch(claudeCatalog([ - { name: 'review', source: './plugins/review' }, - ])); + it('falls back to the source checkout marketplace when the default CDN cannot be fetched', async () => { + const previous = process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + delete process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', source: 'anthropic', fetchImpl, - }); + try { + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', fetchImpl }); - expect(fetchImpl).toHaveBeenCalledWith( - ANTHROPIC_PLUGIN_MARKETPLACE_URL, - { signal: expect.any(AbortSignal) }, - ); - expect(marketplace).toEqual(expect.objectContaining({ - format: 'claude', - name: 'example-marketplace', - sourceLabel: 'Anthropic official', - owner: { name: 'Example Owner', email: undefined, url: undefined }, - })); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - id: 'review', - source: 'https://github.com/anthropics/claude-plugins-official/tree/HEAD', - repositorySubdirectory: 'plugins/review', - declaredRef: 'HEAD', - install: { - kind: 'supported', - source: 'https://github.com/anthropics/claude-plugins-official/tree/HEAD', - options: expect.objectContaining({ - repositorySubdirectory: 'plugins/review', - definition: expect.objectContaining({ id: 'review' }), + expect(fetchImpl).toHaveBeenCalledWith(PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL); + expect(marketplace.source).toBe(join(REPO_ROOT, 'plugins/marketplace.json')); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'superpowers', + source: 'https://github.com/obra/superpowers', }), - }, - })); + ); + } finally { + if (previous === undefined) { + delete process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV]; + } else { + process.env[PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV] = previous; + } + } }); - it('loads owner/repo and GitHub tree marketplace locations', async () => { - const fetchImpl = marketplaceFetch(claudeCatalog([])); - await loadPluginMarketplace({ workDir: '/tmp/does-not-exist', source: 'acme/plugins', fetchImpl }); - await loadPluginMarketplace({ - workDir: '/tmp/work', source: 'https://github.com/acme/plugins/tree/v2', fetchImpl, - }); + it('does not use the source checkout fallback for explicit marketplace sources', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; - expect(fetchImpl).toHaveBeenNthCalledWith( - 1, - 'https://raw.githubusercontent.com/acme/plugins/HEAD/.claude-plugin/marketplace.json', - { signal: expect.any(AbortSignal) }, - ); - expect(fetchImpl).toHaveBeenNthCalledWith( - 2, - 'https://raw.githubusercontent.com/acme/plugins/v2/.claude-plugin/marketplace.json', - { signal: expect.any(AbortSignal) }, - ); + await expect(loadPluginMarketplace({ + workDir: '/tmp/work', + source: PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL, + fetchImpl, + })).rejects.toThrow(/fetch failed/); }); - it('normalizes current Claude GitHub source variants and metadata', async () => { - const fetchImpl = marketplaceFetch(claudeCatalog([ - { - name: 'subdir-plugin', - displayName: 'Subdir Plugin', - description: 'Useful tools', - author: { name: 'Acme', url: 'https://example.com' }, - category: 'development', - keywords: ['tools'], - tags: ['community-managed'], - homepage: 'https://example.com/plugin', - strict: false, - defaultEnabled: false, - source: { - source: 'git-subdir', - url: 'https://github.com/acme/plugins.git', - path: 'plugins/subdir', - ref: 'main', - sha: SHA, - }, - skills: ['./skills'], - mcpServers: './.mcp.json', - hooks: './hooks.json', - }, - { - name: 'legacy-url-path', - source: { - source: 'url', - url: 'https://github.com/acme/legacy.git', - path: 'claude/plugin', - sha: NEXT_SHA, - }, - }, - { name: 'github-source', source: { source: 'github', repo: 'acme/simple', ref: 'v1' } }, - { name: 'npm-source', source: { source: 'npm', package: '@acme/plugin' } }, - ])); + it('keeps the built-in entries when the catalog is unreachable', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('fetch failed'); + }) as unknown as typeof fetch; + // Explicit source (no checkout fallback) + unreachable: the built-ins do + // not come from the catalog, so they must survive the outage. const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', source: 'https://example.com/marketplace.json', fetchImpl, + workDir: '/tmp/work', + source: 'https://example.test/marketplace.json', + fetchImpl, + builtInEntries, }); - const [subdir, legacy, github, npm] = marketplace.plugins; - - expect(subdir).toEqual(expect.objectContaining({ - id: 'subdir-plugin', - displayName: 'Subdir Plugin', - author: { name: 'Acme', email: undefined, url: 'https://example.com' }, - category: 'development', - keywords: ['tools'], - tags: ['community-managed'], - strict: false, - defaultEnabled: false, - declaredRef: 'main', - effectiveSha: SHA, - repositorySubdirectory: 'plugins/subdir', - supportedComponents: ['skills', 'mcpServers'], - unsupportedComponents: ['hooks'], - install: { - kind: 'supported', - source: `https://github.com/acme/plugins/tree/${SHA}`, - options: expect.objectContaining({ - repositorySubdirectory: 'plugins/subdir', - definition: expect.objectContaining({ - id: 'subdir-plugin', - strict: false, - defaultEnabled: false, - unsupportedComponents: ['hooks'], - }), + + expect(marketplace.plugins.map((entry) => entry.id)).toEqual(['pythinker-cu', 'pythinker-webbridge']); + }); + + describe('version derivation from a GitHub source', () => { + async function loadEntry(source: string, version?: string) { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + source, + version, + }, + ], }), - }, - })); - expect(legacy).toEqual(expect.objectContaining({ - source: `https://github.com/acme/legacy/tree/${NEXT_SHA}`, - repositorySubdirectory: 'claude/plugin', - })); - expect(github).toEqual(expect.objectContaining({ - source: 'https://github.com/acme/simple/tree/v1', declaredRef: 'v1', - })); - expect(npm?.install).toEqual({ - kind: 'unsupported', reason: 'npm plugin sources are not supported.', + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file }); + return marketplace.plugins[0]!; + } + + it('derives a version from a /releases/tag/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/v6.0.3'); + expect(entry.version).toBe('6.0.3'); }); - }); - it('prepends catalog pluginRoot to local relative Claude plugin sources', async () => { - const root = await mkdtemp(join(tmpdir(), 'claude-marketplace-')); - await mkdir(join(root, '.claude-plugin')); - await writeFile( - join(root, '.claude-plugin', 'marketplace.json'), - JSON.stringify({ - ...claudeCatalog([{ name: 'local-plugin', source: 'formatter' }]), - metadata: { pluginRoot: './plugins' }, - }), - 'utf8', - ); + it('derives a version from a /tree/ source', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/tree/v6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); - const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: root }); + it('accepts a tag without a leading v', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/releases/tag/6.0.3'); + expect(entry.version).toBe('6.0.3'); + }); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - source: join(root, 'plugins/formatter'), - repositorySubdirectory: undefined, - install: expect.objectContaining({ kind: 'supported' }), - })); - }); + it('does not derive a version from a commit SHA', async () => { + const entry = await loadEntry('https://github.com/obra/superpowers/commit/abc1234'); + expect(entry.version).toBeUndefined(); + }); - it('prepends catalog pluginRoot to GitHub-relative Claude plugin sources', async () => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'acme/catalog', - fetchImpl: marketplaceFetch({ - ...claudeCatalog([{ name: 'formatter', source: 'formatter' }]), - metadata: { pluginRoot: './plugins' }, - }), + it('does not derive a version from a non-GitHub URL', async () => { + const entry = await loadEntry('https://code.kimi.com/pythinker-code/plugins/curated/superpowers.zip'); + expect(entry.version).toBeUndefined(); }); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - source: 'https://github.com/acme/catalog/tree/HEAD', - repositorySubdirectory: 'plugins/formatter', - install: expect.objectContaining({ kind: 'supported' }), - })); + it('lets an explicit version override the derived one', async () => { + const entry = await loadEntry( + 'https://github.com/obra/superpowers/releases/tag/v6.0.3', + '9.9.9', + ); + expect(entry.version).toBe('9.9.9'); + }); }); - it.each([ - 'git://example.test/acme/plugin.git', - 'git+https://example.test/acme/plugin.git', - ])('keeps generic Git source %s visible but unavailable', async (source) => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([{ name: 'generic-git', source }])), + describe('latest release resolution for bare GitHub sources', () => { + async function loadWithLatest(source: string, fetchImpl: typeof fetch) { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'demo', displayName: 'Demo', source }] }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + return marketplace.plugins[0]!; + } + + function redirectFetch(location: string): typeof fetch { + return vi.fn(async () => ({ + status: 302, + headers: new Headers({ location }), + })) as unknown as typeof fetch; + } + + it('fills the version from /releases/latest for a bare repo URL', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/v6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); }); - expect(marketplace.plugins[0]?.install).toEqual({ - kind: 'unsupported', - reason: 'Generic Git plugin sources are not supported.', + it('strips a leading v from the resolved latest tag', async () => { + const entry = await loadWithLatest( + 'https://github.com/owner/repo', + redirectFetch('https://github.com/owner/repo/releases/tag/6.0.3'), + ); + expect(entry.version).toBe('6.0.3'); }); - }); - it('keeps direct-URL relative plugins visible but unavailable', async () => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([{ name: 'relative', source: './plugin' }])), + it('leaves version undefined when the repo has no release', async () => { + const fetchImpl = vi.fn(async () => ({ + status: 404, + headers: new Headers(), + })) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); }); - expect(marketplace.plugins[0]?.install).toEqual({ - kind: 'unsupported', - reason: 'Relative Claude plugin sources require a GitHub repository or local marketplace directory.', + it('degrades gracefully when the latest lookup throws', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest('https://github.com/owner/repo', fetchImpl); + expect(entry.version).toBeUndefined(); }); - }); - it.each([ - ['missing marketplace name', { owner: { name: 'Owner' }, plugins: [{ name: 'demo', source: './demo' }] }, /must define "name"/], - ['missing owner', { name: 'catalog', plugins: [{ name: 'demo', source: './demo' }] }, /owner.*name/], - ['missing plugin name', claudeCatalog([{ source: './demo' }]), /must define "name"/], - ['duplicate plugin names', claudeCatalog([{ name: 'Demo', source: './a' }, { name: 'demo', source: './b' }]), /duplicate plugin name/], - ['invalid SHA', claudeCatalog([{ name: 'demo', source: { source: 'url', url: 'https://github.com/acme/demo.git', sha: 'abc' } }]), /40-character hexadecimal SHA/], - ['traversing path', claudeCatalog([{ name: 'demo', source: '../demo' }]), /stay inside its repository/], - ['backslash path', claudeCatalog([{ name: 'demo', source: '.\\demo' }]), /absolute or unsafe/], - ])('rejects %s', async (_name, catalog, error) => { - await expect(loadPluginMarketplace({ - workDir: '/tmp/work', source: 'acme/catalog', fetchImpl: marketplaceFetch(catalog), - })).rejects.toThrow(error as RegExp); - }); + it('does not query latest when the source already pins a ref', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const entry = await loadWithLatest( + 'https://github.com/owner/repo/releases/tag/v6.0.3', + fetchImpl, + ); + expect(entry.version).toBe('6.0.3'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); - it('encodes object GitHub refs without flattening valid multi-segment refs', async () => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([ - { - name: 'reserved-ref', - source: { source: 'github', repo: 'acme/plugin', ref: 'release#1' }, - }, - { - name: 'multi-segment-ref', - source: { source: 'github', repo: 'acme/plugin', ref: 'feature/release 1' }, - }, - ])), + it('keeps an explicit version without querying latest', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('should not be called'); + }) as unknown as typeof fetch; + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'demo', + displayName: 'Demo', + version: '9.9.9', + source: 'https://github.com/owner/repo', + }, + ], + }), + 'utf8', + ); + const marketplace = await loadPluginMarketplace({ workDir: dir, source: file, fetchImpl }); + expect(marketplace.plugins[0]?.version).toBe('9.9.9'); + expect(fetchImpl).not.toHaveBeenCalled(); }); + }); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - source: 'https://github.com/acme/plugin/tree/release%231', - declaredRef: 'release#1', - })); - expect(new URL(marketplace.plugins[0]!.source).hash).toBe(''); - expect(marketplace.plugins[1]).toEqual(expect.objectContaining({ - source: 'https://github.com/acme/plugin/tree/feature/release%201', - declaredRef: 'feature/release 1', - })); + it('accepts legacy marketplace type aliases as normal plugins', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [ + { + id: 'pythinker-webbridge', + type: 'guide', + displayName: 'Pythinker WebBridge', + source: './pythinker-webbridge', + installSkill: 'install', + removeSkill: 'remove', + }, + { + id: 'demo-managed', + type: 'managed', + source: './demo-managed', + }, + ], + }), + 'utf8', + ); + + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source: file }); + + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'pythinker-webbridge', + source: join(dir, 'pythinker-webbridge'), + }), + ); + expect(marketplace.plugins).toContainEqual( + expect.objectContaining({ + id: 'demo-managed', + source: join(dir, 'demo-managed'), + }), + ); }); - it('decodes and safely re-encodes GitHub tree refs for catalogs and plugins', async () => { - const fetchImpl = marketplaceFetch(claudeCatalog([ - { name: 'encoded-ref', source: 'https://github.com/acme/plugin/tree/release%231' }, - ])); - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://github.com/acme/catalog/tree/feature/release%201', - fetchImpl, - }); + it('rejects an entry without a source', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ plugins: [{ id: 'broken', displayName: 'Broken' }] }), + 'utf8', + ); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://raw.githubusercontent.com/acme/catalog/feature/release%201/.claude-plugin/marketplace.json', - { signal: expect.any(AbortSignal) }, + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /must define "source"/, ); - expect(marketplace.plugins[0]).toEqual(expect.objectContaining({ - source: 'https://github.com/acme/plugin/tree/release%231', - declaredRef: 'release#1', - })); }); - it.each(['feature//x', 'feature/./x', 'feature/../x'])( - 'rejects unsafe object GitHub ref %s', - async (ref) => { - await expect(loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([ - { name: 'unsafe-ref', source: { source: 'github', repo: 'acme/plugin', ref } }, - ])), - })).rejects.toThrow(/GitHub ref must not contain empty/); - }, - ); - - it.each([ - 'feature//x', - 'feature/./x', - 'feature/../x', - 'feature/%2E%2E/x', - 'feature%2F%2E%2E%2Fx', - ])('keeps unsafe GitHub URL ref %s visible as unsupported', async (ref) => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([ - { name: 'unsafe-ref', source: `https://github.com/acme/plugin/tree/${ref}` }, - ])), - }); + it('loads an explicit remote marketplace with injectable fetch', async () => { + const source = 'https://example.com/plugins/marketplace.json'; + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + plugins: [{ id: 'superpowers', name: 'Superpowers', url: 'superpowers.zip' }], + }), + })) as unknown as typeof fetch; - expect(marketplace.plugins[0]?.install).toEqual({ - kind: 'unsupported', - reason: 'Only GitHub-backed Claude plugin sources are supported.', - }); + const marketplace = await loadPluginMarketplace({ workDir: '/tmp/work', source, fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith(source); + expect(marketplace.plugins[0]).toEqual( + expect.objectContaining({ + id: 'superpowers', + displayName: 'Superpowers', + source: new URL('superpowers.zip', source).toString(), + }), + ); }); - it('keeps malformed percent-encoded GitHub refs visible as unsupported', async () => { - const marketplace = await loadPluginMarketplace({ - workDir: '/tmp/work', - source: 'https://example.com/.claude-plugin/marketplace.json', - fetchImpl: marketplaceFetch(claudeCatalog([ - { name: 'malformed-ref', source: 'https://github.com/acme/plugin/tree/%zz' }, - ])), - }); + it('rejects malformed marketplace entries', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile(file, JSON.stringify({ plugins: [{ displayName: 'Missing id' }] }), 'utf8'); - expect(marketplace.plugins[0]?.install).toEqual({ - kind: 'unsupported', - reason: 'Only GitHub-backed Claude plugin sources are supported.', - }); + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /must define "id"/, + ); }); - it('rejects unknown Pythinker marketplace tier values', async () => { + it('rejects unknown marketplace tier values', async () => { const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); const file = join(dir, 'marketplace.json'); - await writeFile(file, JSON.stringify({ - plugins: [{ id: 'demo', tier: 'community', source: './demo' }], - }), 'utf8'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', tier: 'community', source: './demo' }], + }), + 'utf8', + ); await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( /"tier" must be one of/, ); }); -}); -function claudeCatalog(plugins: readonly unknown[]): Record<string, unknown> { - return { - name: 'example-marketplace', - description: 'Example catalog', - owner: { name: 'Example Owner' }, - plugins, - }; -} - -function marketplaceFetch(catalog: unknown): typeof fetch { - return vi.fn(async () => ({ - ok: true, - status: 200, - text: async () => JSON.stringify(catalog), - })) as unknown as typeof fetch; -} - -function marketplaceEntry( - overrides: Partial<PluginMarketplaceEntry> = {}, -): PluginMarketplaceEntry { - return { - id: 'demo', - displayName: 'Demo', - source: 'https://github.com/acme/demo/tree/HEAD', - sourceLabel: 'acme/demo@HEAD', - marketplaceName: 'example', - marketplaceOwner: 'Example', - tier: undefined, - version: '1.0.0', - description: undefined, - author: undefined, - homepage: undefined, - repository: 'https://github.com/acme/demo', - license: undefined, - category: undefined, - keywords: undefined, - tags: undefined, - strict: undefined, - defaultEnabled: undefined, - supportedComponents: [], - unsupportedComponents: [], - declaredRef: 'HEAD', - effectiveSha: undefined, - github: { owner: 'acme', repo: 'demo' }, - repositorySubdirectory: undefined, - install: { kind: 'unsupported', reason: 'not used by this test' }, - ...overrides, - }; -} - -function pluginSummary(options: { installedSha?: string } = {}): PluginSummary { - return { - id: 'demo', - displayName: 'Demo', - version: '1.0.0', - description: undefined, - enabled: true, - state: 'ok', - source: 'github', - originalSource: 'https://github.com/acme/demo/tree/HEAD', - skillCount: 0, - mcpServerCount: 0, - enabledMcpServerCount: 0, - hasErrors: false, - github: { - owner: 'acme', - repo: 'demo', - ref: options.installedSha === undefined - ? { kind: 'branch', value: 'HEAD' } - : { kind: 'sha', value: options.installedSha }, - installedSha: options.installedSha, - }, - } as PluginSummary; -} + it('rejects unknown marketplace entry types', async () => { + const dir = await mkdtemp(join(tmpdir(), 'pythinker-plugin-marketplace-')); + const file = join(dir, 'marketplace.json'); + await writeFile( + file, + JSON.stringify({ + plugins: [{ id: 'demo', type: 'integration', source: './demo' }], + }), + 'utf8', + ); + + await expect(loadPluginMarketplace({ workDir: '/tmp/work', source: file })).rejects.toThrow( + /Legacy aliases "managed" and "guide" are also accepted/, + ); + }); + +}); diff --git a/apps/pythinker-code/test/utils/process/fd-detect.test.ts b/apps/pythinker-code/test/utils/process/fd-detect.test.ts index 0521437f..8576bb36 100644 --- a/apps/pythinker-code/test/utils/process/fd-detect.test.ts +++ b/apps/pythinker-code/test/utils/process/fd-detect.test.ts @@ -7,6 +7,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { detectFdPath, getFdAssetName } from '#/utils/process/fd-detect'; import { getBinDir } from '#/utils/paths'; +const mocks = vi.hoisted(() => ({ + resolveCommandPath: vi.fn(), + spawnSync: vi.fn(), +})); + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, +})); +vi.mock('node:child_process', () => ({ spawnSync: mocks.spawnSync })); + const originalEnv = { ...process.env }; let tempHome: string | undefined; @@ -16,6 +26,7 @@ afterEach(() => { tempHome = undefined; } process.env = { ...originalEnv }; + vi.clearAllMocks(); vi.unstubAllGlobals(); }); @@ -43,6 +54,20 @@ describe('getFdAssetName', () => { }); describe('detectFdPath', () => { + it('returns the absolute resolved path for a system fd binary', () => { + tempHome = mkdtempSync(join(tmpdir(), 'pythinker-fd-home-')); + process.env['PYTHINKER_CODE_HOME'] = tempHome; + mocks.resolveCommandPath.mockImplementation((name: string) => + name === 'fd' ? '/usr/local/bin/fd' : undefined, + ); + mocks.spawnSync.mockReturnValue({ status: 0 }); + + expect(detectFdPath()).toBe('/usr/local/bin/fd'); + expect(mocks.spawnSync).toHaveBeenCalledWith('/usr/local/bin/fd', ['--version'], { + stdio: 'ignore', + }); + }); + it('prefers the managed fd binary under PYTHINKER_CODE_HOME', () => { tempHome = mkdtempSync(join(tmpdir(), 'pythinker-fd-home-')); process.env['PYTHINKER_CODE_HOME'] = tempHome; diff --git a/apps/pythinker-code/test/utils/process/resolve-command.test.ts b/apps/pythinker-code/test/utils/process/resolve-command.test.ts new file mode 100644 index 00000000..1bff7b5d --- /dev/null +++ b/apps/pythinker-code/test/utils/process/resolve-command.test.ts @@ -0,0 +1,147 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +const originalEnv = { ...process.env }; +const originalPlatform = process.platform; +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + process.env = { ...originalEnv }; + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }); +} + +describe('resolveCommandPath (posix)', () => { + // Executable-bit checks only work on a posix host. + it.skipIf(process.platform === 'win32')('resolves an executable from PATH to an absolute path', () => { + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const tool = join(bin, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBe(tool); + }); + + it.skipIf(process.platform === 'win32')('ignores PATH files without the executable bit', () => { + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + writeFileSync(join(bin, 'mytool'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(bin, 'mytool'), 0o644); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit inside the current working directory', () => { + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + // The cwd itself sits on PATH (e.g. a `.` entry) — the planted binary + // must be rejected, not executed. + process.env['PATH'] = cwd; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit from a relative PATH entry landing in the cwd', () => { + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = '.'; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit in a subdirectory of the cwd', () => { + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const nested = join(cwd, 'bin'); + mkdirSync(nested); + const tool = join(nested, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = nested; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it('returns undefined when the command is not on PATH', () => { + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + process.env['PATH'] = bin; + + expect(resolveCommandPath('definitely-not-a-real-command', cwd)).toBeUndefined(); + }); +}); + +describe('resolveCommandPath (win32)', () => { + it('resolves a bare name through PATHEXT', () => { + mockPlatform('win32'); + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + // Windows is case-insensitive, so the resolved name carries the PATHEXT + // casing; match it here so the test also passes on case-insensitive + // posix filesystems. + const shim = join(bin, 'npm.CMD'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBe(shim); + }); + + it('tries an explicitly suffixed name as-is', () => { + mockPlatform('win32'); + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const shim = join(bin, 'npm.cmd'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm.cmd', cwd)).toBe(shim); + }); + + it('falls back to the default PATHEXT when the variable is unset', () => { + mockPlatform('win32'); + const bin = makeTempDir('pythinker-resolve-bin-'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + const shim = join(bin, 'bun.EXE'); + writeFileSync(shim, 'MZ'); + process.env['PATH'] = bin; + delete process.env['PATHEXT']; + + expect(resolveCommandPath('bun', cwd)).toBe(shim); + }); + + it('refuses a hit inside the current working directory', () => { + mockPlatform('win32'); + const cwd = makeTempDir('pythinker-resolve-cwd-'); + writeFileSync(join(cwd, 'npm.cmd'), '@echo off\r\n'); + process.env['PATH'] = cwd; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBeUndefined(); + }); +}); diff --git a/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts b/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts new file mode 100644 index 00000000..cacb8868 --- /dev/null +++ b/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts @@ -0,0 +1,584 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; + +import { resolvePythinkerCodeOAuthKey } from '@pymodel/pythinker-code-oauth'; +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = join(import.meta.dirname, '../../../..'); +const SERVER_ENTRY = join(REPO_ROOT, 'plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs'); + +describe('pythinker-datasource MCP server', () => { + it('exposes the same two generic tools as the Python plugin', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + try { + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + join(pythinkerHome, 'credentials', 'pythinker-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + PYTHINKER_CODE_HOME: pythinkerHome, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/list', {}); + + expect(result.error).toBeUndefined(); + const tools = (result.result as { tools: Array<{ name: string }> }).tools; + expect(tools.map((tool) => tool.name)).toEqual(['call_data_source_tool', 'get_data_source_desc']); + } finally { + child?.stdin.end(); + child?.kill(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('prefers assistant text and writes response files', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + const textFile = join(tempDir, 'world-bank.csv'); + const binaryFile = join(tempDir, 'world-bank_payload.csv'); + const blockedFile = join(tempDir, 'blocked.csv'); + const requests: unknown[] = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleMockDatasourceRequest(request, response, { + requests, + textFile, + binaryFile, + blockedFile, + }); + }); + + try { + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + join(pythinkerHome, 'credentials', 'pythinker-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + PYTHINKER_CODE_HOME: pythinkerHome, + PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'call_data_source_tool', + arguments: { + data_source_name: 'world_bank_open_data', + api_name: 'world_bank_open_data', + params: { filepath: textFile }, + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(JSON.stringify(result.result)).toContain('skipped returned file'); + expect(await readFile(textFile, 'utf8')).toBe('country,value\nCN,1\n'); + expect(await readFile(binaryFile, 'utf8')).toBe('binary payload'); + await expect(readFile(blockedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + expect(requests).toEqual([ + { + authorization: 'Bearer test-token', + method: 'call_data_source_tool', + params: { + data_source_name: 'world_bank_open_data', + api_name: 'world_bank_open_data', + params: { filepath: textFile }, + }, + url: '/', + }, + ]); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('uses env-scoped credentials and derives the datasource URL from PYTHINKER_CODE_BASE_URL', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + const requests: unknown[] = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleMockDatasourceRequest(request, response, { + requests, + textFile: join(tempDir, 'unused.csv'), + binaryFile: join(tempDir, 'unused_payload.csv'), + blockedFile: join(tempDir, 'blocked.csv'), + }); + }); + + try { + await listen(server); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`; + const oauthHost = 'https://auth.dev.example.test'; + const scopedCredential = pythinkerCodeEnvCredentialName({ oauthHost, baseUrl }); + + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + join(pythinkerHome, 'credentials', 'pythinker-code.json'), + JSON.stringify({ access_token: 'expired-prod-token', expires_at: 1 }), + 'utf8', + ); + await writeFile( + join(pythinkerHome, 'credentials', `${scopedCredential}.json`), + JSON.stringify({ access_token: 'scoped-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + PYTHINKER_CODE_HOME: pythinkerHome, + PYTHINKER_CODE_BASE_URL: baseUrl, + PYTHINKER_CODE_OAUTH_HOST: oauthHost, + PYTHINKER_DATASOURCE_API_URL: undefined, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { + name: 'arxiv', + }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(requests).toEqual([ + { + authorization: 'Bearer scoped-token', + method: 'get_data_source_desc', + params: { name: 'arxiv' }, + url: '/coding/v1/tools', + }, + ]); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('retries with a rotated credential when the backend rejects the previous token', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + const credentialsFile = join(pythinkerHome, 'credentials', 'pythinker-code.json'); + const authorizations: Array<string | undefined> = []; + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + void handleCredentialRotationRequest(request, response, { + authorizations, + credentialsFile, + }); + }); + + try { + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + credentialsFile, + JSON.stringify({ access_token: 'previous-token', expires_at: 1 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + PYTHINKER_CODE_HOME: pythinkerHome, + PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { name: 'imf' }, + }); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + content: [ + { + type: 'text', + text: expect.stringContaining('assistant complete result'), + }, + ], + }); + expect(authorizations).toEqual(['Bearer previous-token', 'Bearer refreshed-token']); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('returns the complete data-source routing contract when tools are listed', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + try { + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + join(pythinkerHome, 'credentials', 'pythinker-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { ...process.env, PYTHINKER_CODE_HOME: pythinkerHome }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/list', {}); + + const tools = ( + result.result as { + tools: Array<{ + name: string; + description: string; + inputSchema: { + properties: Record<string, { description?: string; enum?: string[] }>; + }; + }>; + } + ).tools; + const call = tools.find((tool) => tool.name === 'call_data_source_tool'); + const desc = tools.find((tool) => tool.name === 'get_data_source_desc'); + expect(desc?.inputSchema.properties['name']?.enum).toEqual([ + 'stock_finance_data', + 'yahoo_finance', + 'world_bank_open_data', + 'tianyancha', + 'arxiv', + 'scholar', + 'yuandian_law', + 'wind', + 'imf', + 'gildata', + 'sec_edgar', + 'sp_data', + ]); + expect(call?.description).toContain( + 'For a simple lookup, use one specialized source and stop after its first successful result', + ); + expect(call?.description).toContain('When the user names a data source, use that source'); + expect(call?.inputSchema.properties['data_source_name']?.description).toContain( + 'When the user names a source, pass that source', + ); + expect(desc?.description).toContain('choose exactly one specialized source'); + expect(desc?.inputSchema.properties['name']?.description).toContain( + 'yahoo_finance FX history is limited to about 2 years', + ); + } finally { + child?.stdin.end(); + child?.kill(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('appends a request-id / tool-call-id trace line to tool results', async () => { + const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); + const pythinkerHome = join(tempDir, 'pythinker-home'); + let child: ChildProcessWithoutNullStreams | undefined; + + const server = createServer((request, response) => { + request.on('data', () => {}); + request.on('end', () => { + response.setHeader('x-request-id', 'backend-req-test'); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ is_success: true, result: { assistant: [{ type: 'text', text: 'ok' }] } }), + ); + }); + }); + + try { + await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); + await writeFile( + join(pythinkerHome, 'credentials', 'pythinker-code.json'), + JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + await listen(server); + + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Expected an ephemeral TCP port for the test server.'); + } + + child = spawn(process.execPath, [SERVER_ENTRY], { + cwd: REPO_ROOT, + env: { + ...process.env, + PYTHINKER_CODE_HOME: pythinkerHome, + PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const client = createRpcClient(child); + + await client.request('initialize', {}); + const result = await client.request('tools/call', { + name: 'get_data_source_desc', + arguments: { name: 'yuandian_law' }, + }); + + const text = (result.result as { content: Array<{ text: string }> }).content[0]!.text; + expect(text).toContain('[pythinker-datasource] request-id: backend-req-test · tool-call-id:'); + } finally { + child?.stdin.end(); + child?.kill(); + await closeServer(server); + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +// Pin the expected credential file name to the canonical OAuth-key resolver so +// this test fails if the plugin's standalone digest drifts from the source of +// truth in @pymodel/pythinker-code-oauth. The credential file name is the OAuth +// key with its `oauth/` prefix stripped. +function pythinkerCodeEnvCredentialName(options: { + readonly oauthHost: string; + readonly baseUrl: string; +}): string { + return resolvePythinkerCodeOAuthKey(options).replace(/^oauth\//, ''); +} + +async function readJson(request: IncomingMessage): Promise<unknown> { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + return JSON.parse(body); +} + +async function handleMockDatasourceRequest( + request: IncomingMessage, + response: ServerResponse, + options: { + readonly requests: unknown[]; + readonly textFile: string; + readonly binaryFile: string; + readonly blockedFile: string; + }, +): Promise<void> { + try { + options.requests.push({ + ...(await readJson(request) as Record<string, unknown>), + authorization: request.headers.authorization, + url: request.url, + }); + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify({ + is_success: true, + result: { + assistant: [{ type: 'text', text: 'assistant complete result' }], + user: [{ type: 'text', text: '{"data_preview": null}' }], + }, + files: [ + { name: options.textFile, content: 'country,value\nCN,1\n' }, + { + name: options.binaryFile, + content: Buffer.from('binary payload').toString('base64'), + encoding: 'base64', + }, + { name: options.blockedFile, content: 'blocked\n' }, + ], + }), + ); + } catch (error) { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + } +} + +async function handleCredentialRotationRequest( + request: IncomingMessage, + response: ServerResponse, + options: { + readonly authorizations: Array<string | undefined>; + readonly credentialsFile: string; + }, +): Promise<void> { + try { + await readJson(request); + options.authorizations.push(request.headers.authorization); + response.setHeader('Content-Type', 'application/json'); + + if (options.authorizations.length === 1) { + await writeFile( + options.credentialsFile, + JSON.stringify({ access_token: 'refreshed-token', expires_at: 4_102_444_800 }), + 'utf8', + ); + response.statusCode = 401; + response.end(JSON.stringify({ error: 'expired access token' })); + return; + } + + response.end( + JSON.stringify({ + is_success: true, + result: { assistant: [{ type: 'text', text: 'assistant complete result' }] }, + }), + ); + } catch (error) { + response.statusCode = 500; + response.end(error instanceof Error ? error.message : String(error)); + } +} + +function listen(server: ReturnType<typeof createServer>): Promise<void> { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function closeServer(server: ReturnType<typeof createServer>): Promise<void> { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +function createRpcClient(child: ChildProcessWithoutNullStreams) { + let nextId = 1; + const stderr: string[] = []; + const pending = new Map< + number, + { + resolve: (value: JsonRpcResponse) => void; + reject: (err: Error) => void; + timeout: NodeJS.Timeout; + } + >(); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr.push(chunk); + }); + + const lines = createInterface({ input: child.stdout }); + lines.on('line', (line) => { + const message = JSON.parse(line) as JsonRpcResponse; + const id = typeof message.id === 'number' ? message.id : undefined; + if (id === undefined) return; + const waiter = pending.get(id); + if (waiter === undefined) return; + clearTimeout(waiter.timeout); + pending.delete(id); + waiter.resolve(message); + }); + + child.on('exit', (code, signal) => { + for (const [id, waiter] of pending) { + clearTimeout(waiter.timeout); + waiter.reject(new Error(`MCP server exited before response ${id}: code=${code}, signal=${signal}.`)); + } + pending.clear(); + }); + + return { + request(method: string, params: unknown): Promise<JsonRpcResponse> { + const id = nextId++; + const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out waiting for MCP response ${id}. stderr: ${stderr.join('')}`)); + }, 5_000); + pending.set(id, { resolve, reject, timeout }); + child.stdin.write(payload); + }); + }, + }; +} + +interface JsonRpcResponse { + id?: number; + result?: unknown; + error?: unknown; +} diff --git a/apps/pythinker-code/test/utils/usage/debug-timing.test.ts b/apps/pythinker-code/test/utils/usage/debug-timing.test.ts index be871f3c..5cd04ee2 100644 --- a/apps/pythinker-code/test/utils/usage/debug-timing.test.ts +++ b/apps/pythinker-code/test/utils/usage/debug-timing.test.ts @@ -27,6 +27,38 @@ describe('formatStepDebugTiming', () => { expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); }); + it('formats input tokens and cache read/write counts', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 700, + inputCacheRead: 1200, + inputCacheCreation: 100, + output: 200, + }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s) | tokens in 2k | cache read 1.2k (60%) / write 100', + ); + }); + + it('omits cache write count when it is zero', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + usage: { + inputOther: 1000, + inputCacheRead: 0, + inputCacheCreation: 0, + output: 200, + }, + }); + expect(result).toContain('tokens in 1000'); + expect(result).toContain('cache read 0 (0%)'); + expect(result).not.toContain('/ write 0'); + }); + it('omits TPS when the streamed window is too short to measure', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1200, @@ -57,6 +89,52 @@ describe('formatStepDebugTiming', () => { expect(result).toContain('900ms'); }); + it('splits TTFT into api-server and client portions when both are present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 2500, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 2400, + llmRequestBuildMs: 100, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 2.5s (api 2.4s + client 100ms) | TPS: 40.0 tok/s (200 tokens in 5.0s)', + ); + }); + + it('falls back to the bare TTFT when only one split component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerFirstTokenMs: 700, + usage: { output: 0 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms'); + }); + + it('appends the decode wait/consume split to the TPS clause', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + llmClientConsumeMs: 400, + usage: { output: 200 }, + }); + expect(result).toBe( + '[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s; server 4.6s + client 400ms)', + ); + }); + + it('omits the decode split when only one component is present', () => { + const result = formatStepDebugTiming({ + llmFirstTokenLatencyMs: 800, + llmStreamDurationMs: 5000, + llmServerDecodeMs: 4600, + usage: { output: 200 }, + }); + expect(result).toBe('[Debug] TTFT: 800ms | TPS: 40.0 tok/s (200 tokens in 5.0s)'); + }); + it('formats durations at or above 1s as seconds', () => { const result = formatStepDebugTiming({ llmFirstTokenLatencyMs: 1500, diff --git a/apps/pythinker-code/test/utils/usage/usage-format.test.ts b/apps/pythinker-code/test/utils/usage/usage-format.test.ts index d35de518..f2207828 100644 --- a/apps/pythinker-code/test/utils/usage/usage-format.test.ts +++ b/apps/pythinker-code/test/utils/usage/usage-format.test.ts @@ -16,17 +16,27 @@ describe('formatTokenCount', () => { expect(formatTokenCount(999)).toBe('999'); }); - it('uses 1024-based k units', () => { + it('switches to k at 1024 and trims a redundant ".0"', () => { expect(formatTokenCount(1_000)).toBe('1000'); expect(formatTokenCount(1_024)).toBe('1k'); expect(formatTokenCount(1_536)).toBe('1.5k'); + expect(formatTokenCount(2_048)).toBe('2k'); + }); + + it('rounds k values to 1 decimal', () => { + expect(formatTokenCount(50_552)).toBe('49.4k'); expect(formatTokenCount(262_144)).toBe('256k'); }); - it('switches to M at 1024 squared', () => { - expect(formatTokenCount(1_000_000)).toBe('977k'); + it('rounds k values at or above 100k to whole k', () => { + expect(formatTokenCount(102_400)).toBe('100k'); + expect(formatTokenCount(999_999)).toBe('977k'); + }); + + it('switches to M at 1024*1024', () => { expect(formatTokenCount(1_048_576)).toBe('1M'); expect(formatTokenCount(1_572_864)).toBe('1.5M'); + expect(formatTokenCount(10_485_760)).toBe('10M'); }); it('clamps negatives and NaN to 0', () => { @@ -36,18 +46,46 @@ describe('formatTokenCount', () => { }); }); -describe('usage percentages', () => { - it('ceils and clamps token ratios', () => { +describe('usagePercent', () => { + it('returns 0 for zero usage', () => { expect(usagePercent(0, 1000)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero usage', () => { expect(usagePercent(4, 10_000)).toBe(1); + }); + + it('ceils fractional percentages', () => { expect(usagePercent(427, 1000)).toBe(43); + expect(usagePercent(992, 1000)).toBe(100); + }); + + it('clamps to 100 when used meets or exceeds max', () => { + expect(usagePercent(1000, 1000)).toBe(100); expect(usagePercent(1200, 1000)).toBe(100); + }); + + it('returns 0 for a non-positive or non-finite max', () => { expect(usagePercent(500, 0)).toBe(0); + expect(usagePercent(500, -1)).toBe(0); + expect(usagePercent(500, Number.NaN)).toBe(0); }); +}); - it('ceils and clamps precomputed ratios', () => { +describe('usagePercentFromRatio', () => { + it('coerces NaN to 0', () => { expect(usagePercentFromRatio(Number.NaN)).toBe(0); + }); + + it('returns 0 for zero usage', () => { + expect(usagePercentFromRatio(0)).toBe(0); + }); + + it('ceil-guarantees at least 1% for any non-zero ratio', () => { expect(usagePercentFromRatio(0.004)).toBe(1); + }); + + it('ceils fractional percentages and clamps above 100', () => { expect(usagePercentFromRatio(0.427)).toBe(43); expect(usagePercentFromRatio(1.5)).toBe(100); }); diff --git a/apps/pythinker-code/tsconfig.json b/apps/pythinker-code/tsconfig.json index 226f6c86..10388dd0 100644 --- a/apps/pythinker-code/tsconfig.json +++ b/apps/pythinker-code/tsconfig.json @@ -3,8 +3,6 @@ "compilerOptions": { "allowJs": true, "experimentalDecorators": true, - "jsx": "preserve", - "jsxImportSource": "@opentui/solid", "paths": { "@/*": ["./src/*"] } diff --git a/apps/pythinker-code/tsdown.config.ts b/apps/pythinker-code/tsdown.config.ts index 7d6f3e49..858aeeb4 100644 --- a/apps/pythinker-code/tsdown.config.ts +++ b/apps/pythinker-code/tsdown.config.ts @@ -1,18 +1,15 @@ import { resolve } from 'node:path'; import { defineConfig } from 'tsdown'; -import solid from 'unplugin-solid/rolldown'; import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs'; -import { solidRuntimeAlias, solidRuntimeAliasPlugin } from './scripts/solid-runtime.mjs'; const appRoot = import.meta.dirname; export default defineConfig({ - entry: ['./src/launcher.ts', './src/main.ts'], + entry: ['./src/main.ts'], format: ['esm'], - target: 'node26', outDir: 'dist', clean: true, dts: false, @@ -26,11 +23,7 @@ export default defineConfig({ 'const __dirname = __cjsShimDirname(__filename);', ].join('\n'), }, - plugins: [ - solidRuntimeAliasPlugin({ external: true }), - solid({ include: [/\.[jt]sx$/u], solid: { moduleName: '@opentui/solid', generate: 'universal' } }), - rawTextPlugin(), - ], + plugins: [rawTextPlugin()], alias: { '@': resolve(appRoot, 'src'), }, @@ -38,20 +31,10 @@ export default defineConfig({ [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(), }, deps: { - alwaysBundle: [/^@pymodel\//u, solidRuntimeAlias.find], - // node-pty is a native addon: its `pty.node` binary cannot be bundled and - // must resolve from node_modules at runtime. Keep it external (even though - // its importer @pymodel/agent-core is force-bundled above) and declare it - // as a runtime dependency of this package so npm/npx installs it with its - // prebuilt binary. Bundling it leaves the binary unresolvable → the terminal - // PTY fails with "Failed to load native module: pty.node". - // @opentui/* ships Bun-style `import ... with { type: 'file' }` wasm assets - // rolldown cannot bundle; they are runtime dependencies, so resolve them - // from node_modules like node-pty. - neverBundle: ['node-pty', /^@opentui\//u, 'web-tree-sitter'], + onlyBundle: false, }, outputOptions: { - entryFileNames: '[name].mjs', - chunkFileNames: '[name]-[hash].mjs', + codeSplitting: false, + entryFileNames: 'main.mjs', }, }); diff --git a/apps/pythinker-code/tsdown.dist-worker.config.ts b/apps/pythinker-code/tsdown.dist-worker.config.ts new file mode 100644 index 00000000..43c26570 --- /dev/null +++ b/apps/pythinker-code/tsdown.dist-worker.config.ts @@ -0,0 +1,41 @@ +// Bundles the kap-server global-search worker +// (packages/kap-server/src/search/worker/entry.ts) into ONE self-contained +// `dist/search-worker.mjs` sibling of the main bundle. The search worker +// host resolves it at runtime next to `dist/main.mjs` (dev/tests use the TS +// source; the SEA binary uses the extracted asset from +// tsdown.worker.config.ts). Separate config because rolldown forbids +// `codeSplitting: false` with multiple inputs. + +import { resolve } from 'node:path'; + +import { defineConfig } from 'tsdown'; + +const appRoot = import.meta.dirname; + +export default defineConfig({ + entry: { + 'search-worker': resolve( + appRoot, + '../../packages/kap-server/src/search/worker/entry.ts', + ), + }, + format: ['esm'], + // Shares the main bundle's dist (never wipe it) and lands as + // `dist/search-worker.mjs`. + outDir: 'dist', + clean: false, + dts: false, + hash: false, + platform: 'node', + target: 'node24', + sourcemap: false, + minify: false, + silent: true, + deps: { + onlyBundle: false, + }, + outputOptions: { + codeSplitting: false, + entryFileNames: '[name].mjs', + }, +}); diff --git a/apps/pythinker-code/tsdown.native.config.ts b/apps/pythinker-code/tsdown.native.config.ts index c15eb1d1..8ff96b00 100644 --- a/apps/pythinker-code/tsdown.native.config.ts +++ b/apps/pythinker-code/tsdown.native.config.ts @@ -3,12 +3,9 @@ import { builtinModules } from 'node:module'; import { resolve } from 'node:path'; import { defineConfig } from 'tsdown'; -import solid from 'unplugin-solid/rolldown'; import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; import { BUILT_IN_CATALOG_DEFINE, builtInCatalogDefine } from './scripts/built-in-catalog.mjs'; -import { OPENTUI_TARGETS } from './scripts/native/opentui-target.mjs'; -import { solidRuntimeAliasPlugin } from './scripts/solid-runtime.mjs'; const appRoot = import.meta.dirname; const packageJson = JSON.parse( @@ -20,24 +17,12 @@ const builtins = new Set([ ...builtinModules.map((name) => `node:${name}`), ]); const optionalNativeDependencies = new Set(['cpu-features']); -const openTuiShimPath = resolve(appRoot, 'src/native/opentui-native-shim.ts'); -const openTuiAssetHelperPath = resolve(appRoot, 'src/native/opentui-library.ts'); -const openTuiPlatformAliases = Object.fromEntries([ - ...Object.values(OPENTUI_TARGETS).map(({ packageName }) => [ - packageName, - openTuiShimPath, - ]), - ['@opentui/core-linux-arm64-musl', openTuiShimPath], - ['@opentui/core-linux-x64-musl', openTuiShimPath], -]); -const openTuiAssetPrefix = '\0pythinker-opentui-asset:'; function shouldAlwaysBundle(id: string): boolean { if (builtins.has(id) || id.startsWith('node:')) return false; - if (id === 'node-pty') return false; if (optionalNativeDependencies.has(id)) return false; // Everything else is force-bundled, which covers `@pymodel/*` (incl. - // dashboard-server for `pythinker dashboard`) plus its transitive `hono` / `@hono/node-server` + // vis-server for `pythinker vis`) plus its transitive `hono` / `@hono/node-server` // — so the SEA bundle is self-contained (check-bundle.mjs enforces this). return true; } @@ -48,46 +33,18 @@ function buildTarget(): string { export default defineConfig({ entry: ['./src/main.ts'], - format: ['esm'], + format: ['cjs'], outDir: 'dist-native/intermediates', clean: true, dts: false, fixedExtension: true, hash: false, platform: 'node', - target: 'node26', + target: 'node24', banner: { js: '#!/usr/bin/env node' }, - plugins: [ - solidRuntimeAliasPlugin(), - solid({ include: [/\.[jt]sx$/u], solid: { moduleName: '@opentui/solid', generate: 'universal' } }), - { - name: 'opentui-extracted-assets', - resolveId(source, importer) { - if ( - /[/\\]@opentui[/\\]core[/\\]/u.test(importer ?? '') && - /^\.\/assets\/.+\.(?:scm|wasm)$/u.test(source) - ) { - return `${openTuiAssetPrefix}${source.slice(2)}`; - } - return null; - }, - load(id) { - if (!id.startsWith(openTuiAssetPrefix)) return null; - const packageRelativePath = id.slice(openTuiAssetPrefix.length); - return { - code: [ - `import { getOpenTuiAssetPath } from ${JSON.stringify(openTuiAssetHelperPath)};`, - `export default getOpenTuiAssetPath(${JSON.stringify(packageRelativePath)});`, - ].join('\n'), - map: null, - }; - }, - }, - rawTextPlugin(), - ], + plugins: [rawTextPlugin()], alias: { '@': resolve(appRoot, 'src'), - ...openTuiPlatformAliases, }, define: { [BUILT_IN_CATALOG_DEFINE]: builtInCatalogDefine(), @@ -99,12 +56,12 @@ export default defineConfig({ }, deps: { alwaysBundle: shouldAlwaysBundle, - neverBundle: [...optionalNativeDependencies, 'node-pty'], + neverBundle: [...optionalNativeDependencies], onlyBundle: false, }, outputOptions: { codeSplitting: false, - entryFileNames: 'main.mjs', + entryFileNames: 'main.cjs', }, checks: { legacyCjs: false, diff --git a/apps/pythinker-code/tsdown.worker.config.ts b/apps/pythinker-code/tsdown.worker.config.ts new file mode 100644 index 00000000..b9555a65 --- /dev/null +++ b/apps/pythinker-code/tsdown.worker.config.ts @@ -0,0 +1,50 @@ +// Dedicated tsdown config that bundles the off-main-thread workers into +// self-contained ESM files so they can ride the SEA blob as assets +// (02-sea-blob.mjs) and be spawned from disk at runtime: +// - text-build-worker.mjs: the minidb text-build worker +// (packages/minidb/src/worker/text-build-worker.ts); +// - search-worker.mjs: the kap-server global-search worker +// (packages/kap-server/src/search/worker/entry.ts). +// Without them the bundled binary lacks the worker entry files on disk and +// heavy index work degrades to the inline main-thread cores, stalling the +// event loop on large corpora. Runs after the main bundle with clean:false +// so all verified files remain. +// +// One config per entry: rolldown forbids `codeSplitting: false` with +// multiple inputs, and each worker must be a single self-contained file +// (check-bundle.mjs enforces zero remaining externals/relative imports). + +import { resolve } from 'node:path'; + +import { defineConfig } from 'tsdown'; + +const here = import.meta.dirname; + +function workerConfig(name: string, entry: string) { + return defineConfig({ + entry: { [name]: resolve(here, entry) }, + format: ['esm'], + outDir: resolve(here, 'dist-native/intermediates'), + entryFileNames: '[name].mjs', + codeSplitting: false, + platform: 'node', + target: 'node24', + dts: false, + sourcemap: false, + minify: false, + silent: true, + deps: { + // Force-bundle the workspace packages the entries import + // (`@pymodel/minidb` and its subpaths) so the output is + // self-contained. + alwaysBundle: [/^@pymodel\//], + }, + // The intermediates dir also holds main.cjs & co. — never wipe it. + clean: false, + }); +} + +export default [ + workerConfig('text-build-worker', '../../packages/minidb/src/worker/text-build-worker.ts'), + workerConfig('search-worker', '../../packages/kap-server/src/search/worker/entry.ts'), +]; diff --git a/apps/pythinker-code/vitest.config.ts b/apps/pythinker-code/vitest.config.ts index 23213d93..e23ea368 100644 --- a/apps/pythinker-code/vitest.config.ts +++ b/apps/pythinker-code/vitest.config.ts @@ -1,22 +1,14 @@ import { resolve } from 'node:path'; -import solid from 'unplugin-solid/vite'; import { defineConfig } from 'vitest/config'; -import { solidRuntimeAlias } from './scripts/solid-runtime.mjs'; - const appRoot = import.meta.dirname; export default defineConfig({ - plugins: [solid({ ssr: true, include: [/\.[jt]sx$/u], solid: { moduleName: '@opentui/solid', generate: 'universal' } })], resolve: { - alias: [ - solidRuntimeAlias, - { - find: '@', - replacement: resolve(appRoot, 'src'), - }, - ], + alias: { + '@': resolve(appRoot, 'src'), + }, }, test: { name: 'cli', diff --git a/apps/pythinker-inspect/AGENTS.md b/apps/pythinker-inspect/AGENTS.md new file mode 100644 index 00000000..bfbff136 --- /dev/null +++ b/apps/pythinker-inspect/AGENTS.md @@ -0,0 +1,44 @@ +# pythinker-inspect Agent Guide + +Web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. + +## Top-level views + +A left icon rail (`src/components/NavRail.tsx`) switches top-level views: + +- **Chat workspace** — the per-session chat (see "Chat view" below), with the session table on the left: `src/components/Sidebar.tsx` is a spreadsheet-like table panel over `GET /api/v2/sessions` (client in `src/sessions/api.ts` — v1-style `{ code, msg, data }` envelope, opaque-cursor pagination; preset views in `src/sessions/views.ts` map onto the endpoint's status / archived / git query conditions), with column visibility + active view persisted to localStorage, server-side sort toggles on the Updated / Created headers, live activity badges from the hub, and a per-workspace grouped view. +- **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index). +- **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. +- **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`. +- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:<name>` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix. + +The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs: + +- `Agent` tab — `Inspector`: agent switcher + a Plan lookup card (`PlanCard` in `src/components/Inspector.tsx` — querying `GET /sessions/{id}/transcript/plan` (one tool_call_id, or every plan of the agent) via `src/transcript/api.ts`'s `fetchTranscriptPlan`) plus the agent Service panels. +- `State` tab — every key an Agent Service registered into the agent-state container, polled live via `IAgentStateService.snapshot()` — the same live diff-tree view as the session State tab, sharing `StateCard` from `src/components/StateCard.tsx`. + +The **Session scope** has its own column right next to the session-list sidebar (`src/components/SessionPane.tsx`) with two tabs: Services (the pending-interactions card — `src/components/InteractionsCard.tsx` — plus the session Service panels) and State (every key a Session Service registered into the session-state container, read on demand via `ISessionStateService.snapshot()`). + +## Channel layer + +Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. + +## Session activity + +Session-level coarse status is the one exception to no-push: `src/activity/` holds a second `/api/v1/ws` client (`GlobalEventsWs`) that subscribes to nothing and consumes the server-pushed global facts — `event.session.work_changed` updates a per-session activity map (`SessionActivityHub` + subscribe/version store, seeded on connect/reconnect from `GET /api/v1/sessions`), while `event.session.created` / `session.meta.updated` invalidate the `['sessions']` / `['v2-sessions']` queries; the session table rows render `running` / `approval` / `question` / `failed` badges from it via `useSessionActivities` (live facts override the REST `activity.status`). + +## Dev server + +The Vite dev server proxies `/api` to a running kap-server (`PYTHINKER_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.pythinker-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. + +## Chat view + +The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory and carries an in-chat search bar (`src/components/ChatSearchBar.tsx`): it searches the current session via `POST /api/v1/search` with `container: { session_id }` (usually served by the live route, since selecting a session resumes it), and a result click funnels through the app shell's `openSearchHit` — the same agent-switch + `ChatJump` (page-back, scroll, flash) path the global search view uses. + +Full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library). + +`/api/v1/ws` is an incremental channel (`transcript.ops`, grade `block` — the cheapest grade that still carries whole-state frame upserts, dropping per-token `append` frames; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: a dedicated `subscribe_v2` control frame carries the per-agent grades and the `transcript_since` cursor, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@pymodel/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). + +## Transcript audit panel + +The Transcript audit panel (`src/components/audit/`, the `Audit` tab of the chat view's right dock — `src/components/RightPanel.tsx`, fed the trail by `ChatView`'s `onTrailChange`) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. diff --git a/apps/pythinker-inspect/README.md b/apps/pythinker-inspect/README.md new file mode 100644 index 00000000..bea68ea0 --- /dev/null +++ b/apps/pythinker-inspect/README.md @@ -0,0 +1,58 @@ +# pythinker-inspect + +Web inspector for the kap-server `/api/v1/debug` RPC surface — a read/trigger +window into a running Pythinker Code engine (workspaces, sessions, agents, and the +scoped DI registry). + +## Run + +1. Start a kap-server with the debug surface mounted (repo dev scripts do this + for you): `pnpm dev:v1` / `pnpm dev:v2` from the repo root pass + `--debug-endpoints` on a loopback bind; the surface inherits the global + bearer auth. +2. `pnpm --filter @pymodel/pythinker-inspect dev` — the Vite dev server proxies + `/api` to the server (`PYTHINKER_SERVER_URL`, default `http://127.0.0.1:58627`) + and auto-discovers running instances + (`~/.pythinker-code/server/instances`); switch servers from the header dropdown. + +A connection failure shows a blocking "Debug surface unavailable" screen — +there is no fallback data source. + +## Views (left icon rail) + +- **Chat workspace** — session list (activity badges from the global-events WS) + plus a transcript-driven per-session chat; the right dock hosts the + Agent-scope Service panels, a plan lookup card, and the transcript audit + panel. The Session scope has its own column (pending interactions + session + Service panels, and a State tab). +- **Search** — cross-session full-text search over `POST /api/v1/search` + (cursor-paged; exact-match maps to the API's `literal` mode; a `live`/`index` + badge shows which server route served the results). +- **Model Catalog** — every provider with its models; expanding one opens the + model inspector (config layers + resolved runtime view with per-value + provenance). +- **App / Workspace Services** — the full Service reflection over the App + scope, and over each Workspace scope (picked via the directory browser; + workspace handlers materialize on demand). +- **DI** — the engine's Service × Effect × DI debug surface, four panels fed + by the App-scope debug Services (`IDebugLedgerService` / `IDebugGraphService` + / `IDebugCascadeService`) and refreshed eagerly off the `event.di.unit_changed` + WS frame: + - **Unit tree** — scope → unit → ledger entries (label, five-state + `Pending / Activating / Active / Unloading / Failed`, uid, `pinned` flag, + unit error object), with **unprovide / update / dispose** triggers. + - **Graph** — the dependency DAG (instance edges across scopes + collection + edges). + - **Cascade** — the cascade transaction history ring (changes, contagion set, + torn-down / rebuilt / failed, abort wait, duration). + - **Pending** — the waiting area: units parked on unsatisfied dependencies + with their missing-token sets. + +## Notes for maintainers + +- The channel layer (`src/channel/`) is a VS Code-style `ProxyChannel`: + `GET /api/v1/debug/channels` enumerates every scoped Service — there is no + whitelist; new Services appear automatically. +- There is no Service-event push channel besides the global events listed + above; panels fetch/refresh on demand (react-query, 15 s poll) plus the + `event.di.unit_changed` invalidation for the DI view. diff --git a/apps/pythinker-inspect/index.html b/apps/pythinker-inspect/index.html new file mode 100644 index 00000000..94e9c5d7 --- /dev/null +++ b/apps/pythinker-inspect/index.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Pythinker Inspect + + +
+ + + diff --git a/apps/pythinker-inspect/package.json b/apps/pythinker-inspect/package.json new file mode 100644 index 00000000..d7a23d0a --- /dev/null +++ b/apps/pythinker-inspect/package.json @@ -0,0 +1,41 @@ +{ + "name": "@pymodel/pythinker-inspect", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "imports": { + "#/*": { + "types": [ + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ], + "default": "./src/*" + } + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@pymodel/agent-core-v2": "workspace:^", + "@pymodel/transcript": "workspace:^", + "@tanstack/react-query": "^5.74.4", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "tailwindcss": "^4.1.4", + "typescript": "6.0.3", + "vite": "^6.3.3", + "vitest": "4.1.9" + } +} diff --git a/apps/pythinker-inspect/src/App.tsx b/apps/pythinker-inspect/src/App.tsx new file mode 100644 index 00000000..8c24029b --- /dev/null +++ b/apps/pythinker-inspect/src/App.tsx @@ -0,0 +1,164 @@ +/** + * App shell — selection state and session resume. There is no live event + * push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent + * event streams was removed server-side, so Service panels and the pending + * interactions card fetch on demand and the sidebar polls. + * Layout: header / icon rail / view. The `chat` view is a strip of the + * left sidebar (workspaces + sessions), the session pane (session Services + * / State tabs), the chat column, and the right dock (`RightPanel`) merging + * the transcript audit and the agent inspector under Audit / Agent tabs; + * the `models` view is the full-width model catalog; the `services` view is + * the full-width app-scope Service reflection (`AppServicesView`); the + * `workspace` view is the workspace-scope counterpart + * (`WorkspaceServicesView`, with a workspace picker on top); the + * `bash` view is the full-width `IBashParserService` playground + * (`BashParserView`); the `di` view is the engine's Service × Effect × DI + * debug surface (`DiInspectionView`); the `search` view is the full-width + * global message search (`SearchView`) whose hits navigate back into the + * chat timeline. + */ + +import { ISessionManager } from '@pymodel/agent-core-v2/app/sessionManager/sessionManager'; +import { useEffect, useState } from 'react'; + +import type { AuditTrail } from './audit/trail'; +import { AppServicesView } from './components/AppServicesView'; +import { BashParserView } from './components/BashParserView'; +import { ChatView, type ChatJump } from './components/ChatView'; +import { DiInspectionView } from './components/DiInspectionView'; +import { ModelCatalogView } from './components/ModelCatalogView'; +import { NavRail, type AppView } from './components/NavRail'; +import { RightPanel } from './components/RightPanel'; +import { SearchView } from './components/SearchView'; +import { ServerSwitcher } from './components/ServerSwitcher'; +import { SessionPane } from './components/SessionPane'; +import { Sidebar } from './components/Sidebar'; +import { WorkspaceServicesView } from './components/WorkspaceServicesView'; +import { useConnection } from './connection'; +import type { SearchHit } from './search/api'; +import { errorMessage } from './ui'; + +export function App() { + const { klient, baseUrl, disconnect } = useConnection(); + const [sessionId, setSessionId] = useState(null); + const [agentId, setAgentId] = useState('main'); + const [view, setView] = useState('chat'); + const [ready, setReady] = useState(false); + const [resumeError, setResumeError] = useState(null); + /** Audit trail of the chat view's transcript channel, rendered in the right dock. */ + const [trail, setTrail] = useState(null); + /** Pending chat navigation requested from another view (search result click). */ + const [jump, setJump] = useState(null); + + // Resume (materialize) the session on the server when it is selected, so + // session / agent scoped Services become reachable. Session lifecycle lives + // on the workspace handler (Workspace scope): the index yields the + // session's workspaceId, then the handler resumes it. + useEffect(() => { + if (sessionId === null) return; + let cancelled = false; + setReady(false); + setResumeError(null); + klient + .core(ISessionManager) + .resume(sessionId) + .then((session) => { + if (session === undefined) throw new Error(`session ${sessionId} does not exist`); + }) + .then(() => { + if (!cancelled) setReady(true); + }) + .catch((error: unknown) => { + if (!cancelled) setResumeError(error); + }); + return () => { + cancelled = true; + }; + }, [klient, sessionId]); + + // Switching servers invalidates every session/agent selection: sessions + // belong to the server they were listed from. + useEffect(() => { + setSessionId(null); + setAgentId('main'); + setJump(null); + }, [baseUrl]); + + // A search hit opens the chat view at its session / agent / turn / step. + // Title hits belong to the session (agent '') and carry no turn: switch + // over without a scroll target. + const openSearchHit = (hit: SearchHit): void => { + setSessionId(hit.sessionId); + setAgentId(hit.agentId === '' ? 'main' : hit.agentId); + setView('chat'); + setJump({ + turnId: hit.turn === undefined ? undefined : `t${hit.turn}`, + stepId: hit.stepId, + nonce: Date.now(), + }); + }; + + return ( +
+
+ PYTHINKER INSPECT + +
+ +
+
+ + {view === 'services' ? ( + + ) : view === 'workspace' ? ( + + ) : view === 'bash' ? ( + + ) : view === 'di' ? ( + + ) : view === 'models' ? ( + { + setSessionId(id); + setView('chat'); + }} + /> + ) : view === 'search' ? ( + + ) : ( + <> + + + {resumeError !== null ? ( +
+ Failed to open session: {errorMessage(resumeError)} +
+ ) : ( + setJump(null)} + onOpenSearchHit={openSearchHit} + /> + )} + + + )} +
+
+ ); +} diff --git a/apps/pythinker-inspect/src/activity/di.ts b/apps/pythinker-inspect/src/activity/di.ts new file mode 100644 index 00000000..ed5b7e3f --- /dev/null +++ b/apps/pythinker-inspect/src/activity/di.ts @@ -0,0 +1,53 @@ +/** + * DI debug feed — a dedicated global-events socket for the DI view. The + * session activity hub (`useSessionActivities`) lives with the chat Sidebar, + * which unmounts when the DI view is active, so the DI view owns its own + * `GlobalEventsWs` (only one of the two is ever connected at a time). + * + * Every `event.di.unit_changed` frame invalidates the `['di']` query prefix + * (the same pattern as `event.session.created` invalidating `['sessions']`), + * so all DI panels refetch on unit transitions instead of waiting out their + * poll interval. Bursts (a cascade flipping many units at once) are coalesced + * with a short trailing throttle; a reconnect invalidates immediately, since + * live transitions were missed while the socket was down. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +import { useConnection } from '../connection'; +import { GlobalEventsWs } from './ws'; + +const INVALIDATE_THROTTLE_MS = 250; + +export function useDiQueryInvalidation(): void { + const { baseUrl, config } = useConnection(); + const queryClient = useQueryClient(); + const token = config.token.trim(); + + useEffect(() => { + let timer: ReturnType | undefined; + const invalidate = () => { + if (timer !== undefined) return; + timer = setTimeout(() => { + timer = undefined; + void queryClient.invalidateQueries({ queryKey: ['di'] }); + }, INVALIDATE_THROTTLE_MS); + }; + const ws = new GlobalEventsWs({ + url: baseUrl, + token: token === '' ? undefined : token, + handlers: { + onWorkChanged: () => {}, + onSessionCreated: () => {}, + onMetaUpdated: () => {}, + onDiUnitChanged: invalidate, + onReconnected: invalidate, + }, + }); + return () => { + if (timer !== undefined) clearTimeout(timer); + ws.close(); + }; + }, [baseUrl, token, queryClient]); +} diff --git a/apps/pythinker-inspect/src/activity/store.test.ts b/apps/pythinker-inspect/src/activity/store.test.ts new file mode 100644 index 00000000..dea64966 --- /dev/null +++ b/apps/pythinker-inspect/src/activity/store.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { WsLike, WsLikeCtor } from '../channel/wsLike'; +import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store'; + +function facts(partial: Partial = {}): SessionWorkFacts { + return { + busy: false, + mainTurnActive: false, + pendingInteraction: 'none', + lastTurnReason: undefined, + ...partial, + }; +} + +class FakeWs implements WsLike { + static readonly OPEN = 1; + readyState = 1; + sent: string[] = []; + closed = false; + private readonly listeners = new Map void>>(); + + send(data: string): void { + this.sent.push(data); + } + close(): void { + this.closed = true; + } + addEventListener(type: string, listener: (event: never) => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener); + this.listeners.set(type, list); + } + emit(type: 'open' | 'message' | 'close' | 'error', event?: unknown): void { + for (const listener of this.listeners.get(type) ?? []) listener(event as never); + } + emitFrame(frame: Record): void { + this.emit('message', { data: JSON.stringify(frame) }); + } +} + +function makeFakeWsCtor(): { ctor: WsLikeCtor; instances: FakeWs[] } { + const instances: FakeWs[] = []; + const ctor = class { + static readonly OPEN = 1; + constructor(_url: string, _protocols?: string | string[]) { + const ws = new FakeWs(); + instances.push(ws); + return ws; + } + } as unknown as WsLikeCtor; + return { ctor, instances }; +} + +function seedFetch(items: Record[]): typeof fetch { + return vi.fn(async () => ({ + json: async () => ({ code: 0, data: { items, has_more: false } }), + })) as unknown as typeof fetch; +} + +describe('SessionActivityStore', () => { + it('applies work facts and notifies with a version bump', () => { + const store = new SessionActivityStore(); + const listener = vi.fn(); + store.subscribe(listener); + + store.applyWorkChanged('s1', facts({ busy: true, mainTurnActive: true })); + + expect(store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + expect(store.getVersion()).toBe(1); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('ignores identical facts (no bump, no notify)', () => { + const store = new SessionActivityStore(); + const listener = vi.fn(); + store.applyWorkChanged('s1', facts({ busy: true })); + store.subscribe(listener); + + store.applyWorkChanged('s1', facts({ busy: true })); + + expect(store.getVersion()).toBe(1); + expect(listener).not.toHaveBeenCalled(); + }); + + it('seed replaces the whole map', () => { + const store = new SessionActivityStore(); + store.applyWorkChanged('stale', facts({ busy: true })); + + store.seed([['s1', facts({ pendingInteraction: 'approval' })]]); + + expect(store.get('stale')).toBeUndefined(); + expect(store.get('s1')?.pendingInteraction).toBe('approval'); + }); +}); + +describe('SessionActivityHub', () => { + it('seeds the store from the REST session list when the socket opens', async () => { + const { ctor, instances } = makeFakeWsCtor(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl: seedFetch([ + { id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' }, + { id: 's2', busy: false, main_turn_active: false, pending_interaction: 'approval' }, + ]), + }); + + instances[0]!.emit('open'); + await vi.waitFor(() => expect(hub.store.get('s1')).toBeDefined()); + + expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true })); + expect(hub.store.get('s2')?.pendingInteraction).toBe('approval'); + // The hello goes out with no subscriptions — global facts flow regardless. + const hello = JSON.parse(instances[0]!.sent[0]!) as { + type: string; + payload: { subscriptions: string[] }; + }; + expect(hello.type).toBe('client_hello'); + expect(hello.payload.subscriptions).toEqual([]); + hub.close(); + }); + + it('applies live work_changed frames by session id', () => { + const { ctor, instances } = makeFakeWsCtor(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged: () => {}, + WebSocketImpl: ctor, + fetchImpl: seedFetch([]), + }); + instances[0]!.emit('open'); + + instances[0]!.emitFrame({ + type: 'event.session.work_changed', + session_id: 's1', + payload: { + type: 'event.session.work_changed', + busy: true, + main_turn_active: true, + pending_interaction: 'question', + last_turn_reason: null, + }, + }); + + expect(hub.store.get('s1')).toEqual( + facts({ busy: true, mainTurnActive: true, pendingInteraction: 'question' }), + ); + hub.close(); + }); + + it('forwards created and meta updates as list-level signals', () => { + const { ctor, instances } = makeFakeWsCtor(); + const onListChanged = vi.fn(); + const hub = new SessionActivityHub({ + url: 'http://127.0.0.1:58627', + onListChanged, + WebSocketImpl: ctor, + fetchImpl: seedFetch([]), + }); + instances[0]!.emit('open'); + + instances[0]!.emitFrame({ type: 'event.session.created', session_id: 's1', payload: {} }); + instances[0]!.emitFrame({ type: 'session.meta.updated', session_id: 's1', payload: {} }); + // Agent-grained frames are ignored even if they somehow arrive. + instances[0]!.emitFrame({ type: 'turn.started', session_id: 's1', payload: {} }); + + expect(onListChanged).toHaveBeenCalledTimes(2); + expect(hub.store.get('s1')).toBeUndefined(); + hub.close(); + }); +}); diff --git a/apps/pythinker-inspect/src/activity/store.ts b/apps/pythinker-inspect/src/activity/store.ts new file mode 100644 index 00000000..b68fffc6 --- /dev/null +++ b/apps/pythinker-inspect/src/activity/store.ts @@ -0,0 +1,145 @@ +/** + * Session activity hub — owns the global-events socket and the per-session + * coarse activity map behind the Sidebar's status badges. + * + * Two data sources converge into one store: the initial / reconnect + * baseline comes from a single `GET /api/v1/sessions` page (every wire + * session carries `busy` / `main_turn_active` / `pending_interaction` / + * `last_turn_reason`), and live updates arrive as + * `event.session.work_changed` frames over the global WS channel (no + * subscription needed server-side). List-level facts (session created / + * retitled) are forwarded to the consumer as `onListChanged` so the + * react-query session list invalidates instead of waiting out its slow poll. + * The store is a plain subscribe/version store so React binds through + * `useSyncExternalStore`. + */ + +import type { WsLikeCtor } from '../channel/wsLike'; +import { GlobalEventsWs, type SessionWorkFacts } from './ws'; + +export type { SessionWorkFacts }; + +export class SessionActivityStore { + private activities = new Map(); + private readonly listeners = new Set<() => void>(); + private version = 0; + + get(sessionId: string): SessionWorkFacts | undefined { + return this.activities.get(sessionId); + } + + getVersion(): number { + return this.version; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + applyWorkChanged(sessionId: string, facts: SessionWorkFacts): void { + const previous = this.activities.get(sessionId); + if ( + previous !== undefined && + previous.busy === facts.busy && + previous.mainTurnActive === facts.mainTurnActive && + previous.pendingInteraction === facts.pendingInteraction && + previous.lastTurnReason === facts.lastTurnReason + ) { + return; + } + this.activities.set(sessionId, facts); + this.bump(); + } + + /** Replace the whole map with a REST baseline (initial load / re-seed). */ + seed(entries: Iterable): void { + this.activities = new Map(entries); + this.bump(); + } + + private bump(): void { + this.version += 1; + for (const listener of this.listeners) listener(); + } +} + +export interface SessionActivityHubOptions { + /** Server base URL (`http(s)://host:port`). */ + readonly url: string; + readonly token?: string | undefined; + /** List-level signal (session created / retitled) — invalidate the list. */ + readonly onListChanged: () => void; + readonly WebSocketImpl?: WsLikeCtor; + readonly fetchImpl?: typeof fetch; +} + +export class SessionActivityHub { + readonly store = new SessionActivityStore(); + private readonly ws: GlobalEventsWs; + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + + constructor(opts: SessionActivityHubOptions) { + this.baseUrl = opts.url.replace(/\/$/, ''); + this.token = opts.token; + // Bind the default: `this.fetchImpl(...)` is a member call, and the + // browser's `fetch` throws Illegal invocation when its receiver is not + // the global object (Node's undici fetch does not care). + this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis); + this.ws = new GlobalEventsWs({ + url: opts.url, + token: opts.token, + WebSocketImpl: opts.WebSocketImpl, + handlers: { + onWorkChanged: (sessionId, facts) => this.store.applyWorkChanged(sessionId, facts), + onSessionCreated: () => opts.onListChanged(), + onMetaUpdated: () => opts.onListChanged(), + onReconnected: () => void this.seed(), + }, + }); + } + + close(): void { + this.ws.close(); + } + + private async seed(): Promise { + const headers: Record = {}; + if (this.token !== undefined && this.token.length > 0) { + headers['authorization'] = `Bearer ${this.token}`; + } + try { + const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions`, { headers }); + const envelope = (await res.json()) as { + code: number; + data?: { items?: Record[] }; + }; + if (envelope.code !== 0 || envelope.data?.items === undefined) return; + const entries: [string, SessionWorkFacts][] = []; + for (const item of envelope.data.items) { + const id = item['id']; + if (typeof id !== 'string' || typeof item['busy'] !== 'boolean') continue; + const pending = item['pending_interaction']; + const reason = item['last_turn_reason']; + entries.push([ + id, + { + busy: item['busy'], + mainTurnActive: item['main_turn_active'] === true, + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', + lastTurnReason: + reason === 'completed' || reason === 'cancelled' || reason === 'failed' + ? reason + : undefined, + }, + ]); + } + this.store.seed(entries); + } catch { + // Seed is best-effort: live frames keep flowing, and the next reconnect + // re-seeds. A dead server surfaces through the connection layer anyway. + } + } +} diff --git a/apps/pythinker-inspect/src/activity/useSessionActivity.ts b/apps/pythinker-inspect/src/activity/useSessionActivity.ts new file mode 100644 index 00000000..c435b123 --- /dev/null +++ b/apps/pythinker-inspect/src/activity/useSessionActivity.ts @@ -0,0 +1,51 @@ +/** + * React binding for the session activity hub: one hub per (server, token), + * torn down on server switch or unmount. Consumers read per-session coarse + * activity (`get(sessionId)`) and re-render on every store bump; list-level + * signals invalidate the `['sessions']` / `['v2-sessions']` react-query lists + * directly. + * + * The hub is created inside `useEffect` (not `useMemo`): under StrictMode + * the mount → cleanup → re-mount cycle runs the cleanup of the FIRST mount, + * and a memo-created hub would stay closed for the rest of the page's life. + */ + +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState, useSyncExternalStore } from 'react'; + +import { useConnection } from '../connection'; +import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store'; + +const EMPTY_STORE = new SessionActivityStore(); + +export function useSessionActivities(): { + get(sessionId: string): SessionWorkFacts | undefined; +} { + const { baseUrl, config } = useConnection(); + const queryClient = useQueryClient(); + const token = config.token.trim(); + const [hub, setHub] = useState(null); + + useEffect(() => { + const created = new SessionActivityHub({ + url: baseUrl, + token: token === '' ? undefined : token, + onListChanged: () => { + void queryClient.invalidateQueries({ queryKey: ['sessions'] }); + void queryClient.invalidateQueries({ queryKey: ['v2-sessions'] }); + }, + }); + setHub(created); + return () => { + setHub(null); + created.close(); + }; + }, [baseUrl, token, queryClient]); + + const store = hub?.store ?? EMPTY_STORE; + useSyncExternalStore( + (listener) => store.subscribe(listener), + () => store.getVersion(), + ); + return store; +} diff --git a/apps/pythinker-inspect/src/activity/ws.ts b/apps/pythinker-inspect/src/activity/ws.ts new file mode 100644 index 00000000..af801c85 --- /dev/null +++ b/apps/pythinker-inspect/src/activity/ws.ts @@ -0,0 +1,274 @@ +/** + * Minimal `/api/v1/ws` client for GLOBAL session facts — no subscriptions. + * + * The server pushes every global event (`event.session.*` / + * `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every + * established connection, so this client subscribes to nothing: it sends a + * `client_hello` with an empty subscription list (etiquette only — the + * delivery set does not depend on it) and dispatches the coarse per-session + * facts to the consumer: + * + * - `event.session.work_changed` → `{busy, main_turn_active, + * pending_interaction, last_turn_reason}` for one session; + * - `event.session.created` / `session.meta.updated` → list-level signals + * (a session appeared / retitled), forwarded for list invalidation; + * - `event.di.unit_changed` → one DI unit state transition of the engine's + * scope tree (the debug-surface feed), forwarded for `['di']` + * invalidation. Global like the rest: it carries the `__global__` + * session watermark and fans out to every connection. + * + * Session/agent-grained events never arrive here (they stay subscribe-gated + * server-side); the transcript chat channel has its own socket. Global + * frames are live-only — a drop loses whatever fired meanwhile, so the + * consumer answers `onReconnected` with a REST re-seed. + * + * The bearer token is presented at the upgrade through the + * `pythinker-code.bearer.` subprotocol (the only credential channel a + * browser WebSocket has). + */ + +import type { WsLike, WsLikeCtor } from '../channel/wsLike'; + +export type SessionPendingInteraction = 'none' | 'approval' | 'question'; +export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed'; + +export interface SessionWorkFacts { + readonly busy: boolean; + readonly mainTurnActive: boolean; + readonly pendingInteraction: SessionPendingInteraction; + readonly lastTurnReason?: SessionTurnOutcome | undefined; +} + +export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed'; + +/** Wire payload of the `event.di.unit_changed` global event. */ +export interface DiUnitChangedPayload { + /** Scope path of the container owning the unit (`app` / `app/workspace:` / …). */ + readonly scope: string; + readonly token: string; + readonly state: DiUnitState; + /** Serialized sticky failure, present only on a Failed transition. */ + readonly error?: string | undefined; +} + +export interface GlobalEventsWsHandlers { + /** Coarse work-fact tuple for one session changed. */ + onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void; + /** A session was created (list-level signal). */ + onSessionCreated: (sessionId: string) => void; + /** A session's title/patch changed (list-level signal). */ + onMetaUpdated: (sessionId: string) => void; + /** A DI unit of the engine's scope tree changed state (debug feed). */ + onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined; + /** Socket established (initial connect and every reconnect) — the consumer + * answers with a REST re-seed, since live facts are missed while down. */ + onReconnected: () => void; +} + +export interface GlobalEventsWsOptions { + /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */ + readonly url: string; + readonly token?: string | undefined; + readonly handlers: GlobalEventsWsHandlers; + /** WebSocket implementation; defaults to the global `WebSocket`. */ + readonly WebSocketImpl?: WsLikeCtor; + /** Base delay (ms) for the reconnect backoff. Default `500`. */ + readonly reconnectDelayMs?: number; +} + +interface ServerFrame { + readonly type: string; + readonly id?: string; + readonly session_id?: string; + readonly payload?: unknown; +} + +const WS_BEARER_PROTOCOL_PREFIX = 'pythinker-code.bearer.'; + +export class GlobalEventsWs { + private readonly wsUrl: string; + private readonly token?: string; + private readonly handlers: GlobalEventsWsHandlers; + private readonly WsCtor: WsLikeCtor; + private readonly reconnectDelayMs: number; + + private ws: WsLike | undefined; + private manualClose = false; + private reconnectAttempt = 0; + private reconnectTimer: ReturnType | undefined; + + constructor(opts: GlobalEventsWsOptions) { + this.wsUrl = toWsUrl(opts.url); + this.token = opts.token; + this.handlers = opts.handlers; + const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined); + if (ctor === undefined) { + throw new Error('no WebSocket implementation available; pass WebSocketImpl'); + } + this.WsCtor = ctor; + this.reconnectDelayMs = opts.reconnectDelayMs ?? 500; + this.connect(); + } + + /** Tear the socket down permanently. */ + close(): void { + this.manualClose = true; + if (this.reconnectTimer !== undefined) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + const ws = this.ws; + this.ws = undefined; + ws?.close(); + } + + private connect(): void { + const protocols = + this.token !== undefined && this.token.length > 0 + ? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`] + : undefined; + let ws: WsLike; + try { + ws = new this.WsCtor(this.wsUrl, protocols); + } catch { + this.scheduleReconnect(); + return; + } + this.ws = ws; + ws.addEventListener('open', () => { + this.reconnectAttempt = 0; + this.send({ + type: 'client_hello', + id: `pythinker-inspect-global-${Date.now().toString(36)}`, + payload: { client_id: 'pythinker-inspect', subscriptions: [] }, + }); + // Established (first connect and every reconnect alike): live facts may + // have been missed — the consumer re-seeds from REST. + this.handlers.onReconnected(); + }); + ws.addEventListener('message', (event: { data: unknown }) => { + this.onMessage(event.data); + }); + ws.addEventListener('close', () => { + // Stale socket (a manual close already cleared `this.ws`). + if (this.ws !== ws) return; + this.ws = undefined; + if (!this.manualClose) this.scheduleReconnect(); + }); + ws.addEventListener('error', () => { + // The 'close' event always follows 'error'; reconnect logic lives there. + }); + } + + private onMessage(raw: unknown): void { + let frame: ServerFrame; + try { + frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame; + } catch { + return; + } + const sessionId = frame.session_id; + if (typeof sessionId !== 'string' || sessionId === '') return; + switch (frame.type) { + case 'event.session.work_changed': { + const facts = parseWorkFacts(frame.payload); + if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts); + return; + } + case 'event.session.created': { + this.handlers.onSessionCreated(sessionId); + return; + } + case 'session.meta.updated': { + this.handlers.onMetaUpdated(sessionId); + return; + } + case 'event.di.unit_changed': { + const payload = parseDiUnitChangedPayload(frame.payload); + if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload); + return; + } + case 'ping': { + const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce; + this.send({ type: 'pong', payload: { nonce } }); + return; + } + default: + return; + } + } + + private scheduleReconnect(): void { + if (this.manualClose) return; + this.reconnectAttempt += 1; + const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + this.connect(); + }, delay); + this.reconnectTimer.unref?.(); + } + + private send(frame: Record): void { + const ws = this.ws; + if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return; + try { + ws.send(JSON.stringify(frame)); + } catch { + // best-effort; the close handler handles teardown + } + } +} + +function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const p = payload as Record; + if (typeof p['busy'] !== 'boolean') return undefined; + const pending = p['pending_interaction']; + const reason = p['last_turn_reason']; + return { + busy: p['busy'], + mainTurnActive: p['main_turn_active'] === true, + pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none', + lastTurnReason: + reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined, + }; +} + +const DI_UNIT_STATES: ReadonlySet = new Set([ + 'Pending', + 'Activating', + 'Active', + 'Unloading', + 'Failed', +]); + +function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined { + if (typeof payload !== 'object' || payload === null) return undefined; + const p = payload as Record; + if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined; + const state = p['state']; + if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined; + return { + scope: p['scope'], + token: p['token'], + state: state as DiUnitState, + error: typeof p['error'] === 'string' ? p['error'] : undefined, + }; +} + +/** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */ +function toWsUrl(base: string): string { + const url = new URL(base); + if (url.protocol === 'http:') url.protocol = 'ws:'; + else if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol !== 'ws:' && url.protocol !== 'wss:') { + throw new Error(`unsupported URL scheme for WS transport: ${base}`); + } + if (!url.pathname.endsWith('/api/v1/ws')) { + url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`; + } + url.search = ''; + url.hash = ''; + return url.toString(); +} diff --git a/apps/pythinker-inspect/src/audit/audit.test.ts b/apps/pythinker-inspect/src/audit/audit.test.ts new file mode 100644 index 00000000..338233e7 --- /dev/null +++ b/apps/pythinker-inspect/src/audit/audit.test.ts @@ -0,0 +1,236 @@ +/** + * Audit-layer tests: the trail recorder, the structural diff, serialization, + * and tail-preserving truncation used by the chat view's audit panel. + */ + +import { EMPTY_AGENT_STATE, type AgentState, type TranscriptTurn } from '@pymodel/transcript'; +import { describe, expect, it } from 'vitest'; + +import { diffValue, type DiffNode } from './diff'; +import { serializeState } from './serialize'; +import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; +import { tailTrunc } from './truncate'; + +function turnItem(n: number): TranscriptTurn { + return { + kind: 'turn', + turnId: `t${n}`, + ordinal: n, + state: 'completed', + origin: { kind: 'user' }, + steps: [], + }; +} + +function stateWith(items: readonly TranscriptTurn[]): AgentState { + return { ...EMPTY_AGENT_STATE, items }; +} + +// ---------------------------------------------------------------- diff + +describe('diffValue', () => { + it('collapses reference-equal subtrees to unchanged without children', () => { + const shared = { a: 1, b: { c: 'x' } }; + const node = diffValue({ v: shared }, { v: shared }); + expect(node.status).toBe('unchanged'); + expect(node.children?.get('v')?.children).toBeUndefined(); + }); + + it('marks added, removed, and modified object keys', () => { + const node = diffValue( + { keep: 1, gone: 'x', changed: 'a' }, + { keep: 1, fresh: true, changed: 'b' }, + ); + expect(node.status).toBe('modified'); + expect(node.children?.get('keep')?.status).toBe('unchanged'); + expect(node.children?.get('fresh')?.status).toBe('added'); + expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' }); + expect(node.children?.get('changed')).toMatchObject({ + status: 'modified', + prev: 'a', + value: 'b', + }); + }); + + it('matches entity arrays by id instead of index', () => { + const prev = [turnItem(1), turnItem(2)]; + const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)]; + const node = diffValue(prev, next); + expect(node.children?.get('t1')?.status).toBe('unchanged'); + expect(node.children?.get('t2')?.status).toBe('modified'); + expect(node.children?.get('t2')?.children?.get('state')).toMatchObject({ + status: 'modified', + prev: 'completed', + value: 'running', + }); + expect(node.children?.get('t3')?.status).toBe('added'); + }); + + it('keys steps by stepId (not their shared turnId) so siblings never collide', () => { + const step = (id: string, state: 'running' | 'completed') => ({ + kind: 'step' as const, + stepId: id, + turnId: 't1', + ordinal: 1, + state, + frames: [], + }); + const node = diffValue( + [step('t1.1', 'completed'), step('t1.2', 'completed')], + [step('t1.1', 'completed'), step('t1.2', 'running')], + ); + expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']); + expect(node.children?.get('t1.1')?.status).toBe('unchanged'); + expect(node.children?.get('t1.2')?.status).toBe('modified'); + }); + + it('marks removed array elements by id', () => { + const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]); + expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); + expect(node.children?.get('t2')?.status).toBe('unchanged'); + }); + + it('marks whole-subtree adds/removes without descending', () => { + const added = diffValue(undefined, { nested: { deep: 1 } }); + expect(added.status).toBe('added'); + expect(added.children).toBeUndefined(); + const removed = diffValue({ nested: 1 }, undefined); + expect(removed.status).toBe('removed'); + expect(removed.children).toBeUndefined(); + }); + + it('treats type changes as leaf modifications', () => { + expect(diffValue('1', 1).status).toBe('modified'); + expect(diffValue(null, {}).status).toBe('modified'); + expect(diffValue([1], { 0: 1 }).status).toBe('modified'); + }); + + it('diffs two serialized states with meta changes visible (goal/plan fields)', () => { + const prev = serializeState(stateWith([turnItem(1)])); + const nextState: AgentState = { + ...stateWith([turnItem(1)]), + meta: { + goal: { objective: 'ship it', status: 'active' }, + modes: { plan: { reviewPath: '/tmp/plan.md' } }, + }, + }; + const node: DiffNode = diffValue(prev, serializeState(nextState)); + expect(node.children?.get('items')?.status).toBe('unchanged'); + const meta = node.children?.get('meta'); + expect(meta?.status).toBe('modified'); + expect(meta?.children?.get('goal')?.status).toBe('added'); + // Whole-subtree add: `modes` was absent before, so the block (plan + // included) is marked added without descending into children. + expect(meta?.children?.get('modes')?.status).toBe('added'); + expect(meta?.children?.get('modes')?.children).toBeUndefined(); + }); +}); + +// ---------------------------------------------------------------- serialize + +describe('serializeState', () => { + it('turns maps into sorted plain objects and sets into arrays', () => { + const state: AgentState = { + ...EMPTY_AGENT_STATE, + tasks: new Map([ + [ + 'b-task', + { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }, + ], + [ + 'a-task', + { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }, + ], + ]), + pendingInteractions: new Set(['z', 'a']), + }; + const out = serializeState(state); + expect(Object.keys(out.tasks as Record)).toEqual(['a-task', 'b-task']); + expect(out.pendingInteractions).toEqual(['a', 'z']); + expect(out.hasMoreOlder).toBe(false); + }); +}); + +// ---------------------------------------------------------------- truncate + +describe('tailTrunc', () => { + it('returns short strings unchanged', () => { + expect(tailTrunc('hello')).toBe('hello'); + expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500)); + }); + + it('keeps the tail of long strings and reports the total length', () => { + const value = 'head-padding'.repeat(100) + 'THE-TAIL'; + const out = tailTrunc(value, 50); + expect(out).toContain(`${value.length} chars total`); + expect(out.endsWith('THE-TAIL')).toBe(true); + expect(out).not.toContain('head-padding'.repeat(10)); + }); +}); + +// ---------------------------------------------------------------- trail + +describe('AuditTrail', () => { + const page = { + items: [turnItem(1)], + hasMoreOlder: false, + tasks: [], + interactions: [], + attachments: [], + todos: [], + meta: {}, + pendingInteractions: [], + }; + + it('records entries with increasing indices, timestamps, and state references', () => { + const trail = new AuditTrail(); + const s1 = stateWith([turnItem(1)]); + const s2 = stateWith([turnItem(1), turnItem(2)]); + trail.recordRest({ pageSize: 30 }, 'replace', page, s1); + trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2); + trail.recordEvent('prompt', 'hello', s2); + trail.recordReset( + { items: [], tasks: [], interactions: [], attachments: [], todos: [], prompts: [], meta: {} }, + false, + undefined, + s2, + ); + + const entries = trail.getEntries(); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]); + expect(entries[0]!.state).toBe(s1); + expect(entries[1]!.state).toBe(s2); + expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); + expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); + expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe( + true, + ); + expect(entries.every((entry) => entry.summary.length > 0)).toBe(true); + }); + + it('notifies subscribers on each record', () => { + const trail = new AuditTrail(); + let notified = 0; + const unsubscribe = trail.subscribe(() => { + notified += 1; + }); + trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + unsubscribe(); + trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + }); + + it('drops the oldest entries beyond the cap while indices keep increasing', () => { + const trail = new AuditTrail(); + for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { + trail.recordEvent('prompt', `p${i}`, EMPTY_AGENT_STATE); + } + const entries = trail.getEntries(); + expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); + expect(entries[0]!.index).toBe(10); + expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9); + }); +}); diff --git a/apps/pythinker-inspect/src/audit/diff.ts b/apps/pythinker-inspect/src/audit/diff.ts new file mode 100644 index 00000000..613d7ebe --- /dev/null +++ b/apps/pythinker-inspect/src/audit/diff.ts @@ -0,0 +1,120 @@ +/** + * Structural diff over serialized `AgentState` values (see `serialize.ts`). + * + * The audit panel diffs two adjacent, immutable states. Because the store + * is copy-on-write, untouched subtrees share references — the reference + * equality fast path below collapses them to `unchanged` without walking. + * + * Arrays of transcript entities are matched by their id field (turnId, + * stepId, frameId, …) rather than by index, so an upsert in the middle of + * the timeline does not turn into a cascade of spurious modifications. + */ + +export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; + +export interface DiffNode { + readonly status: DiffStatus; + /** Current value (`undefined` when removed). */ + readonly value: unknown; + /** Previous value (`undefined` when added). */ + readonly prev: unknown; + /** + * Object/array children — key is the object key, the entity id, or + * `#` for plain arrays. Absent on leaves and on whole-subtree + * added/removed nodes (the renderer colors the subtree as one block). + */ + readonly children?: ReadonlyMap; +} + +/** + * Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries + * both `turnId` and `stepId`, and a frame can carry `taskId` alongside its + * `frameId`; matching the wrong one mislabels the node and, worse, collides + * siblings in the children map (two steps of one turn both keyed `t1`). + */ +const ID_FIELDS = [ + 'frameId', + 'stepId', + 'interactionId', + 'attachmentId', + 'todoId', + 'markerId', + 'refId', + 'turnId', + 'taskId', +] as const; + +function elementId(element: unknown): string | undefined { + if (typeof element !== 'object' || element === null) return undefined; + for (const field of ID_FIELDS) { + const value = (element as Record)[field]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Public for the audit UI: same id-priority keying used to match array elements. */ +export { elementId }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function containerStatus(children: ReadonlyMap): DiffStatus { + for (const child of children.values()) { + if (child.status !== 'unchanged') return 'modified'; + } + return 'unchanged'; +} + +function diffObjects(prev: Record, next: Record): DiffNode { + const children = new Map(); + for (const key of Object.keys(next)) { + children.set(key, diffValue(prev[key], next[key])); + } + for (const key of Object.keys(prev)) { + if (!(key in next)) { + children.set(key, { status: 'removed', value: undefined, prev: prev[key] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode { + const children = new Map(); + const keyed = + prev.every((el) => elementId(el) !== undefined) && + next.every((el) => elementId(el) !== undefined); + + if (keyed) { + const prevById = new Map(); + for (const el of prev) prevById.set(elementId(el) as string, el); + const nextIds = new Set(); + for (const el of next) { + const id = elementId(el) as string; + nextIds.add(id); + children.set(id, diffValue(prevById.get(id), el)); + } + for (const el of prev) { + const id = elementId(el) as string; + if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el }); + } + } else { + for (let i = 0; i < next.length; i += 1) { + children.set(`#${i}`, diffValue(prev[i], next[i])); + } + for (let i = next.length; i < prev.length; i += 1) { + children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +export function diffValue(prev: unknown, next: unknown): DiffNode { + if (prev === next) return { status: 'unchanged', value: next, prev }; + if (prev === undefined) return { status: 'added', value: next, prev: undefined }; + if (next === undefined) return { status: 'removed', value: undefined, prev }; + if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next); + if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next); + return { status: 'modified', value: next, prev }; +} diff --git a/apps/pythinker-inspect/src/audit/serialize.ts b/apps/pythinker-inspect/src/audit/serialize.ts new file mode 100644 index 00000000..22336926 --- /dev/null +++ b/apps/pythinker-inspect/src/audit/serialize.ts @@ -0,0 +1,48 @@ +/** + * Serialize an `AgentState` into a plain, JSON-shaped object for the audit + * panel's state tree and structural diff. Maps become key-sorted plain + * objects (stable display order), Sets become sorted arrays; everything + * else is passed through by reference (state is immutable, so sharing is + * safe and keeps the reference-equality fast path in `diffValue` useful). + */ + +import type { + AgentState, + TranscriptAttachment, + TranscriptInteraction, + TranscriptItem, + TranscriptMeta, + TranscriptTask, + TranscriptTodo, +} from '@pymodel/transcript'; + +/** Plain-object view of an `AgentState` (Maps/Sets unwrapped). */ +export interface SerializedAgentState { + readonly items: readonly TranscriptItem[]; + readonly tasks: Record; + readonly interactions: Record; + readonly attachments: Record; + readonly todos: Record; + readonly meta: TranscriptMeta; + readonly pendingInteractions: readonly string[]; + readonly hasMoreOlder: boolean; +} + +function mapToSortedObject(map: ReadonlyMap): Record { + const out: Record = {}; + for (const key of [...map.keys()].sort()) out[key] = map.get(key) as V; + return out; +} + +export function serializeState(state: AgentState): SerializedAgentState { + return { + items: state.items, + tasks: mapToSortedObject(state.tasks), + interactions: mapToSortedObject(state.interactions), + attachments: mapToSortedObject(state.attachments), + todos: mapToSortedObject(state.todos), + meta: state.meta, + pendingInteractions: [...state.pendingInteractions].sort(), + hasMoreOlder: state.hasMoreOlder, + }; +} diff --git a/apps/pythinker-inspect/src/audit/trail.ts b/apps/pythinker-inspect/src/audit/trail.ts new file mode 100644 index 00000000..768d0c7f --- /dev/null +++ b/apps/pythinker-inspect/src/audit/trail.ts @@ -0,0 +1,175 @@ +/** + * Audit trail for the chat view's transcript channel. + * + * A pure observer: the chat pipeline (REST loads, WS frames, user actions) + * calls the `record*` methods AFTER applying each step to the real + * `TranscriptChatStore`, passing the resulting immutable `AgentState` + * reference. Replaying the trail is therefore free — every entry already + * holds the exact state the store had at that point, ready for the + * timeline slider and the structural diff. + */ + +import type { + AgentState, + AgentTranscriptSnapshot, + TranscriptOperation, +} from '@pymodel/transcript'; + +import type { TranscriptPage } from '../transcript/api'; + +export const AUDIT_TRAIL_MAX_ENTRIES = 5000; + +interface AuditEntryBase { + /** Position in the trail (stable even when old entries are dropped). */ + readonly index: number; + /** Local record time (ISO). */ + readonly at: string; + /** Store state right after this entry was applied (immutable reference). */ + readonly state: AgentState; + /** One-line summary for the timeline list. */ + readonly summary: string; +} + +export interface RestAuditEntry extends AuditEntryBase { + readonly kind: 'rest'; + readonly request: { readonly beforeTurn?: string | undefined; readonly pageSize: number }; + readonly appliedAs: 'replace' | 'prepend'; + readonly page: TranscriptPage; +} + +export interface OpsAuditEntry extends AuditEntryBase { + readonly kind: 'ops'; + /** Envelope timestamp (server send time) when present. */ + readonly envelopeAt?: string | undefined; + readonly ops: readonly TranscriptOperation[]; + /** live = applied immediately; buffered = held during a REST refresh; flushed = replayed after one; catchup = fetched via the ops catch-up endpoint after a seq gap. */ + readonly delivery: 'live' | 'buffered' | 'flushed' | 'catchup'; +} + +export interface ResetAuditEntry extends AuditEntryBase { + readonly kind: 'reset'; + readonly envelopeAt?: string | undefined; + readonly snapshot: AgentTranscriptSnapshot; + readonly hasMoreOlder: boolean; +} + +export interface EventAuditEntry extends AuditEntryBase { + readonly kind: 'event'; + readonly event: 'ack-refresh' | 'resync' | 'gap' | 'prompt' | 'cancel'; + readonly detail?: string | undefined; +} + +export type AuditEntry = RestAuditEntry | OpsAuditEntry | ResetAuditEntry | EventAuditEntry; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** Entry payload accepted by `push` (index/at are filled in there). */ +type AuditEntryInput = DistributiveOmit; + +function summarizeOps(ops: readonly TranscriptOperation[]): string { + const counts = new Map(); + for (const op of ops) counts.set(op.op, (counts.get(op.op) ?? 0) + 1); + return [...counts.entries()].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(', '); +} + +export class AuditTrail { + private entryList: AuditEntry[] = []; + private nextIndex = 0; + private readonly listeners = new Set<() => void>(); + + /** `useSyncExternalStore`-compatible subscribe. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getEntries(): readonly AuditEntry[] { + return this.entryList; + } + + recordRest( + request: RestAuditEntry['request'], + appliedAs: RestAuditEntry['appliedAs'], + page: TranscriptPage, + state: AgentState, + ): void { + const cursor = request.beforeTurn !== undefined ? `?before_turn=${request.beforeTurn}` : ''; + this.push({ + kind: 'rest', + request, + appliedAs, + page, + state, + summary: `GET transcript${cursor} → ${page.items.length} items (${appliedAs})`, + }); + } + + recordOps( + ops: readonly TranscriptOperation[], + delivery: OpsAuditEntry['delivery'], + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'ops', + ops, + delivery, + envelopeAt, + state, + summary: `${ops.length} ops (${summarizeOps(ops)}) [${delivery}]`, + }); + } + + recordReset( + snapshot: AgentTranscriptSnapshot, + hasMoreOlder: boolean, + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'reset', + snapshot, + hasMoreOlder, + envelopeAt, + state, + summary: `reset snapshot (${snapshot.items.length} items) — ignored by chat store`, + }); + } + + recordEvent( + event: EventAuditEntry['event'], + detail: string | undefined, + state: AgentState, + ): void { + const label = + event === 'ack-refresh' + ? 'subscribe ack → REST refresh' + : event === 'resync' + ? 'resync_required → REST refresh' + : event === 'gap' + ? 'append gap → REST refresh' + : event === 'prompt' + ? 'prompt sent' + : 'cancel sent'; + this.push({ + kind: 'event', + event, + detail, + state, + summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label, + }); + } + + private push(entry: AuditEntryInput): void { + const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry; + this.nextIndex += 1; + const kept = + this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES + ? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1) + : this.entryList; + this.entryList = [...kept, full]; + for (const listener of this.listeners) listener(); + } +} diff --git a/apps/pythinker-inspect/src/audit/truncate.ts b/apps/pythinker-inspect/src/audit/truncate.ts new file mode 100644 index 00000000..69a7c137 --- /dev/null +++ b/apps/pythinker-inspect/src/audit/truncate.ts @@ -0,0 +1,14 @@ +/** + * Tail-preserving string truncation for the audit panel: long values are + * rendered with their total length plus the LAST `keep` characters (the + * tail is where streaming text, tool output, and prompts carry the newest + * information). Rendering-only — the underlying state is never truncated, + * and no field is ever dropped. + */ + +export const TRUNCATE_KEEP = 500; + +export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string { + if (value.length <= keep) return value; + return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`; +} diff --git a/apps/pythinker-inspect/src/channel/channel.test.ts b/apps/pythinker-inspect/src/channel/channel.test.ts new file mode 100644 index 00000000..fdf2de26 --- /dev/null +++ b/apps/pythinker-inspect/src/channel/channel.test.ts @@ -0,0 +1,199 @@ +/** + * Channel layer unit tests — `ProxyChannel` URL/envelope semantics, + * `makeProxy` routing, the HTTP-only `listen` failure, and the debug-surface + * probe (`/api/v1/debug` is the only RPC surface; there is no v2 fallback). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { Event, IChannel } from './channel'; +import { probeDebugSurface } from './channels'; +import { createInspectClient } from './client'; +import { RPCError } from './errors'; +import { + fetchAgentRuntimeBinding, + fetchSessionWorkspaceAssociation, + fetchWorkspaceSnapshot, +} from '../snapshots/api'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; + +const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' }); + +function fakeFetch(envelope: unknown) { + const calls: { url: string; init?: RequestInit }[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { json: async () => envelope }; + }) as unknown as typeof fetch; + return { calls, fetchImpl }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('ProxyChannel.call', () => { + it('POSTs the command to the service base URL; no body and no header without args/token', async () => { + const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' })); + const channel = new ProxyChannel({ + baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService', + fetch: fetchImpl, + }); + const result = await channel.call('getModel', []); + expect(result).toEqual({ id: 's1' }); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe( + 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService/getModel', + ); + expect(calls[0]!.init?.method).toBe('POST'); + expect(calls[0]!.init?.body).toBeUndefined(); + }); + + it('sends the complete argument array as the JSON body, plus the bearer token', async () => { + const { calls, fetchImpl } = fakeFetch(ok(null)); + const channel = new ProxyChannel({ + baseUrl: 'http://h:2/api/v1/debug/configService', + token: 'tok', + fetch: fetchImpl, + }); + await channel.call('set', ['workspace', { theme: 'dark' }]); + expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }])); + expect(calls[0]!.init?.headers).toEqual({ + 'content-type': 'application/json', + authorization: 'Bearer tok', + }); + }); + + it('unwraps the envelope and throws RPCError on a non-zero code', async () => { + const { fetchImpl } = fakeFetch({ + code: 40401, + msg: 'session not found', + data: null, + request_id: 'r2', + details: { id: 's9' }, + }); + const channel = new ProxyChannel({ + baseUrl: 'http://h:3/api/v1/debug/sessionIndex', + fetch: fetchImpl, + }); + const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error); + expect(err).toBeInstanceOf(RPCError); + expect((err as RPCError).code).toBe(40401); + expect((err as RPCError).message).toBe('session not found'); + expect((err as RPCError).details).toEqual({ id: 's9' }); + }); +}); + +describe('makeProxy', () => { + interface DemoService { + read(id: string, n: number): Promise; + onDidChangeMetadata: Event<{ title: string }>; + } + + it('routes methods to call and onXxx members to listen', async () => { + const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] }; + const channel: IChannel = { + call: async (command: string, args?: unknown[]): Promise => { + seen.calls.push([command, args ?? []]); + return 'ret' as T; + }, + listen: (event: string): Event => { + seen.listens.push(event); + return () => ({ dispose: () => {} }); + }, + }; + const svc = makeProxy(channel); + await expect(svc.read('a', 1)).resolves.toBe('ret'); + expect(seen.calls).toEqual([['read', ['a', 1]]]); + const d = svc.onDidChangeMetadata(() => {}); + d.dispose(); + expect(seen.listens).toEqual(['onDidChangeMetadata']); + }); +}); + +describe('ProxyChannel.listen', () => { + it('throws: the debug surface is HTTP-only, there is no event transport', () => { + const channel = new ProxyChannel({ + baseUrl: 'http://h:4/api/v1/debug/configService', + fetch: fakeFetch(ok(null)).fetchImpl, + }); + expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/); + }); +}); + +describe('business snapshots', () => { + it('uses explicit workspace, session association, and agent binding routes', async () => { + const calls: string[] = []; + vi.stubGlobal('fetch', async (url: string | URL) => { + const value = String(url); + calls.push(value); + if (value.endsWith('/workspace/w%201/snapshot')) { + return { json: async () => ok({ metadata: { id: 'w 1' } }) }; + } + if (value.endsWith('/session/s%201/association')) { + return { json: async () => ok({ sessionId: 's 1', workspaceId: 'w 1', cwd: '/work' }) }; + } + return { + json: async () => ok({ + binding: { workspaceId: 'w 1', runtimeId: 'remote' }, + available: true, + runtime: { runtimeId: 'remote', generation: 'g2', status: 'ready', capabilities: ['process'] }, + }), + }; + }); + const client = createInspectClient({ url: 'http://h:9', token: 'tok' }); + + await expect(fetchWorkspaceSnapshot(client, 'w 1')).resolves.toMatchObject({ metadata: { id: 'w 1' } }); + await expect(fetchSessionWorkspaceAssociation(client, 's 1')).resolves.toMatchObject({ workspaceId: 'w 1' }); + await expect(fetchAgentRuntimeBinding(client, 's 1', 'main')).resolves.toMatchObject({ + binding: { runtimeId: 'remote' }, + runtime: { generation: 'g2' }, + }); + expect(calls).toEqual([ + 'http://h:9/api/v1/debug/workspace/w%201/snapshot', + 'http://h:9/api/v1/debug/session/s%201/association', + 'http://h:9/api/v1/debug/session/s%201/agent/main/runtime-binding', + ]); + }); +}); + +describe('probeDebugSurface', () => { + function stubProbeFetch(impl: (url: string, init?: RequestInit) => unknown) { + const calls: { url: string; init?: RequestInit }[] = []; + vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return impl(String(url), init); + }); + return calls; + } + + it('resolves when /api/v1/debug/channels answers a zero-code envelope (with bearer header)', async () => { + const calls = stubProbeFetch(() => ({ ok: true, json: async () => ({ code: 0 }) })); + await expect( + probeDebugSurface({ baseUrl: 'http://h:5/', token: 'tok' }), + ).resolves.toBeUndefined(); + expect(calls[0]!.url).toBe('http://h:5/api/v1/debug/channels'); + expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' }); + }); + + it('throws a --debug-endpoints hint when the surface is not mounted (HTTP 404)', async () => { + stubProbeFetch(() => ({ ok: false, status: 404 })); + await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow(/--debug-endpoints/); + }); + + it('throws an unreachable-server error when fetch itself fails', async () => { + vi.stubGlobal('fetch', async () => { + throw new Error('ECONNREFUSED'); + }); + await expect(probeDebugSurface({ baseUrl: 'http://h:7' })).rejects.toThrow(/cannot reach/); + }); + + it('throws a token hint when the envelope carries a non-zero code', async () => { + stubProbeFetch(() => ({ + ok: true, + json: async () => ({ code: 40101, msg: 'unauthorized' }), + })); + await expect(probeDebugSurface({ baseUrl: 'http://h:8' })).rejects.toThrow(/bearer token/); + }); +}); diff --git a/apps/pythinker-inspect/src/channel/channel.ts b/apps/pythinker-inspect/src/channel/channel.ts new file mode 100644 index 00000000..c2ca7151 --- /dev/null +++ b/apps/pythinker-inspect/src/channel/channel.ts @@ -0,0 +1,45 @@ +/** + * Transport-agnostic channel contract for the debug-RPC client — the + * old-klient / VS Code `ProxyChannel` model: the channel is bound to one + * Service (the URL carries the scope + the Service's decorator id) and + * `command` is the method name, invoked by reflection on the server. + * + * `listen` is kept for contract completeness (Service `onXxx` emitters map to + * it), but the `/api/v1/debug` surface is HTTP-only — the v2 event socket + * (`/api/v2/ws`) that used to serve it was removed — so the HTTP channel's + * `listen` throws and panels fetch on demand instead. + */ + +import type { ServiceIdentifier } from '@pymodel/agent-core-v2/_base/di/instantiation'; + +export interface IDisposable { + dispose(): void; +} + +export interface Event { + (listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable; +} + +/** The client-facing channel contract. Calls always carry the complete argument array. */ +export interface IChannel { + call(command: string, args?: unknown[]): Promise; + listen(event: string, arg?: unknown): Event; +} + +/** A wire Service reference: a DI decorator (stringifies to the wire channel + * name) or the raw channel name as a string. */ +export type ServiceRef = ServiceIdentifier | string; + +/** + * Remote view of a Service contract: every method becomes an async wire call; + * `onXxx` event members (`Event` — callables returning `IDisposable`) stay + * subscribable events; plain non-function members become zero-arg property + * reads (the dispatcher returns non-function members as-is). + */ +export type ServiceProxy = { + [K in keyof T]: T[K] extends (...args: infer A) => infer R + ? R extends IDisposable + ? T[K] + : (...args: A) => Promise> + : () => Promise>; +}; diff --git a/apps/pythinker-inspect/src/channel/channels.ts b/apps/pythinker-inspect/src/channel/channels.ts new file mode 100644 index 00000000..5187d93e --- /dev/null +++ b/apps/pythinker-inspect/src/channel/channels.ts @@ -0,0 +1,120 @@ +/** + * Protocol loading — the debug surface's `GET /api/v1/debug/channels` + * endpoint is the server's self-description of every wire-callable Service + * (name, scope, domain, methods + properties), whitelist-free. Paired with + * `serviceByName`, each descriptor materializes 1:1 into a typed proxy of + * the channel layer: same channel name, same scope route, methods invoked by + * reflection. + * + * `/api/v1/debug` is the ONLY RPC surface this app talks to (mounted by + * kap-server with `--debug-endpoints` on a loopback bind); the v2 surface + * (`/api/v2` + `/api/v2/ws`) was removed server-side, so there is no + * fallback — `probeDebugSurface` fails the connection with a clear error. + */ + +import { createDecorator } from '@pymodel/agent-core-v2/_base/di/instantiation'; + +import type { ServiceProxy } from './channel'; +import { DEBUG_RPC_BASE, type InspectClient } from './client'; +import { RPCError } from './errors'; + +/** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */ +export type ChannelScope = 'app' | 'session' | 'agent'; + +/** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v1/debug/channels`). */ +export interface ChannelDescriptor { + readonly name: string; + readonly scope: ChannelScope; + readonly domain: string; + readonly methods: readonly { + readonly name: string; + readonly kind: 'method' | 'property'; + readonly arity: number; + readonly params: string; + }[]; +} + +/** Fetch the dynamic channel list (unwrapped from the project envelope). */ +export async function fetchChannelDescriptors( + client: InspectClient, +): Promise { + const headers: Record = {}; + if (client.token !== undefined && client.token !== '') { + headers['authorization'] = `Bearer ${client.token}`; + } + const res = await fetch(`${client.baseUrl}${DEBUG_RPC_BASE}/channels`, { headers }); + const envelope = (await res.json()) as { + code: number; + msg: string; + data: readonly ChannelDescriptor[]; + }; + if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg); + return envelope.data; +} + +/** + * Verify the server mounts the debug RPC surface before the client is built. + * Resolves silently when `GET /api/v1/debug/channels` answers with a + * zero-code envelope; otherwise throws an `Error` whose message tells the + * user exactly what is wrong (unreachable server, surface not mounted → + * start kap-server with `--debug-endpoints`, or a rejected probe → check the + * token). + */ +export async function probeDebugSurface(options: { + readonly baseUrl: string; + readonly token?: string; +}): Promise { + const headers: Record = {}; + if (options.token !== undefined && options.token !== '') { + headers['authorization'] = `Bearer ${options.token}`; + } + const url = `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`; + let res: Response; + try { + res = await fetch(url, { headers }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error(`cannot reach ${options.baseUrl} — is kap-server running? (${reason})`); + } + if (!res.ok) { + throw new Error( + `GET ${DEBUG_RPC_BASE}/channels answered HTTP ${res.status} — this server does not ` + + 'mount the debug RPC surface. Start kap-server with --debug-endpoints on a loopback bind.', + ); + } + const envelope = (await res.json()) as { code?: number; msg?: string }; + if (envelope.code !== 0) { + throw new Error( + `the debug surface rejected the probe (code ${envelope.code ?? '?'}: ${ + envelope.msg ?? 'no message' + }) — check the bearer token.`, + ); + } +} + +export interface ServiceTarget { + readonly scope: ChannelScope; + readonly sessionId?: string; + readonly agentId?: string; +} + +/** + * Resolve a Service proxy by wire channel name. The DI decorator registry keys + * identifiers by name, so re-creating the decorator resolves to the same token + * the server channel registry created — the name is the wire channel, which is + * all the proxy uses. Returns `undefined` when the target scope needs a + * workspace/session/agent id that isn't available. + */ +export function serviceByName( + client: InspectClient, + name: string, + target: ServiceTarget, +): ServiceProxy | undefined { + const id = createDecorator(name); + if (target.scope === 'app') return client.core(id); + if (target.sessionId === undefined) return undefined; + const base = client.session(target.sessionId); + if (target.scope === 'session') return base.service(id); + if (target.agentId === undefined) return undefined; + return base.agent(target.agentId).service(id); +} diff --git a/apps/pythinker-inspect/src/channel/client.ts b/apps/pythinker-inspect/src/channel/client.ts new file mode 100644 index 00000000..04eb6a76 --- /dev/null +++ b/apps/pythinker-inspect/src/channel/client.ts @@ -0,0 +1,81 @@ +/** + * Inspect client — the app's `/api/v1/debug` entry point, in the old-klient + * VS Code `ProxyChannel` model: a multi-level scope entry (`core` / + * `workspace` / `session` / `agent`) whose every Service handle is a + * `makeProxy`-materialized typed proxy over a service-bound HTTP channel. + * + * const client = createInspectClient({ url: 'http://127.0.0.1:58627' }); + * await client.core(ISessionIndex).listRecent({}); + * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1'); + * await client.session('s1').service(ISessionMetadata).read(); + * await client.session('s1').agent('main').service(IAgentLoopService).cancelFromUser(); + * + * The `agent-core-v2` service token is the whole key: its type parameter `T` + * types the returned proxy, and its decorator id (`String(id)`) is the channel + * name in the URL. Calls ride HTTP (`ProxyChannel`). There is no event + * transport: the v2 socket (`/api/v2/ws`) that used to carry Service `onXxx` + * emitters and scope event streams was removed server-side, so the UI reads + * Service state on demand. (The transcript's own `/api/v1/ws` delta channel + * lives in `src/transcript/` and is unrelated to this client.) + */ + +import type { ServiceProxy, ServiceRef } from './channel'; +import { makeProxy } from './proxy'; +import { ProxyChannel } from './proxyChannel'; + +/** The dev server's whitelist-free debug surface (`--debug-endpoints` + loopback). */ +export const DEBUG_RPC_BASE = '/api/v1/debug' as const; + +export interface InspectAgentHandle { + service(id: ServiceRef): ServiceProxy; +} + +export interface InspectSessionHandle extends InspectAgentHandle { + agent(agentId: string): InspectAgentHandle; +} + +export interface InspectClient { + /** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */ + readonly baseUrl: string; + /** Bearer token in use, when any. */ + readonly token?: string; + core(id: ServiceRef): ServiceProxy; + session(sessionId: string): InspectSessionHandle; +} + +export interface InspectClientOptions { + /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ + readonly url: string; + /** Optional bearer token. */ + readonly token?: string; +} + +export function createInspectClient(options: InspectClientOptions): InspectClient { + const url = options.url.replace(/\/$/, ''); + + /** Materialize a typed proxy for one Service on one scope binding. */ + function proxy(scopePath: string, id: ServiceRef): ServiceProxy { + const service = String(id); + return makeProxy( + new ProxyChannel({ + baseUrl: `${url}${DEBUG_RPC_BASE}${scopePath}/${service}`, + token: options.token, + }), + ); + } + + return { + baseUrl: url, + token: options.token, + core: (id) => proxy('', id), + session: (sessionId) => { + const scopePath = `/session/${encodeURIComponent(sessionId)}`; + return { + service: (id) => proxy(scopePath, id), + agent: (agentId) => ({ + service: (subId) => proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, subId), + }), + }; + }, + }; +} diff --git a/apps/pythinker-inspect/src/channel/errors.ts b/apps/pythinker-inspect/src/channel/errors.ts new file mode 100644 index 00000000..1748e9ac --- /dev/null +++ b/apps/pythinker-inspect/src/channel/errors.ts @@ -0,0 +1,16 @@ +/** + * Client-side RPC error surfaced when the debug-RPC envelope carries a + * non-zero `code`. Mirrors the server envelope (`{ code, msg, data, + * request_id }`) — the numeric `code` is the stable branch key across the + * wire, not `instanceof`. + */ +export class RPCError extends Error { + constructor( + readonly code: number, + message: string, + readonly details?: unknown, + ) { + super(message); + this.name = 'RPCError'; + } +} diff --git a/apps/pythinker-inspect/src/channel/index.ts b/apps/pythinker-inspect/src/channel/index.ts new file mode 100644 index 00000000..4dadf528 --- /dev/null +++ b/apps/pythinker-inspect/src/channel/index.ts @@ -0,0 +1,7 @@ +export * from './channel'; +export * from './channels'; +export * from './client'; +export * from './errors'; +export * from './proxy'; +export * from './proxyChannel'; +export * from './wsLike'; diff --git a/apps/pythinker-inspect/src/channel/proxy.ts b/apps/pythinker-inspect/src/channel/proxy.ts new file mode 100644 index 00000000..5a0eb751 --- /dev/null +++ b/apps/pythinker-inspect/src/channel/proxy.ts @@ -0,0 +1,21 @@ +/** + * Typed proxy turning an `IChannel` (bound to one Service) into a value + * satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`. + * + * Members named `onUpperCase` become channel events; every other property access + * becomes a function forwarding its complete argument array to `channel.call` + * (the dispatcher also answers property reads this way). The shared interface + * `T` is the whole contract, with no per-method allowlist or renaming. + */ + +import type { IChannel, ServiceProxy } from './channel'; + +export function makeProxy(channel: IChannel): ServiceProxy { + return new Proxy({} as ServiceProxy, { + get(_target, prop) { + if (typeof prop !== 'string') return undefined; + if (/^on[A-Z]/.test(prop)) return channel.listen(prop); + return (...args: unknown[]) => channel.call(prop, args); + }, + }); +} diff --git a/apps/pythinker-inspect/src/channel/proxyChannel.ts b/apps/pythinker-inspect/src/channel/proxyChannel.ts new file mode 100644 index 00000000..8ab9795d --- /dev/null +++ b/apps/pythinker-inspect/src/channel/proxyChannel.ts @@ -0,0 +1,74 @@ +/** + * `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to + * kap-server's `/api/v1/debug` HTTP surface. Every call `POST`s the method + * name to the Service base URL with the complete argument array as the JSON + * body, then unwraps the project envelope: a non-zero `code` throws + * `RPCError`, otherwise `data` is returned. Non-function members answer as + * property reads through the same route (the dispatcher returns them as-is). + * + * `listen` cannot be served by HTTP: the v2 event socket (`/api/v2/ws`) that + * used to back Service emitter events was removed, so `listen` throws and + * the UI fetches Service state on demand instead. + */ + +import type { Event, IChannel } from './channel'; +import { RPCError } from './errors'; + +interface Envelope { + readonly code: number; + readonly msg: string; + readonly data: T; + readonly request_id: string; + readonly details?: unknown; +} + +export interface ProxyChannelOptions { + /** Service base URL, e.g. `http://127.0.0.1:58627/api/v1/debug[/session/:sid[/agent/:aid]]/:service`. */ + readonly baseUrl: string; + /** Optional bearer token. */ + readonly token?: string; + /** `fetch` implementation; defaults to the global `fetch`. */ + readonly fetch?: typeof fetch; +} + +export class ProxyChannel implements IChannel { + private readonly baseUrl: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + + constructor(opts: ProxyChannelOptions) { + this.baseUrl = opts.baseUrl.replace(/\/$/, ''); + this.token = opts.token; + // Bind the global fetch: browsers throw "Illegal invocation" when the + // native function is invoked with a non-Window receiver. + this.fetchImpl = opts.fetch ?? fetch.bind(globalThis); + } + + async call(command: string, args: unknown[] = []): Promise { + const headers: Record = {}; + let body: string | undefined; + if (args.length > 0) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(args); + } + if (this.token !== undefined) { + headers['authorization'] = `Bearer ${this.token}`; + } + const res = await this.fetchImpl(`${this.baseUrl}/${command}`, { + method: 'POST', + headers, + body, + }); + const envelope = (await res.json()) as Envelope; + if (envelope.code !== 0) { + throw new RPCError(envelope.code, envelope.msg, envelope.details); + } + return envelope.data; + } + + listen(_event: string): Event { + throw new Error( + 'events are not supported on this channel (HTTP-only; the /api/v2/ws event socket was removed)', + ); + } +} diff --git a/apps/pythinker-inspect/src/channel/wsLike.ts b/apps/pythinker-inspect/src/channel/wsLike.ts new file mode 100644 index 00000000..440a9e8c --- /dev/null +++ b/apps/pythinker-inspect/src/channel/wsLike.ts @@ -0,0 +1,20 @@ +/** + * Minimal DOM-compatible WebSocket surface shared by the app's socket + * clients (today only the transcript `/api/v1/ws` client). Coding against + * this structural type keeps the clients testable with an injected fake; + * the default is the global `WebSocket` (browsers, Node ≥ 21). + */ +export interface WsLike { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener( + type: 'open' | 'message' | 'close' | 'error', + listener: (event: never) => void, + ): void; +} + +export interface WsLikeCtor { + new (url: string, protocols?: string | string[]): WsLike; + readonly OPEN: number; +} diff --git a/apps/pythinker-inspect/src/components/AppServicesView.tsx b/apps/pythinker-inspect/src/components/AppServicesView.tsx new file mode 100644 index 00000000..98ae9aa3 --- /dev/null +++ b/apps/pythinker-inspect/src/components/AppServicesView.tsx @@ -0,0 +1,27 @@ +/** + * App Services view — the app-scope (server-level) Service reflection as a + * standalone rail view. Postman-style three-pane layout + * (`ScopePanelsScrollspy`): the Service list on the left, every Service's + * methods expanded in one continuously scrolling column in the middle + * (scroll position and left-side highlight kept in sync), and a + * request/response call history on the right. The proxies resolve on the + * `core` route, so this view works before any session is selected. + */ + +import { useCallback } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ScopePanelsScrollspy } from './ServicePanels'; + +export function AppServicesView() { + const { klient } = useConnection(); + const proxyFor = useCallback( + (name: string): AnyService | null => + serviceByName(klient, name, { scope: 'app' }) ?? null, + [klient], + ); + + return ; +} diff --git a/apps/pythinker-inspect/src/components/BashParserView.tsx b/apps/pythinker-inspect/src/components/BashParserView.tsx new file mode 100644 index 00000000..07963f70 --- /dev/null +++ b/apps/pythinker-inspect/src/components/BashParserView.tsx @@ -0,0 +1,279 @@ +/** + * Bash Parser view — a playground for the App-scope `IBashParserService` + * (the `bashParser` domain, a thin adapter over `@pymodel/tree-sitter-bash`). + * + * left: the bash source textarea plus the parse budget (timeoutMs / + * maxNodes, empty = package default); the `examples…` dropdown + * fills the textarea with curated snippets from the parser's own + * differential fixtures; + * right: the parse result — status badges (hasError / aborted / node + * count) and the syntax tree, one row per node with its type, + * UTF-16 range and (for leaves) the source text. Anonymous tokens + * are dimmed; rows expand/collapse. + * + * Parsing is debounced off the textarea and rides the same `/api/v1/debug` + * channel as every other panel (`klient.core(IBashParserService).parse`) — + * the budgeted parse never throws, `{ ok: false }` means budget exhaustion. + */ + +import { useEffect, useState } from 'react'; + +import { + IBashParserService, + type BashParseResult, + type BashSyntaxNode, +} from '@pymodel/agent-core-v2/app/bashParser/bashParser'; + +import { useConnection } from '../connection'; +import { Badge, errorMessage } from '../ui'; + +const DEFAULT_SOURCE = `if [ -f config.sh ]; then + source config.sh && echo "loaded" | tee -a setup.log +else + echo "missing" >&2; exit 1 +fi +`; + +const PARSE_DEBOUNCE_MS = 300; + +/** + * Quick-fill examples, adapted from the parser's own differential fixtures + * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one + * exercises a distinct area of the grammar. The last three probe the + * non-happy paths: deep nesting (a left-associative arithmetic chain, the + * case that once overflowed the DTO conversion) and the error-recovery + * paths that set `hasError`. + */ +const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [ + { + name: 'deep arithmetic (1000 operands)', + // A thousand left-nested binary_expression levels. Deeper chains parse + // fine in-process, but past ~2500 levels the JSON RPC transport itself + // cannot serialize the tree (V8 call-stack limit in JSON.stringify). + source: `echo $((${'1+'.repeat(1000)}1))`, + }, + { + name: 'pipeline & redirects', + source: `git log --oneline | head -20 | tee /tmp/log.txt +find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO +cmd <<< "$input" >out.txt 2>&1 +`, + }, + { + name: 'case statement', + source: `case $x in + a) echo A ;; + b|c) echo BC ;& + foo*|bar) echo match ;; + [a-z]) echo lower ;; + *) echo other ;; +esac +`, + }, + { + name: 'heredoc', + source: `foo() { cat < sum + countNodes(child), 0); +} + +export function BashParserView() { + const { klient } = useConnection(); + const [source, setSource] = useState(DEFAULT_SOURCE); + const [timeoutMs, setTimeoutMs] = useState(''); + const [maxNodes, setMaxNodes] = useState(''); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const handle = setTimeout(() => { + klient + .core(IBashParserService) + .parse(source, { + timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs), + maxNodes: maxNodes === '' ? undefined : Number(maxNodes), + }) + .then(setResult, (e: unknown) => { + setResult(null); + setError(errorMessage(e)); + }); + }, PARSE_DEBOUNCE_MS); + return () => { + clearTimeout(handle); + }; + }, [klient, source, timeoutMs, maxNodes]); + + const nodeCount = result !== null && result.ok ? countNodes(result.root) : null; + + return ( +
+
+
+ bash source + +
+ + +
+ - -
-
- - - diff --git a/apps/pythinker-web/src/components/Sidebar.vue b/apps/pythinker-web/src/components/Sidebar.vue index 53a37a8b..b0b3c46d 100644 --- a/apps/pythinker-web/src/components/Sidebar.vue +++ b/apps/pythinker-web/src/components/Sidebar.vue @@ -3,41 +3,100 @@ The old workspace rail and workspace tabs have been removed; workspace switching, folding and renaming all live in the group header. --> diff --git a/apps/pythinker-web/src/components/SlashMenu.vue b/apps/pythinker-web/src/components/SlashMenu.vue deleted file mode 100644 index aaee13b9..00000000 --- a/apps/pythinker-web/src/components/SlashMenu.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - diff --git a/apps/pythinker-web/src/components/StatusPanel.vue b/apps/pythinker-web/src/components/StatusPanel.vue deleted file mode 100644 index 46d71c9f..00000000 --- a/apps/pythinker-web/src/components/StatusPanel.vue +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - - - diff --git a/apps/pythinker-web/src/components/TasksPane.vue b/apps/pythinker-web/src/components/TasksPane.vue deleted file mode 100644 index ec5a1ac9..00000000 --- a/apps/pythinker-web/src/components/TasksPane.vue +++ /dev/null @@ -1,358 +0,0 @@ - - - - - - - diff --git a/apps/pythinker-web/src/components/Terminal.vue b/apps/pythinker-web/src/components/Terminal.vue index accdf153..10b19092 100644 --- a/apps/pythinker-web/src/components/Terminal.vue +++ b/apps/pythinker-web/src/components/Terminal.vue @@ -6,6 +6,7 @@ import type { Terminal as XTerm, ITheme } from '@xterm/xterm'; import { computed, nextTick, onMounted, onUnmounted, ref, toRef, watch } from 'vue'; import { useIsDark } from '../composables/useIsDark'; import { useTerminal } from '../composables/useTerminal'; +import Button from './ui/Button.vue'; const props = defineProps<{ sessionId: string }>(); @@ -175,9 +176,9 @@ onUnmounted(() => { exited
- - - + + +
@@ -214,7 +215,7 @@ onUnmounted(() => { gap: 7px; color: var(--dim); font-family: var(--mono); - font-size: calc(var(--ui-font-size) - 3px); + font-size: var(--text-base); } .terminal-dot { width: 7px; @@ -224,7 +225,7 @@ onUnmounted(() => { flex: none; } .terminal-dot.on { - background: var(--ok); + background: var(--color-success); } .terminal-cwd { min-width: 0; @@ -234,7 +235,7 @@ onUnmounted(() => { color: var(--muted); } .terminal-readonly { - color: var(--warn); + color: var(--color-warning); } .terminal-actions { display: flex; @@ -242,23 +243,6 @@ onUnmounted(() => { gap: 5px; flex: none; } -.terminal-btn { - border: 1px solid var(--line); - border-radius: 6px; - background: var(--bg); - color: var(--dim); - font-family: var(--mono); - font-size: calc(var(--ui-font-size) - 3px); - padding: 3px 7px; - cursor: pointer; -} -.terminal-btn:hover { - background: var(--soft); - color: var(--ink); -} -.terminal-btn.primary { - color: var(--blue2); -} .terminal-surface { position: relative; flex: 1; @@ -286,6 +270,6 @@ onUnmounted(() => { text-align: center; } .terminal-overlay.error { - color: var(--err); + color: var(--color-danger); } diff --git a/apps/pythinker-web/src/components/ThinkingPanel.vue b/apps/pythinker-web/src/components/ThinkingPanel.vue deleted file mode 100644 index 7303f18b..00000000 --- a/apps/pythinker-web/src/components/ThinkingPanel.vue +++ /dev/null @@ -1,127 +0,0 @@ - - - - - - - diff --git a/apps/pythinker-web/src/components/TodoCard.vue b/apps/pythinker-web/src/components/TodoCard.vue deleted file mode 100644 index ecfc6c0a..00000000 --- a/apps/pythinker-web/src/components/TodoCard.vue +++ /dev/null @@ -1,194 +0,0 @@ - - - - - - - diff --git a/apps/pythinker-web/src/components/ToolCall.vue b/apps/pythinker-web/src/components/ToolCall.vue deleted file mode 100644 index 735ec60a..00000000 --- a/apps/pythinker-web/src/components/ToolCall.vue +++ /dev/null @@ -1,367 +0,0 @@ - - - - - - diff --git a/apps/pythinker-web/src/components/WarningToasts.vue b/apps/pythinker-web/src/components/WarningToasts.vue index f3164a20..cbf92a2f 100644 --- a/apps/pythinker-web/src/components/WarningToasts.vue +++ b/apps/pythinker-web/src/components/WarningToasts.vue @@ -4,6 +4,8 @@ import { onUnmounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import type { AppNotice, AppWarning } from '../api/types'; +import { copyTextToClipboard } from '../lib/clipboard'; +import Toast from './ui/Toast.vue'; const props = defineProps<{ warnings: AppWarning[] }>(); const emit = defineEmits<{ dismiss: [index: number] }>(); @@ -120,8 +122,8 @@ function toggleDetails(toast: ToastItem): void { } async function copyDetails(toast: ToastItem): Promise { - if (!navigator.clipboard?.writeText) return; - await navigator.clipboard.writeText(formatWarningForCopy(toast.warning)); + const ok = await copyTextToClipboard(formatWarningForCopy(toast.warning)); + if (!ok) return; toast.copied = true; const prev = copiedTimers.get(toast.id); if (prev) clearTimeout(prev); @@ -187,41 +189,34 @@ onUnmounted(() => { diff --git a/apps/pythinker-web/src/components/WindowControls.vue b/apps/pythinker-web/src/components/WindowControls.vue index 295eb12f..0a910500 100644 --- a/apps/pythinker-web/src/components/WindowControls.vue +++ b/apps/pythinker-web/src/components/WindowControls.vue @@ -8,7 +8,7 @@ import { useI18n } from 'vue-i18n'; const { t } = useI18n(); -const isWindows = computed(() => document.documentElement.dataset['desktopPlatform'] === 'win32'); +const isWindows = computed(() => window.pythinkerDesktop?.platform === 'win32'); function minimize(): void { void window.pythinkerDesktop?.minimizeWindow(); diff --git a/apps/pythinker-web/src/components/WorkspaceGroup.vue b/apps/pythinker-web/src/components/WorkspaceGroup.vue new file mode 100644 index 00000000..8d4f0a05 --- /dev/null +++ b/apps/pythinker-web/src/components/WorkspaceGroup.vue @@ -0,0 +1,392 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/chat/ActivityNotice.vue b/apps/pythinker-web/src/components/chat/ActivityNotice.vue new file mode 100644 index 00000000..7c803339 --- /dev/null +++ b/apps/pythinker-web/src/components/chat/ActivityNotice.vue @@ -0,0 +1,34 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/chat/AgentDetailPanel.vue b/apps/pythinker-web/src/components/chat/AgentDetailPanel.vue new file mode 100644 index 00000000..e478434c --- /dev/null +++ b/apps/pythinker-web/src/components/chat/AgentDetailPanel.vue @@ -0,0 +1,266 @@ + + + + + + + diff --git a/apps/pythinker-web/src/components/chat/ApprovalCard.vue b/apps/pythinker-web/src/components/chat/ApprovalCard.vue new file mode 100644 index 00000000..a4f43c0c --- /dev/null +++ b/apps/pythinker-web/src/components/chat/ApprovalCard.vue @@ -0,0 +1,582 @@ + + + +